Skip to content

About

HTML emails via Django templates

Resources

Code of conduct

Security policy

Stars

15 stars

Watchers

1 watching

Forks

Repository files navigation

django-letter: HTML emails via Django templates.
Issues | Changelog | Funding 💚

django-letter PyPi Version Test Coverage GitHub License

Write an email once as a Django template. Send it as HTML with inlined CSS and a plain-text alternative.

django-letter renders your markup, inlines the CSS, and derives a Gmail-style plain-text body from the same template.

Setup

  1. Add the package:

    uv add django-letter
  2. Add the app to your settings:

    # settings.py
    INSTALLED_APPS = [
        "django_letter",
        "django_letter.notifications",  # Optional. Adds unsubscribe links.
        # ...
    ]
  3. Add an emails.py module to each app that sends mail. Put it next to that app's views.py.

  4. Mount the debug pages in your root URLconf:

    # urls.py
    from django.urls import include, path
    
    urlpatterns = [
        path("emails/", include("django_letter.urls")),
    ]

    While DEBUG is true, /emails/ lists every discovered email with the module of its class and its template, and previews each one:

    WelcomeEmail in the desktop, mobile and plain-text frames of the preview page
  5. Optional: mount the notification URLs:

    # urls.py
    from django.urls import include, path
    
    urlpatterns = [
        # ...
        path("notifications/", include("django_letter.notifications.urls")),
    ]

    Create django_letter/notification_form.html for the packaged settings view. Log in to change your preferences.

Usage

Make a pretty email

Subclass TemplateEmail. Set template_name, subject, and the values your markup needs. This example attaches the invoice and adds a tracking button:

# myapp/emails.py
from django.utils.translation import gettext_lazy as _

from django_letter import TemplateEmail


class OrderShippedEmail(TemplateEmail):
    template_name = "emails/order_shipped.html"
    subject = _("Order %(number)s has shipped")
    preheader = _("Track your parcel with the link inside.")

    def __init__(self, order, **kwargs):
        self.order = order
        super().__init__(**kwargs)

    def get_context_data(self):
        return {"order": self.order, "number": self.order.number}

    def gen_attachments(self):
        yield "invoice.pdf", self.order.invoice, "application/pdf"

subject and preheader fill their %(name)s placeholders from the context of the template. Write %% for a literal percent sign. gen_attachments() yields (filename, content, MIME type) tuples. Use None as the MIME type and Python guesses it from the file name.

Write {% attached_static %} where the markup needs a picture. It takes the same path as {% static %}, and the message carries the file with it:

