Markdown-powered emails in Django
Write in Markdown, style with GitHub Markdown CSS, and inline the CSS with premailer
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.
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:
Write our template in Markdown
Install a Markdown parsing and rendering library
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
'sgfm_to_html
function to the text rendered bydjango.template.loader.render_to_string
, andchange 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:
https://myemma.com/blog/css-in-html-emails-what-you-need-to-know-to-get-started/
https://customer.io/blog/how-to-make-css-play-nice-in-html-emails-without-breaking-everything/
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 thelxml.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:
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 theSTATICFILES_DIRS
because it's not used on the frontend โ we are only using it when generating emails). I minified the file usingsass
in order to reduce the file size.HTML Content Generation: We create a formatted HTML string based on the example in the github-markdown-css README.
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!