Markdown-powered emails in Django

Markdown-powered emails in Django

Write in Markdown, style with GitHub Markdown CSS, and inline the CSS with premailer

ยท

8 min read

Programmatically sending "nice-looking" HTML emails with minimal effort is hard. This is why projects like MJML exist. MJML is cool, but I think it comes with some bit of overhead, as you have to learn (and write) the markup and design the layouts (you can make use of available templates if they fit your use-case). This is fine if you have the time, and email visual quality is a core feature of your project. However, if you don't have time, and you just want to send decent looking emails with minimal effort, there is another way.

You probably already use Markdown to write your docs, and even if you don't often write docs (which you should be doing!), your project's README is most likely written in Markdown. I have become so used to writing in Markdown that I even use the markdown-here browser extension so I can write emails in Markdown right within the Gmail interface! As John Gruber said in his introduction to Markdown:

Markdown allows you to write using an easy-to-read, easy-to-write plain text format, then convert it to structurally valid XHTML (or HTML).

This is why I find it appealing โ€” you just write, with less worries about layouts and formatting. Wouldn't it be cool if we could write our Django email templates in Markdown, and render nice looking emails without breaking a sweat? Well, we can! So let's dive in and see how we can get this done.

๐Ÿ’ก
TLDR; Here's a GitHub repo where you can see the code in action, and try it out for yourself.

Suppose, in our Django project, we want to send notification emails whenever somebody registers for an event. We have a plain text email template as follows:

Howdy {{ user }},

This is to confirm that you have successfully registered for โ€œ{{ event.title }}โ€. Here are the details, for your reference:

-----------------------------
Event: {{ event.title }}
Description: {{ event.description }}
Date and Time: {{ event.date_and_time }}
Location: {{ event.location }}
-----------------------------

We look forward to seeing you there!

Cheers,

โ€” The Project Team

This is a Django template, with context variables user and event. This template is rendered using the render_to_string function in the django.template.loader module. Here's a custom send_mail function, where this is done:

from typing import List, Optional

from django.conf import settings
from django.core.mail import EmailMessage
from django.template.loader import render_to_string


def send_email(
    subject: str,
    to_email_list: List[str],
    template: str,
    context: Optional[dict] = None,
) -> None:
    """
    Sends an email with the specified subject,
    to the specified list of email recipients,
    using the supplied text template with optional context.

    Args:
        subject: The subject line of the email.
        to_email_list: A list of email addresses to send the email to.
        template: The path to the text template to use.
        context: A dictionary containing values to pass to the template (optional).

    Returns:
        None
    """

    # If context is None, set it to an empty dictionary
    if context is None:
        context = {}

    # Render the text template using the provided context
    text_content = render_to_string(template, context)

    # Create the email message object
    email = EmailMessage(
        subject=subject,
        body=text_content,
        from_email=settings.DEFAULT_FROM_EMAIL,
        to=to_email_list,
    )

    # Send the email
    email.send()

So, in order to send an email, we'd call our custom send_mail function like this:

send_email(
    subject=f"Event Registration for {event.title}",
    to_email_list=[user.email],
    template="events/event_registration_notification_email.txt",
    context={"event": event, "user": user},
)

And the user will get a plain text email like this:

Now, in order to bring Markdown into the mix, we need to make a couple of changes:

  1. Write our template in Markdown

  2. Install a Markdown parsing and rendering library

  3. Modify our send_mail function to use the library above

So, let's get to it. Here's a slightly modified version of the template:

Howdy {{ user }},

This is to confirm that you have successfully registered for **{{ event.title }}**. Here are the details, for your reference:

---

- _Event_: {{ event.title }}
- _Description_: {{ event.description }}
- _Date and Time_: **{{ event.date_and_time }}**
- _Location_: {{ event.location }}

---

We look forward to seeing you there!

Cheers,

โ€” The Project Team

Regarding a Markdown parsing and rendering library, the choice is entirely up to you! There are several options available, including Python-Markdown and Python-Markdown2, which are quite popular. These are Python implementations of John Gruber's original Perl-implemented Markdown. In my case, I wanted something that was based on GitHub's fork of cmark, so I could use GitHub Flavoured Markdown (GFM) out-of-the-box (without installing / configuring additional extensions), if I needed to. This led me to cmarkgfm and pycmarkgfm. I went with pycmarkgfm, which is similar to cmarkgfm, but with support for additional features such as task lists.

Install the library:

pip install pycmarkgfm

Now let's modify the send_mail function so we can make use of pycmarkgfm:

from typing import List, Optional

import pycmarkgfm
from django.conf import settings
from django.core.mail import EmailMessage
from django.template.loader import render_to_string