{# myapp/templates/emails/newsletter.html #}
{% extends "django_letter/base.html" %}
{% load django_letter %}

{% block content %}
  <img src="{% attached_static 'images/logo.png' %}" alt="Logo">
{% endblock content %}

The same tag works in your web views and in the debug pages, where it returns the plain static URL.

The OrderShippedEmail template extends the bundled base template and fills the content block:

{# myapp/templates/emails/order_shipped.html #}
{% extends "django_letter/base.html" %}
{% load django_letter %}

{% block content %}
  <h1>Thanks for your order</h1>
  <p>Order {{ number }} is on its way.</p>
  {% button href=order.tracking_url value="Track parcel" primary=True %}
  {% table class="summary" %}
    <tr>
      <td>Invoice</td>
      <td>{{ number }}</td>
    </tr>
  {% endtable %}
{% endblock content %}

{% button %} builds a table that Outlook keeps intact, with the link in a new tab. primary uses the accent color of the base template. disabled shows the label as plain text without a link.

{% table %} wraps the rows in a layout table that every email client understands. Each keyword argument becomes an attribute. The tag adds role="presentation", border="0", cellpadding="0" and cellspacing="0", and it escapes each attribute value.

The base template keeps the layout safe for email clients and inlines the CSS when the message is built. It writes subject into <title> and preheader into a hidden span. Its blocks are container, logo, content, footer and footer_content. Its classes cover the layout (container, content, main, wrapper, footer, content-block), alignment (align-left, align-right, align-center) and spacing (first, last, mt0, mb0).

Add your own <style> rules in the extra_header block. The package inlines them:

{% block extra_header %}
  <style>
    .brand {
      color: #bada55;
    }
  </style>
{% endblock extra_header %}
OrderShippedEmail(order, to=["customer@example.com"], language="de").send()
OrderShippedEmail.to_user(user, order=order, language="de").send()

Let recipients manage preferences

The optional django_letter.notifications subapp adds unsubscribe buttons for email clients. It also gives recipients fine-grained control over each notification.

The subapp stores preferences but does not filter delivery. Filter the user queryset before you send. The unsubscribe link uses the first to address, so send one message per recipient.

from django.contrib.auth import get_user_model
from django.core import mail

from django_letter.fields import EmailHash
from django_letter.notifications.models import NotificationPreference

from .emails import WeeklyDigestEmail, weekly_digest

User = get_user_model()
opt_outs = NotificationPreference.objects.email_hashes(weekly_digest)
recipients = (
    User.objects.filter(is_active=True)
    .alias(email_hash=EmailHash(User.get_email_field_name()))
    .exclude(email_hash__in=opt_outs)
)
mail.mailers.default.send_messages(
    WeeklyDigestEmail.to_user(user, language="de") for user in recipients
)

Important

NotificationPreference.objects.email_hashes(notification) returns the stored overrides that differ from the notification default. For a default=True notification those are the opt-outs, so exclude them.

For a default=False notification those are the opt-ins, so include only them. product_news below uses default=False:

from .emails import product_news

opt_ins = NotificationPreference.objects.email_hashes(product_news)
recipients = (
    User.objects.filter(is_active=True)
    .alias(email_hash=EmailHash(User.get_email_field_name()))
    .filter(email_hash__in=opt_ins)
)

NotificationPreference uses HashedEmailField, so you can look up notification preferences per email address:

from django_letter.notifications.models import NotificationPreference

NotificationPreference.objects.filter(email_hash__email="ada@example.com")

Define each kind of email once and decorate the class that sends it:

# myapp/emails.py
from django.utils.translation import gettext_lazy as _

from django_letter import TemplateEmail
from django_letter.notifications import Notification

weekly_digest = Notification(
    "weekly-digest",
    _("Weekly digest"),
    _("A weekly summary of your account."),
)
product_news = Notification("product-news", _("Product news"), default=False)


@weekly_digest
class WeeklyDigestEmail(TemplateEmail):
    template_name = "emails/weekly_digest.html"
    subject = _("Your weekly digest")

Test it

Render the email in your tests without a mail backend:

def test_order_shipped():
    email = OrderShippedEmail.render_preview(order=order)

    assert "Order A-1 is on its way" in email.html
    assert "Track parcel <https://example.com/track/A-1>" in email.body

render_preview() returns the rendered email. It takes the constructor arguments of the class. context= merges extra values over get_context_data(). language= picks the language. The email carries html, body, subject and attachments.

Print the emails to your shell

Point Django at the console backend while you develop, and keep it behind DEBUG so production still sends real mail:

# settings.py
if DEBUG:
    MAILERS = {"default": {"BACKEND": "django_letter.backends.ConsoleEmailBackend"}}

The message headers and the plain-text body reach the shell. The HTML alternative and the attachments stay out, and a line below the separator counts what was left out:

MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="===============7264214045523647149=="
Subject: Invoice 2025-001
From: billing@example.com
To: ada@example.com
Date: Tue, 15 Sep 2026 16:46:14 +0000
Message-ID: <178949077460.48066.7983759548031159413@example.com>

--===============7264214045523647149==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit

Payment is due by 1 June. Invoice 2025-001

Payment is due by 1 June. The payment terms are attached.

--===============7264214045523647149==--

-------------------------------------------------------------------------------
2 more part(s) have been omitted.

Sponsors

Sponsors

About

HTML emails via Django templates

Resources

Code of conduct

Security policy

Stars

15 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages