The Python Smtplib Tutorial - howto send HTML EMAIL with Python is explains how to send HTML emails using SMTP, including auth, SMTP with TLS and SMTPS.

After introducing Python smtplib, the post provides an hands-on example showing how to add HTML email notification to an existing scirpt. The provided code supports every kind of SMTP connection - SMTP, SMTP with startTLS and SMTPS.

This post completes the trilogy we started with the JINJA2 With Python Tutorial JINJA Ansible HowTo and  Python Argparse Tutorial - Argparse HowTo posts.

The Python SMTPLib Library

The Python smtplib library is a built-in module that provides a straightforward way to send emails using the Simple Mail Transfer Protocol (SMTP), the standard protocol for transmitting email over the internet.

Its primary purpose is to enable developers to automate email sending from Python scripts, such as dispatching notifications, alerts, reports, or bulk messages without needing external tools or services.

Knowing smtplib is important for programmers working on automation tasks, web applications, or system monitoring, as it integrates seamlessly with Python's ecosystem, supports secure connections like TLS/SSL, and allows customization of email content, attachments, and recipients, making it a versatile tool for efficient communication in software projects.

This definitively makes Python smtplib library a must have for DevOps and DevSecOps professionals: in DevOps workflows, it powers automated notifications in CI/CD pipelines, alerting teams to build statuses, deployments, or infrastructure changes directly from scripts. For DevSecOps, it facilitates secure, compliant communication by sending vulnerability scans, audit logs, or incident reports, often integrated with tools like Ansible, Jenkins, or monitoring systems, all while leveraging Python's robust security features to ensure encrypted transmissions and reliable delivery.

Example Lab - Add Email Notification To An Existing Script

In this lab we illustrate the use of the smtplib module by adding the email notification feature to an existing script.

This feature is a nice enhancement we can add to the script we initially developed in the JINJA2 With Python Tutorial JINJA Ansible HowTo post, where we saw how to exploit JINJA2 to generate a human-readable HTML report by blending a JSON alert report with a JINJA2 template. After improving it in the Python Argparse Tutorial - Argparse HowTo, where we added support to command line aruments, we can finally focus on adding email notification. This by the way also enables us to provide an additional use case to argparse and, since STMP requires authentication, to see how to manage an INI file where to store credentials used during authentication.

Credentials File

Security best practies strictly forbid to embed sensitive information such as credential inside the code as well to pass them as arguments, since they will be available to everyone in the process list, or under the /proc filesystem for all the time the application process is running.

For this reason, we externalize them into a credentials file, which of course must be secured restricting read access to only the user the process runs as.

First a first, we must define the configuration file format: to promote the script's extensibility, instead of using simply key-value pairs, we are using the INI format, so to be able to group credentials by consumer using stanzas.

The advantage of this approach is that it enables users to easily add credentials for other notification means - such as Slack - keeping everything clean and tidy and without reworks: being far-sighted is the first non-written requirement for developing easy to maintain applications. 

Create the "conf/credentials.ini" credentials file with the following contents:

[smtp]
host = smtp.carcano.corp
port = 465
start_tls = false
#ca_file=ca.crt
username = healthcheck@carcano.corp
password = A-G0od.PwD

of course, replace the values with your actual ones.

The purpose of each field is straightforward, ... just a few details:

  • username and password must be set only if the SMTP server requires authentication
  • start_tls must be set to true only when using server supporting STARTTLS (servers with clear-text connection that anyway support starting a TLS tunnel on demand). For the sake of completenss, typical scenarios are:
    • port 587, start_tls false
    • port 587, start_tls true
    • port 465, start_tls false
  • when dealing with SSL SMTP servers (port 465), ca_file must be configured only if the SMTP server's certificate is issued by a private CA: in this case it must be the path to the file with the private CA's ROOT certificate.

Since the credentials file contains sensitive data, access to it must be restricted to only the user used to run the script.
For example, if the username is "scheduler", type:

chmod 0600 conf/credentials.ini
chown scheduler: conf/credentials.ini

Of course this is a clear-text file, so users with administrative privileges can still read its contents - to overcome this problem, it is necessary to create an encrypted credentials file, but this is outside the scope of this post.

Process INI FIleS With Configparser

In Python, INI files can be processed using the configparser package. To use it inside our code, we must import it as follows: 

import configparser

we can now add the logic to parse the credentials file.

First, add the credentials_file global variable with the default name for the credentials file:

credentials_file = "credentials.ini"

As by best practices, input must always be checked, handling exceptions as necessary.

For this reason, add the following lines to the bottom of the the "validate_config" function:

    try:
        if not os.path.isfile(os.path.join(directory, credentials_file)):
            raise FileNotFoundError
    except FileNotFoundError:
        print("File {} not found".format(os.path.join(directory, credentials_file)), file=sys.stderr)
        exit(1)

this block of code handles the file not found exception.

Add The SMTP Notification Logic

To work with smtblib, we must import it along with the other related packages we are using in our code: 

import smtplib
import ssl
from email.message import EmailMessage

more specifically:

  • smtplib and email.message from EmailMessage are used for the email formatting and SMTP delivery
  • ssl is used to enable using TLS to encrypt the SMTP session's traffic

We can now add the necessary code to handle the emai notification process.

Just create the send_email function with the following contents:

def send_email(smtp_params, subj, html_message):
    """
    Composes the email using the supplied html_message and sends it from the specified sender to the supplied
    list of recipients by SMTP using the supplied smtp_params

    :param smtp_params: a dictionary with the smtp parameters
    :param subj: the message's subject
    :param html_message: the html message
    """
    try:
        msg = EmailMessage()
        msg["Subject"] = subj
        msg["From"] = sender
        msg["To"] = ", ".join(recipients)

        msg.set_content("This email is in HTML format - please switch to the HTML view to display it.")
        msg.add_alternative("".join(html_message), subtype="html")

        logger.info("Sending email to '{}'".format(msg["To"]))

        if smtp_params["port"] == "465":
            logger.debug("Using SSL")
            if smtp_params.get("ca_file"):
                logger.debug("Using CA-file '{}'".format(os.path.join(config_dir, smtp_params["ca_file"])))
                ctx = ssl.create_default_context(cafile=os.path.join(config_dir, smtp_params["ca_file"]))
            else:
                ctx = ssl.create_default_context()
            smtp = smtplib.SMTP_SSL(host=smtp_params["host"], port=smtp_params["port"], context=ctx)
        else:
            smtp = smtplib.SMTP(host=smtp_params["host"], port=smtp_params["port"])
        if os.getenv("LOGLEVEL") == "DEBUG":
            smtp.set_debuglevel(1)
        if smtp_params["start_tls"] == "true":
            logger.debug("Using Start-TLS")
            smtp.starttls()
        if smtp_params.get("username") and smtp_params.get("password"):
            logger.debug("Using SMTP Auth - authenticating as '{}'".format(smtp_params['username']))
            smtp.login(smtp_params['username'], smtp_params['password'])
        smtp.sendmail(msg['From'], recipients, msg.as_string())
        # dont't use smtp.send_message(msg) because causes error:
        # resent = msg.get_all('Resent-Date')
        # AttributeError: '_UniqueAddressHeader' object has no attribute 'get_all'
        smtp.quit()
        logger.info("Mail sent")
    except (ConnectionRefusedError, smtplib.SMTPServerDisconnected, smtplib.SMTPAuthenticationError,
            smtplib.SMTPNotSupportedError, smtplib.SMTPDataError) as e:
        logger.error("unable to send mail report: SMTP error was \'{}\'".format(e))
        print("unable to send mail report: SMTP error was \'{}\'".format(e), file=sys.stderr)
        exit(1)

let's explain it:

  • lines from 11 to 16 are self-explanatory, and are related to composing the mail message object contents. This involves setting sender, recipient, subject and message body - in this case the message body just contains a warning informing that the real contents are in HTML format and this requires to switch the mail client to HTML parsing to process it. This message is usefull when dealign with users having the mail client procesing mail messages in plain text by defautl.
  • line 17 add the HTML version of the email message.

we then start processing the SMTP parameters:

  • lines from 21 to 28 configure the TLS settings when dealing with SMTPS (port 465)
  • line 30 configures the SMTP setting for working with SMTP, using it as baseline for setting up SMTP with STARTTLS.
  • lines 33-35 configures the use of the STARTTLS method if the smtp_params['starttls'] is set to true
  • lines 36-38 handles SMTP authentication, setting username and password, if the username and password parameters are set in the smtp_parameters dictionary.

We then start the section with the code performing the actual SMTP connection

  • line 39 performs the actual connection to the SMTP server, quitting the process at line 43.
  • lines 45-49 handles the possible exceptions

Assembling Together

We can now add the necessary logic to the main function to support the new email notification feature.

Let's add the following command line arguments:

  • --email-notify to enable the email notification
  • --sender to set the sender email address
  • --recipient to add a recipient - please note how we are using the append action to enable specifying tis argument multiple times so to add more recipients:
    parser.add_argument('-e', "--email-notify", dest="email_notify", action="store_true")
    parser.add_argument("-s", "--sender", dest="sender", type=str, default=None,
        help="email sender")
    parser.add_argument("-r", "--recipient", action="append", dest="recipients", type=str,
        help="email recipient - this option can be used multiple times to list multiple recipients as necessary")

we must then add the necessary logic to use these new arguments:

    if args.email_notify:
        sender = args.sender
        if not args.sender:
            print("You must specify the sender's email address using the '-s' option.", file=sys.stderr)
            exit(1)
        if not args.recipients:
            print("You must specify one or more recipient email addresses using the '-r' option.", file=sys.stderr)
            exit(1)
        recipients = args.recipients
        credentials = configparser.ConfigParser()
        credentials.read(os.path.join(config_dir, credentials_file))
        send_email(credentials['smtp'], subject, message)
    else:
        with open("{}/{}".format(spool_dir, "message.htm"), "w") as message_file_handler:
            message_file_handler.writelines(message)

the above block of code introduces a split in the logic: if the email_notify argument is specified, instead of generating the message.html HTML report, it it delivers the report as an HTML email. Please note how it loads the necessry SMTP parameters and credentials from the credentials file.

Please note also that, if the email argument was specified, it performs also a few basic validation checks, such ase making sure the user specified a sender and one or more recipients. This can of coure be improved, for example adding a regex check for checking the format of the specified values to ensure theiy are valid emails.

Testing The Script

We have finally reached the time for having a go with the new email notification feature.

Just run:

./notify.py -c conf -p spool -f report.json \
-e -r marco.carcano@carcano.corp -r devops@carcano.corp \
-s helpdesk@carcano.corp

please remind that if you need to troubleshoot you can export the "LOGLEVEL" variable increasing the verbosity. For example:

export LOGLEVEL=DEBUG

and then re-run the sript.

If everything is properly working, you must receive a report same as the below image

The Whole Script

For your convenience, here is a copy of the whole script.

Mind that an offering using the below cup is always welcome, since it helps me running this blog.

#!/usr/bin/python3
import os
import sys
import json
import logging
import argparse
from jinja2 import Environment, FileSystemLoader
import configparser
import smtplib
import ssl
from email.message import EmailMessage

