Issues |
Changelog |
Funding 💚
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.
-
Add the package:
uv add django-letter -
Add the app to your settings:
# settings.py INSTALLED_APPS = [ "django_letter", "django_letter.notifications", # Optional. Adds unsubscribe links. # ... ]
-
Add an
emails.pymodule to each app that sends mail. Put it next to that app'sviews.py. -
Mount the debug pages in your root URLconf:
# urls.py from django.urls import include, path urlpatterns = [ path("emails/", include("django_letter.urls")), ]
While
DEBUGis true,/emails/lists every discovered email with the module of its class and its template, and previews each one:
-
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.htmlfor the packaged settings view. Log in to change your preferences.
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()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")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.bodyrender_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.
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.