-
Notifications
You must be signed in to change notification settings - Fork 0
mail plugin
Sometimes when a script has finished, you and your team would want to receive an email about its success/failure. elAPI offers a no-code solution for this: the mail plugin. In a
nutshell, the mail plugin will scan the logs when a script/plugin is done to look for pre-configured trigger
conditions, and if found, it will send an email. The trigger conditions can be atomic and scope-based, i.e., an email
can be sent only when a specific plugin task/command is finished, and/or when a matching log state and/or a matching log pattern is found.
The mail plugin is not enabled by default. To enable it, install elAPI with the optional dependency mail:
pip install elapi[mail] or uv add elapi[mail]. Once it's enabled, running elapi on the CLI would show the mail
command under "Built-in plugins", or you can just run elapi mail. The plugin's APIs can also be used in a script with from elapi.mail import *.
For now the default behavior of the mail plugin is that the emails are not sent immediately after an error or a pre-defined condition is triggered, but only after the plugin has finished. This allows aggregating the relevant logs first, e.g., any other log right before the "ERROR" log, which should be a more helpful in investigation. The mail plugin does provide APIs that can be used to send email immediately after an event, though that has to be done programmatically.
Once the mail plugin is installed, it just needs to configured with your email server provider information. All configuration values need to be set under plugins: mail: in elapi.yml configuration file. For more information about configuration file, read "Configuration". We will first show a complete mail configuration below, and then unpack the meaning of each field. Maybe it already looks intuitive to you without knowing the details.
plugins:
mail:
validate_config_early: false
global_email_setting:
host: localhost
port: 25
smtp_skip_login: true
smtp_ssl: false
smtp_starttls: true
enforce_plaintext_email: false
global_email_headers:
From: "URZ No-Reply <no-reply@uni-heidelberg.de>"
cases:
case_1:
on: ["CRITICAL", "ERROR"]
body: "~/email_messages/failure.j2"
target_command: "bill-teams store-info"
headers:
To:
- "URZ FIRE <elabftw@uni-heidelberg.de>"
From: "URZ No-Reply <no-reply@uni-heidelberg.de>"
Subject: "Billing Failed in elabftw.uni-heidelberg.de"
case_2:
on: ["SUCCESS", "INFO"]
pattern: "exported"
target_command: "bill-teams store-info"
body: "~/email_messages/success.j2"
headers:
To:
- "Alexander Haller <alexander.haller@urz.uni-heidelberg.de›"
- "Mahadi Xion <mahadi.xion@urz.uni-heidelberg.de>"
From: "URZ No-Reply <no-reply@elabftw.uni-heidelberg.de>"
Subject: "Billing Successful in elabftw.uni-heidelberg.de"
case_test:
body: "~/email_messages/test.j2"
headers:
To:
- "Alexander Haller <alexander.haller@urz.uni-heidelberg.de>"
- "Mahadi Xion <mahadi.xion@urz.uni-heidelberg.de>"
From: "URZ No-Reply <no-reply@elabftw.uni-heidelberg.de>"
Subject: "This is a test email"
Short explanation: Each case under cases defines a condition or set of conditions about when an email should be sent. The email server configuration that should apply to all cases is defined under global_email_setting. The email headers that should apply to all cases is defined under global_email_headers. Each case can accept its own unique header or email setting which has higher precedence over global settings. Finally, a special case called case_test that cannot be triggered by any log or error, but is mainly meant to just send a test email. So this can be used to test if an email can actually be sent, i.e., if the global settings/headers are actually working.
This field contains the email server information that elAPI mail will apply to all cases. A case under cases can overwrite the values allowing per-case settings. In the example configuration above though we are not really using per-case settings that much. It is recommended to provide a host and a port number. If host and port aren't provided then localhost and 25 are assumed as default/fallback values, respectively.
host: <examples: "localhost", "smtp.gmail.com", "smtp-mail.outlook.com", "your-institute.org">
port: <examples: 25, 587, 465>If you want the emails to be sent to your organization or work email, the host and port values would depend on your institution-specific guidelines. Under the hood, elAPI is using Yagmail which under the hood is leveraging Python's built-in library SMTP which acts as an SMTP client. Here are a few configuration examples:
Sending emails to Gmail: It is recommended to use OAuth2 for Gmail so the OAuth2 secret only has permission to send emails and nothing more.
host: smtp.gmail.com
port: null
smtp_ssl: true
smtp_skip_login: false
oauth2_file: "<path to your oauth file with google_client_id and google_client_secret>"Sending emails to Outlook: To send emails to Microsoft Outlook, it's recommended to use an app password.
host: smtp.office365.com
port: 587
password: "<your app password>"
smtp_ssl: false
smtp_starttls: trueSending emails via a relay server: You can also use a relay host setup with Postfix. In which case, your host should likely be localhost.
host: localhost
port: 25
smtp_skip_login: true
smtp_ssl: false
smtp_starttls: trueThe key-values here host, port, password, smtp_ssl, smtp_skip_login, smtp_starttls are just passed to Yagmail's SMTP class after some validation. In the future updates, DKIM support will be added.
Before we can test if the configuration is working, we need to define the From and To addresses. This can be done under global_email_headers or a case like before.
Some headers might make sense to apply to all emails, like the From header. In which case, you want every email sent from the same email address.
global_email_headers:
From: "URZ No-Reply <no-reply@uni-heidelberg.de>"
Reply-To: URZ No-Reply <no-reply@uni-heidelberg.de>From header is also an optional header. If no From is given, then a default Your host username <username@host FQDN> is assumed.
The case named case_test is a special case that doesn't listen to any log, but is there only to send a test email. So this is the best place to send your first email before configuring an actual case with error conditions.
case:
case_test:
body: "<path to a text file with a small message>"
headers:
To:
- "Mahadi Xion <mahadi.xion@urz.uni-heidelberg.de>"
From: "URZ No-Reply <no-reply@elabftw.uni-heidelberg.de>"
Subject: "This is a test email"Here, the body can be path to any text file with some message on, or just null for now. Now to send the test email, run elapi mail test. And you should see the following message followed by a successful exit.
$ elapi mail test
INFO Attempting to send a 'case_test' email to 'Mahadi Xion <mahadi.xion@urz.uni-heidelberg.de>', mail.py:249
from 'no-reply@elabftw.uni-heidelberg.de',
with the following additional headers: {'Subject': 'Test email'}.If you see the mail with the subject "This is a test email" in your inbox, you're ready to configure actual cases.
An email will only be sent only if given trigger conditions are met defined by a case. These trigger conditions are: on, pattern and target_command. Each trigger condition gives you finer degree of control over when exactly to send an email and with headers and to whom. Either on or pattern must be provided for each case (except case_test) whereas target_command is optional.
on accepts a YAML list of log level names. Multiple levels given in the list are treated with an OR condition. By default, Python comes with CRITICAL, ERROR, WARNING, INFO and DEBUG. Sometimes, to distinguish between logging messages even further, you want custom logging levels. With this PR, elAPI also added a Haggis-provided function add_logging_level which can be used with from elapi.loggers import add_logging_level. In our example with case_1, the on is set to be triggered when a log has the level CRITICAL or ERROR. Of course, for on trigger to be effective, one must assume that the script or plugin is using elAPI's log functionality properly.
In some cases, a single log level can be overused, so you might be interested in a specific pattern that can be found in the message. E.g., the logs CRITICAL: Server responded with 500., CRITICAL: File could not be stored in <path>, both have CRITICAL level, but they are different. If you are only interested in network failure as seen in the first log, or you want network failure related messages to be sent to a designated team of sysadmin, you can take advantage of the pattern field like the following: pattern: server. Here a pattern can be any valid Python regex. If both on and pattern are defined in a case, elAPI mail will assume an AND condition between them. E.g., in the example above with case_2, an email is only sent if the log level name is either SUCCESS or INFO and the log message has the word exported.
Both on and pattern target all plugins/commands of elAPI. You can narrow down your log of interest with the target_command. If a running plugin command matches the command defined in the target_command, then on and/or pattern are considered for that command only. That is to say, the cases that don't have the target_command work as catch-all cases. In fact, you should use target_command for your main tasks. This way if a target_command case is not triggered, a case without target_command can work as a fallback. At URZ, we use elapi bill-teams store-info to collect users data. So, both case_1 and case_2 have target_command as we are only interested in store-info command's errors.
The body key takes a file path as input, processes the file with Jinja2 templating engine, and adds the rendered message into the email body. This Jinja2 templating allows for a powerful way to customize the email message and to add specific information of interest to the message. Let's look at an example body:
case_1:
body: "~/email_messages/failure.j2"# In ~/email_messages/failure.j2
Dear eLab sysadmin,
Unfortunately, <an operation for billing> in {{server_fqdn}} has failed. The following logs have been captured:
{{all_logs}}
This is an automatic email sent from elAPI.
Best regards,
{{sender_full_name}}Here, the variables inside the double curly braces are the default variables supported and automatically filled in by elAPI.
-
server_fqdn: The server fully qualified domain name (FQDN) where elAPI is installed -
all_logs: All captured logs -
sender_full_name: The name of the sender fromFromheader (if defined) -
unique_receiver_name: The name of the receiver (ifTohas more than 1 recipient, the value is set to empty string)
New variables to the templates can be added via a third-party plugin and by adding a new dictionary key to process_jinja_context. E.g., To add a new variable elab_server_info, create a new plugin, and put the following code inside its cli.py:
from elapi.api import GETRequest
from elapi.mail import mail_body_jinja_context
session = GETRequest()
elab_server_info = session("info").json
mail_body_jinja_context["elab_server_info"] = elab_server_infoIn other words, any new value can be added to the template by just adding it as a key to mail_body_jinja_context shared instance. For now, this process_jinja_context is global for all cases.
Cases are treated in the order they are defined. Only the first case matching a target condition will be considered and tried. The only exception is that if the running elAPI command matches a case's target_command. In which case, the cases with the matching target_command will be tried first, also following the order they are defined. An example:
case_1:
on: ["CRITICAL", "ERROR"]
body: "message.txt"
headers:
To:
- "Sysadmin email <sysadmin@urz.uni-heidelberg.de>"
From: "URZ No-Reply <no-reply@elabftw.uni-heidelberg.de>"
Subject: "Something went wrong in elabftw.uni-heidelberg.de"
case_2:
on: ["CRITICAL", "ERROR"]
target_command: "bill-teams store-info"
body: "message.txt"
headers:
To:
- "Support Mail <elabftw@uni-heidelberg.de>"
From: "URZ No-Reply <no-reply@elabftw.uni-heidelberg.de>"
Subject: "Billing failed in elabftw.uni-heidelberg.de"
case_3:
on: ["ERROR"]
target_command: "bill-teams store-info"
body: "~/message.txt"
headers:
To:
- "Support Mail <elabftw@uni-heidelberg.de>"
From: "URZ No-Reply <no-reply@elabftw.uni-heidelberg.de>"
Subject: "Billing issue in elabftw.uni-heidelberg.de"If the command elapi bill-teams store-info produced the log message ERROR: There is an error with billing, only the case_2 will be considered—case_2 has the matching target_command and case_3 even though it matches it comes after case_2. Any other plugin command, e.g., elapi experiments download-attachment with a CRITICAL log will trigger the case_1.
It may not be sufficient for your use-case that emails are sent only after the entire operation has finished, i.e., emails aren't sent immediately at trigger condition. mail plugin exposes the APIs needed to send emails programmatically from within a plugin.
from elapi.mail import send_mail
from httpx import HTTPError
from elapi.loggers import Logger
logger = Logger()
# You want to send an email when a HTTPError happens
try:
...
# Some function
except HTTPError as e:
logger.error(e)
case_value = {
"main_params": { # keys are the same as Yagmail's SMTP class's parameters
"user": "<'From' email address!>",
"host": "localhost",
"port": 2525,
"password": "<password>",
},
"to": "<email address>",
"body": "<Email body>",
"sender_domain": "<server FQDN>",
"headers": {"Subject": "Email subject"},
}
send_mail(case_name="case_x", case_value=case_value)Let's assume we have a case setup with bill-teams teams info as target_command for any log with ERROR. elapi bill-teams teams info command simply downloads all user data which should work with any eLab server, and doesn't require any UHD-specific environment setup. Running elapi bill-teams teams info will display a progress bar of downloaded data which we will interrupt with a CTRL + C.
The interruption caused the plugin command to quit with an ERROR which triggered the defined case. The email should show up in the inbox shortly.
- At the moment, elAPI
mailstill relies on log message for case triggers. A trigger condition for unexpected Python exceptions will still require an independent crash monitoring tool. - elAPI
mailassumes the plugins or scripts are using elAPI's logging classes to log. Custom logging classes now can be registered withfrom elapi.loggers import LoggerUtil.
A viable choice would be to use centralized monitoring tools like Logwatch
to send emails about your script's state. elAPI's mail plugin does not and is not intended to replace tools like
Logwatch. The more general tools are useful for the "catch-all" cases. For example, Logwatch can be used to monitor all logs found in ~/.local/share/elapi/elapi.log, and to send an email when a log
marked with an ERROR prefix is found. What if we cared about only errors or success status coming from a specific
plugin/script only and not others? In our case, we want to be notified when the bill-teams plugin has finished teams data collection. Logwatch would require a separate a log file for bill-teams to only send reports about
bill-teams. A separate log file for some plugins may make sense, but not for all plugins. elAPI was designed so a small Python script can be turned into an elAPI plugin and have their logs sent to elapi.log file. elAPI already has enough control and information about its plugins. So an email alert functionality that can be configured to monitor various states atomically, i.e., specific plugin outputs, log patterns, log states would be relatively easier to do from within elAPI. And, since elAPI has information about the eLab servers as well, it's even easier to include specific server information in our email body. The more robust we want our email messages to be, the harder it gets for general purpose tools like Logwatch. The introduction of mail plugin also enables a scoped log-based event hook for elAPI where we will be able to do more than just sending emails when a state change is detected.