# https://docs.python.org/3/library/logging.html#levels
logging_loglevels = {"NOTSET": 0, "DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}

report_file = "report.json"
template_file = "message.jn2"
credentials_file = "credentials.ini"


def setup_logging():
    """
    Setups and return a logger object with a default formatter

    :return: the logger object
    """
    log = logging.getLogger(__name__)
    log.addHandler(logging.NullHandler())

    log_handler = logging.getLogger()
    handler = logging.StreamHandler()
    formatter = logging.Formatter("%(asctime)s %(name)-12s %(levelname)-8s %(message)s")
    handler.setFormatter(formatter)
    log_handler.addHandler(handler)
    log_handler.setLevel(logging.INFO)
    return log_handler


def render_message():
    """
    Renders the report_file by merging it using the template_file

    :return: the rendered report
    """

    with open(os.path.join(spool_dir, report_file), "r") as report_file_handler:
        report = json.load(report_file_handler)
        logger.debug("Loaded report {}".format(os.path.join(spool_dir, report_file)))
        jinja_env = Environment(loader=FileSystemLoader(config_dir),
            extensions=["jinja2.ext.loopcontrols"]
        )
        logger.debug("Merging JINJA2 template {} with the loaded report".format(
             os.path.join(config_dir, template_file)))
        msg = jinja_env.get_template(template_file).render(report)
        subj = "{} {}".format(report["testsuite"]["name"], report["testsuite"]["target"])
    return subj, msg

def validate_spool(directory):
    """
    After overriding spool_dir with the related argparse argument (if specified on the command line),
    it validates spool_dir's contents, ensuring it exists and that it contains the report_file file.

    :param directory: the default spool directory

    :return: the actual spool directory (overridden as necessary)
    """
    global report_file
    if args.spool_dir is not None:
        directory = args.spool_dir
    try:
        if not os.path.isdir(directory):
            raise FileNotFoundError
    except FileNotFoundError:
        print("Directory {} not found".format(directory), file=sys.stderr)
        exit(1)
    if args.report_file is not None:
        report_file = args.report_file
    try:
        if not os.path.isfile(os.path.join(directory, report_file)):
            raise FileNotFoundError
    except FileNotFoundError:
        print("File {} not found".format(os.path.join(directory, report_file)), file=sys.stderr)
        exit(1)
    return directory, report_file


def validate_config(directory):
    """
    After overriding config_dir with the related argparse argument (if specified on the command line),
    it validates config_dir's contents, ensuring it exists and that it contains the template file.

    :param directory: the default configuration directory

    :return: the actual configuration directory (overridden as necessary)
    """
    if args.config_dir is not None:
        directory = args.config_dir
    try:
        if not os.path.isdir(directory):
            raise FileNotFoundError
    except FileNotFoundError:
        print("Directory {} not found".format(directory), file=sys.stderr)
        exit(1)
    try:
        if not os.path.isfile(os.path.join(directory, template_file)):
            raise FileNotFoundError
    except FileNotFoundError:
        print("File {} not found".format(os.path.join(directory, template_file)), file=sys.stderr)
        exit(1)
    try:
        if not os.path.isfile(os.path.join(directory, credentials_file)):
            raise FileNotFoundError
    except FileNotFoundError:
        print("File {} not found".format(os.path.join(directory, credentials_file)), file=sys.stderr)
        exit(1)
    return directory

def send_email(smtp_params, subj, html_message):
    """
    Composes the email using the supplied html_message and sends it from the specified sender to the supplied
    list of recipients by SMTP using the supplied smtp_params

    :param smtp_params: a dictionary with the smtp parameters
    :param subj: the message's subject
    :param html_message: the html message
    """
    try:
        msg = EmailMessage()
        msg["Subject"] = subj
        msg["From"] = sender
        msg["To"] = ", ".join(recipients)

        msg.set_content("This email is in HTML format - please switch to the HTML view to display it.")
        msg.add_alternative("".join(html_message), subtype="html")

        logger.info("Sending email to '{}'".format(msg["To"]))

        if smtp_params["port"] == "465":
            logger.debug("Using SSL")
            if smtp_params.get("ca_file"):
                logger.debug("Using CA-file '{}'".format(os.path.join(config_dir, smtp_params["ca_file"])))
                ctx = ssl.create_default_context(cafile=os.path.join(config_dir, smtp_params["ca_file"]))
            else:
                ctx = ssl.create_default_context()
            smtp = smtplib.SMTP_SSL(host=smtp_params["host"], port=smtp_params["port"], context=ctx)
        else:
            smtp = smtplib.SMTP(host=smtp_params["host"], port=smtp_params["port"])
        if os.getenv("LOGLEVEL") == "DEBUG":
            smtp.set_debuglevel(1)
        if smtp_params["start_tls"] == "true":
            logger.debug("Using Start-TLS")
            smtp.starttls()
        if smtp_params.get("username") and smtp_params.get("password"):
            logger.debug("Using SMTP Auth - authenticating as '{}'".format(smtp_params['username']))
            smtp.login(smtp_params['username'], smtp_params['password'])
        smtp.sendmail(msg['From'], recipients, msg.as_string())
        # dont't use smtp.send_message(msg) because causes error:
        # resent = msg.get_all('Resent-Date')
        # AttributeError: '_UniqueAddressHeader' object has no attribute 'get_all'
        smtp.quit()
        logger.info("Mail sent")
    except (ConnectionRefusedError, smtplib.SMTPServerDisconnected, smtplib.SMTPAuthenticationError,
            smtplib.SMTPNotSupportedError, smtplib.SMTPDataError) as e:
        logger.error("unable to send mail report: SMTP error was \'{}\'".format(e))
        print("unable to send mail report: SMTP error was \'{}\'".format(e), file=sys.stderr)
        exit(1)

if __name__ == '__main__':
    config_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "conf")
    spool_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "spool")

    logger = setup_logging()
    logger.setLevel(logging_loglevels[os.getenv("LOGLEVEL", "INFO")])

    parser = argparse.ArgumentParser(
        description="Send an email notification")
    parser.add_argument("-c", "--config-dir", dest="config_dir", type=str, default=None,
        help="path to the directory containing the configuration files - if omitted, it assumes it's the same"
        "containing the script itself")
    parser.add_argument("-p", "--spool-dir", dest="spool_dir", type=str, default=None,
        help="path to the directory containing the report files - if omitted, it assumes it's the same containing"
        " the script itself")
    parser.add_argument("-f", "--report-file", dest="report_file", type=str, default=None,
        help="the file name of the JSON report - if omitted, it assumes '{}'".format(report_file))
    parser.add_argument('-e', "--email-notify", dest="email_notify", action="store_true")
    parser.add_argument("-s", "--sender", dest="sender", type=str, default=None,
        help="email sender")
    parser.add_argument("-r", "--recipient", action="append", dest="recipients", type=str,
        help="email recipient - this option can be used multiple times to list multiple recipients as necessary")
    args = parser.parse_args()

    spool_dir, report_file = validate_spool(spool_dir)
    config_dir = validate_config(config_dir)

    logger.debug("Script started")
    subject, message = render_message()
    
    if args.email_notify:
        sender = args.sender
        if not args.sender:
            print("You must specify the sender's email address using the '-s' option.", file=sys.stderr)
            exit(1)
        if not args.recipients:
            print("You must specify one or more recipient email addresses using the '-r' option.", file=sys.stderr)
            exit(1)
        recipients = args.recipients
        credentials = configparser.ConfigParser()
        credentials.read(os.path.join(config_dir, credentials_file))
        send_email(credentials['smtp'], subject, message)
    else:
        with open("{}/{}".format(spool_dir, "message.htm"), "w") as message_file_handler:
            message_file_handler.writelines(message)
    logger.debug("Script ended")

Footnotes

This post completed the trilogy we started with the JINJA2 With Python Tutorial JINJA Ansible HowTo and  Python Argparse Tutorial - Argparse HowTo posts. In this trilogy you learned how to create a professional grade script supporting command line arguments processed with argparse, exploiting the JINJA2 template engine to generate contents from templates (in our example we turned a JSON report into an HTML report), and delivering the report as an HTML email using the Python smtplib module.

As expected by any good DevSecOps professional, you are now equipped with the necessary skills to write secure and powerfull notification scripts that are typically used in SIEM.

If you appreciate this post and any other in this blog, just share this and the others on Linkedin - sharing and comments are an inexpensive way to push me into going on writing - this blog makes sense only if it gets visited.

Also concrete contributions to the maintenance of this blog are also very welcome, ... just put your tip in the below cup.

I hate blogs with pop-ups, ads and all the (even worse) other stuff that distracts from the topics you're reading and violates your privacy. I want to offer my readers the best experience possible for free, ... but please be wary that for me it's not really free: on top of the raw costs of running the blog, I usually spend on average 50-60 hours writing each post. I offer all this for free because I think it's nice to help people, but if you think something in this blog has helped you professionally and you want to give concrete support, your contribution is very much appreciated: you can just use the above button.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>