Skip to main content

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",
        # ...
    ]
    
  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, you can preview each email at /emails/:

    WelcomeEmail in the desktop and mobile frames of the preview page

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

Sponsors

Metadata

Release files for django-letter 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-letter 0.4.0
File Size Uploaded
django_letter-0.4.0.tar.gz 30.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-letter 0.4.0
File Interpreter ABI Platform
django_letter-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 62.3 kB

Release files / django_letter-0.4.0.tar.gz

Download URL django_letter-0.4.0.tar.gz
Size 30.5 kB
Tags Source
SHA-256 checksum
How to use checksums
7d0e192d027124e3e076532cd533985d055a1d9645b8365c1b4ce932fda6a233
BLAKE2b-256 checksum
How to use checksums
6f20e0d4285f649125ad741c2d91a317d2fc4ebe67986e02859e5c8df4d52310
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

Release files / django_letter-0.4.0-py3-none-any.whl

Download URL django_letter-0.4.0-py3-none-any.whl
Size 31.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cea978b489cbf23b7f9b1fb5bf2e77db0035206fb8727d75fe89140b987406f3
BLAKE2b-256 checksum
How to use checksums
b94bcdf1e642aa13b98586d08720dc659bf4acc89984b4defa65d5c6a0ecb0d5
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

Release history Release notifications | RSS feed

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.2

2 release files

0.4.1

2 release files

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page