def send_email(
    subject: str,
    to_email_list: List[str],
    template: str,
    context: Optional[dict] = None,
    md_to_html: Optional[bool] = False,
) -> None:
    """
    Sends an email with the specified subject,
    to the specified list of email recipients,
    using the supplied text template with optional context.

    Args:
        subject: The subject line of the email.
        to_email_list: A list of email addresses to send the email to.
        template: The path to the text template to use.
        context: A dictionary containing values to pass to the template (optional).
        md_to_html: Whether the template content is written in Markdown format and
        should be rendered as HTML.

    Returns:
        None
    """

    # If context is None, set it to an empty dictionary
    if context is None:
        context = {}

    # Render the text template using the provided context
    text_content = render_to_string(template, context)

    if md_to_html:
        text_content = pycmarkgfm.gfm_to_html(text_content)

    # Create the email message object
    email = EmailMessage(
        subject=subject,
        body=text_content,
        from_email=settings.DEFAULT_FROM_EMAIL,
        to=to_email_list,
    )

    # If markdown to html is enabled, set the content type to HTML
    if md_to_html:
        email.content_subtype = "html"

    # Send the email
    email.send()

We have introduced a new Boolean md_to_html keyword argument, so that, when it's True:

  • we apply pycmarkgfm's gfm_to_html function to the text rendered by django.template.loader.render_to_string, and

  • change the email content_subtype to "html".

We could use the EmailMultiAlternatives class here, in order to send both text and HTML versions of our email. However, we'll keep things simple and just send the HTML version. You can check the Django docs for details of how to use the EmailMultiAlternatives class.

Here's what the email looks like

Pretty cool, right? We could end here and call it a day! However, we can make it even better, by adding a custom stylesheet. We can replicate the GitHub Markdown style by using Sindre Sorhus' github-markdown-css.

For a web page, we can easily use an external stylesheet via

<link href="example.css" rel="stylesheet" />

Or we could even embed the stylesheet via the <style> tag.

However, with emails, things are a little bit more complicated, mainly because

  • most email clients do not support linking to external stylesheets due to security and privacy concerns

  • email client CSS support for embedded stylesheets is limited (at the time of writing this post)

For more information about CSS in emails, please see the following resources, which I found quite helpful:

To ensure your emails render properly across different email clients, it is recommended to use inline CSS. That sounds like too much work! Fortunately, someone has written an excellent tool to do all this work for us. Enter premailer, by Peter Bengtsson!

From the project's README:

[Premailer] parses an HTML page, looks up style blocks and parses the CSS. It then uses the lxml.html parser to modify the DOM tree of the page accordingly.

Exactly what we need! Let's start by installing the package

pip install premailer

Next up, we'll need to download the github-markdown-css stylesheet and update our send_mail function to use premailer:

import os
from typing import List, Optional

import pycmarkgfm
from django.conf import settings
from django.core.mail import EmailMessage
from django.template.loader import render_to_string
from premailer import transform


def send_email(
    subject: str,
    to_email_list: List[str],
    template: str,
    context: Optional[dict] = None,
    md_to_html: Optional[bool] = False,
) -> None:
    """
    Sends an email with the specified subject,
    to the specified list of email recipients,
    using the supplied text template with optional context.

    Args:
        subject: The subject line of the email.
        to_email_list: A list of email addresses to send the email to.
        template: The path to the text template to use.
        context: A dictionary containing values to pass to the template (optional).
        md_to_html: Whether the template content is written in Markdown format and
        should be rendered as HTML.

    Returns:
        None
    """

    # If context is None, set it to an empty dictionary
    if context is None:
        context = {}

    # Render the text template using the provided context
    text_content = render_to_string(template, context)

    if md_to_html:
        html = pycmarkgfm.gfm_to_html(text_content)
        with open(
            os.path.join(settings.PROJECT_DIR, "assets/css/github-markdown.min.css"),
            "r",
            encoding="utf-8",
        ) as f:
            github_markdown_css = f.read()
        # see https://github.com/sindresorhus/github-markdown-css?tab=readme-ov-file#usage
        formatted_content = f"""
        <!DOCTYPE html>
        <html>
            <head>
                <meta name="viewport" content="width=device-width, initial-scale=1">
                <style>{github_markdown_css}</style>
                <style>
                    .markdown-body {{
                        box-sizing: border-box;
                        min-width: 200px;
                        max-width: 980px;
                        margin: 0 auto;
                        padding: 45px;
                    }}
                    @media (max-width: 767px) {{
                        .markdown-body {{
                            padding: 15px;
                        }}
                    }}
                </style>
            </head>
            <body class="markdown-body">{html}</body>
        </html>
        """
        text_content = transform(formatted_content)

    # Create the email message object
    email = EmailMessage(
        subject=subject,
        body=text_content,
        from_email=settings.DEFAULT_FROM_EMAIL,
        to=to_email_list,
    )

    # If markdown to html is enabled, set the content type to HTML
    if md_to_html:
        email.content_subtype = "html"

    # Send the email
    email.send()

Notes:

  1. Asset Loading: We load the github-markdown-css stylesheet from a file (github-markdown.min.css), and embed the stylesheet via the <style> tag. The file can be located anywhere (it doesn't need to be within the STATICFILES_DIRS because it's not used on the frontend โ€” we are only using it when generating emails). I minified the file using sass in order to reduce the file size.

  2. HTML Content Generation: We create a formatted HTML string based on the example in the github-markdown-css README.

  3. Inlining CSS Styles: The premailer.transform function is applied to the formatted HTML content to inline CSS styles.

This is the result:

Pretty sweet, right? Well, there you have it โ€” decent-looking emails with minimal effort. After all, simple is better than complex!

Did you find this article valuable?

Support Victor Miti by becoming a sponsor. Any amount is appreciated!

ย