Issues |
Changelog |
Funding 💚
django-letter

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
-
Add the package:
uv add django-letter -
Add the app to your settings:
# settings.py INSTALLED_APPS = [ "django_letter", # ... ]
-
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, you can preview each email at/emails/:
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.
The 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()
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
Metadata
Release files for django-letter 0.4.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_letter-0.4.2.tar.gz | 30.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_letter-0.4.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 62.7 kB
Release files / django_letter-0.4.2.tar.gz
| Download URL | django_letter-0.4.2.tar.gz |
|---|---|
| Size | 30.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
54bb0791dddd7f855e2f1e9ed48c1546c71baad0175205cc781223f2c2f1b568
|
|
BLAKE2b-256 checksum How to use checksums |
ad3654432ad43cede649b7c432a76a4a20cde0e6abbb631514756aa83f51609c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency logRelease files / django_letter-0.4.2-py3-none-any.whl
| Download URL | django_letter-0.4.2-py3-none-any.whl |
|---|---|
| Size | 32.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3845973f25fcd6b02c56ff181570cd06e5a734efafe58593a0d793200bb8800a
|
|
BLAKE2b-256 checksum How to use checksums |
fefd5747ed96e90bdd4399f341aaf56617f131ddadea2ea24fb99b3e927b4e93
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency log