Email

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, checked on 2026-10-03; where Django 5.2 LTS (5.2.17) behaves differently the text says so. Every runnable listing was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, the 18 tests in this page’s test listings (extracted from the page and run verbatim with manage.py test), a curl session against runserver, shell snippets, and a real SMTP conversation with a local debugging server (aiosmtpd) all passed, and the output shown is the real output. The settings blocks that name a provider (an SMTP relay, example.third.party) are configuration to adapt, not code to run. No example needs PostgreSQL, Redis or Docker. Nothing on this page contacts a real email provider: the django-anymail examples use the test backend that ships with the package, and the text says where a production mailer needs a provider account.

Email is the oldest "side effect" a web application has. A visitor registers, a review is approved, an order ships, and someone has to be told. Django wraps Python’s email and smtplib modules in django.core.mail: a few functions for the common cases, an EmailMessage class for everything else, and a pluggable backend that decides how a message actually leaves the process. Two releases changed this area more than any since Django 1.x. Django 6.0 moved message building to Python’s modern email API, and Django 6.1 replaced the scattered EMAIL_* settings with one MAILERS setting that works like CACHES and DATABASES. The page walks through both, in the context of the Bookshelf catalogue that grows across this section: the catalog app sends a "your review is live" message when an editor approves a review. The settings file is covered on Settings & Configuration; the reset-password message that django.contrib.auth sends is part of Passwords.

In a hurry: what to use for what

I need to…​ Use Section

Say how Django should send email

The MAILERS setting (6.1); startproject writes a console-backend default entry.

Configuring email: the MAILERS setting (6.1)

Send one plain message

send_mail(subject, message, from_email, recipient_list)

The sending functions

Send HTML plus plain text, Cc/Bcc, headers, attachments

EmailMultiAlternatives / EmailMessage with keyword arguments.

EmailMessage and EmailMultiAlternatives, Attachments and inline images

Send through a second provider or account

Another MAILERS alias and using="alias".

Multiple mailers and the using argument

Send many messages over one connection

mail.mailers[alias].send_messages([…​]) or with mail.mailers[alias] as backend:.

Sending many messages efficiently

Build an address from a user’s name

email.headerregistry.Address, never an f-string.

Addresses and header injection

Develop without sending anything

Console backend, file backend, or a local aiosmtpd server.

Backends

Use SendGrid, Mailgun, Amazon SES, …​

django-anymail backends, configured as a mailer.

Providers with django-anymail

Move a 6.0 project to MAILERS

Map each EMAIL_* setting to a MAILERS key and fix fail_silently/connection callers.

Migrating from EMAIL_* to MAILERS

Assert on what was "sent"

django.core.mail.outbox inside a Django test; sent_using tells you which mailer.

Testing with mail.outbox

Where email sits in the request/response cycle

send_mail() and EmailMessage.send() are ordinary, blocking calls. They build the message, ask the backend to deliver it, and return only when the backend has finished. With the SMTP backend that means a network round trip to a mail server inside your view, so a slow provider becomes a slow page and an unreachable one becomes an exception in the middle of the request. Three habits follow from that:

  • Send after the database transaction that justifies the message has committed, with transaction.on_commit() (see Transactions, Raw SQL & Multiple Databases). The view in A view that sends after commit does this.

  • Move slow or important sends out of the request into a background worker: the Tasks framework added in Django 6.0 (covered on a later page of this section) or Celery. The worker then calls the same send_mail(); see Delivery Guarantees for why such a task must tolerate running twice.

  • In an async def view, remember there is no asynchronous email API: in Django 6.1 django.core.mail has no asend_mail() or any other a-prefixed sender (a dir() of the module shows none). Wrap the call with asgiref.sync.sync_to_async, shown in Sending from async code, or hand it to a task.

The Bookshelf pieces used on this page

The examples belong to the catalog app. The only new model is a Review; Author and Book are the same two models used on the earlier pages, reduced to what the email needs.

catalog/models.py
from django.conf import settings
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=200)

    def __str__(self):
        return self.name


class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")

    def __str__(self):
        return self.title


class Review(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
    reviewer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    body = models.TextField()
    approved = models.BooleanField(default=False)

Source: Django: Models

The settings that matter for this page are the sender addresses and the two lists that mail_admins() and mail_managers() read. The secret key comes from the environment, as everywhere in this section.

config/settings.py (excerpt)
import os

from django.core.management.utils import get_random_secret_key

SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") or get_random_secret_key()

DEFAULT_FROM_EMAIL = '"Bookshelf" <noreply@bookshelf.example>'  # From: of ordinary messages
SERVER_EMAIL = "errors@bookshelf.example"                       # From: of mail_admins()/mail_managers()
ADMINS = ["admin@bookshelf.example"]                            # error reports and mail_admins()
MANAGERS = ["editor@bookshelf.example"]                         # mail_managers() and broken-link reports

Since Django 6.0, ADMINS and MANAGERS are lists of address strings; the older list of (name, address) tuples is deprecated because Django never used the name. To keep a display name, write the string as '"Name" <address>'.

Configuring email: the MAILERS setting (6.1)

New Django projects are not set up to send real email. In Django 6.1 the settings file written by startproject ends with a MAILERS entry that points the default mailer at the console backend, so every message is printed to the terminal that runs runserver:

config/settings.py (written by startproject in 6.1)
MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.console.EmailBackend",
    },
}

MAILERS is a dictionary from an alias to a configuration, in the same style as CACHES, DATABASES, STORAGES and TASKS. Each configuration has two keys, both optional:

  • "BACKEND": the dotted path of an email backend class. If it is omitted the SMTP backend is used.

  • "OPTIONS": a dictionary of keyword arguments given to that backend’s constructor. Which options exist depends on the backend; Backends lists the built-in ones.

The alias "default" is special: every sending function uses it when you do not pass using. To send through an SMTP server on the local machine, which is what Django did implicitly before 6.1:

config/settings.py
MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": "localhost",
        },
    },
}

What happens when MAILERS is not defined depends on the release line. In Django 6.1 the old behaviour still applies, with deprecation warnings: mail goes through the SMTP backend to localhost port 25, configured by the EMAIL_* settings. The documentation says that, once the deprecation period ends, sending without MAILERS will raise MailerDoesNotExist. Two names are in use for that moment: the topic page says "Django 2028" and the deprecated-settings notes say "Django 7.0", because Django 6.1 adopted calendar versioning (DEP 20) and the release planned as 7.0 is now called Django 2028. The warning text emitted by Django 6.1.1 still reads "before Django 7.0" and the warning class is still named RemovedInDjango70Warning, so grep for either name.

In tests you do not configure anything: the test runner replaces the BACKEND of every configured alias with the in-memory locmem backend (see Testing with mail.outbox). The scratch run confirmed it with a settings module whose default mailer pointed at an SMTP server that was not running; the tests passed because nothing tried to connect.

The sending functions

send_mail()

send_mail() sends one message. Its signature in 6.1 is send_mail(subject, message, from_email, recipient_list, *, fail_silently=False, auth_user=None, auth_password=None, connection=None, html_message=None, using=None); the first four arguments are required and the rest are keyword-only.

Plain text, then HTML plus plain text, through the default mailer (shell)
from django.core.mail import send_mail

send_mail(
    "Subject here",
    "Here is the message.",
    "from@example.com",
    ["to@example.com"],
)

send_mail(
    "Hello",
    "Plain text version",
    None,                                   # None: use DEFAULT_FROM_EMAIL
    ["reader@example.com"],
    html_message="<p>HTML <strong>version</strong></p>",
)

The return value is the number of messages delivered, which for send_mail() is 1 or 0. Every address in recipient_list sees the others in the To: header, so one call is one message to a group, not one message each. html_message turns the message into multipart/alternative with message as text/plain and html_message as text/html.

send_mass_mail(), mail_admins() and mail_managers()

send_mass_mail(datatuple) takes a tuple of (subject, message, from_email, recipient_list) tuples and sends one separate message per element over a single connection. This is the difference from calling send_mail() in a loop, which opens a connection every time, and from one send_mail() with two recipients, which produces one message that both recipients see addressed to both.

One message per recipient with send_mass_mail (catalog/tests.py)
from django.core import mail
from django.core.mail import send_mass_mail
from django.test import TestCase


class MassMailTests(TestCase):
    def test_send_mass_mail_one_message_per_tuple(self):
        datatuple = (
            ("Subject", "Message.", "from@example.com", ["john@example.com"]),
            ("Subject", "Message.", "from@example.com", ["jane@example.com"]),
        )
        self.assertEqual(send_mass_mail(datatuple), 2)
        self.assertEqual(
            [m.to for m in mail.outbox],
            [["john@example.com"], ["jane@example.com"]],
        )

mail_admins(subject, message, …​) and mail_managers(subject, message, …​) are shortcuts that send to the addresses in ADMINS and MANAGERS. mail_admins() prefixes the subject with EMAIL_SUBJECT_PREFIX (default "[Django] ") and uses SERVER_EMAIL as the sender; so does mail_managers(). Both accept html_message and, since 6.1, using.

mail_admins and mail_managers (catalog/tests.py)
from django.core import mail
from django.core.mail import mail_admins, mail_managers
from django.test import TestCase


class AdminMailTests(TestCase):
    def test_mail_admins_and_managers(self):
        mail_admins("Disk almost full", "95%")
        mail_managers("New review queue", "3 waiting")
        self.assertEqual(mail.outbox[0].subject, "[Django] Disk almost full")
        self.assertEqual(mail.outbox[0].from_email, "errors@bookshelf.example")
        self.assertEqual(mail.outbox[0].to, ["admin@bookshelf.example"])
        self.assertEqual(mail.outbox[1].to, ["editor@bookshelf.example"])

Keyword-only arguments (6.0)

Django 6.0 deprecated passing the less common parameters positionally. For the functions, everything from fail_silently onwards must be a keyword argument; for EmailMessage and EmailMultiAlternatives, only the first four (subject, body, from_email, to) may still be positional. In 6.1.1 the old form still works and emits a warning, which manage.py hides unless you ask for it with python -W default manage.py …​:

Output of EmailMessage(…​, ["bcc@example.com"]) with warnings enabled
<string>:3: RemovedInDjango70Warning: Passing positional argument(s) 'bcc' to EmailMessage() is deprecated. Use keyword arguments instead.

EmailMessage and EmailMultiAlternatives

send_mail() and its siblings are thin wrappers around EmailMessage. Anything they cannot express (bcc, reply_to, extra headers, attachments, several body versions) needs the class. EmailMessage only builds the message; the backend sends it. The constructor parameters are:

Parameter Meaning

subject, body, from_email, to

The first four may be positional. body is plain text; from_email defaults to DEFAULT_FROM_EMAIL; to is a list or tuple.

cc, bcc, reply_to

Lists or tuples, keyword-only. bcc recipients are used for delivery but never written into the headers.

attachments

A list of MIMEPart objects (6.0), EmailAttachment named tuples, or (filename, content, mimetype) tuples.

headers

A dictionary of extra headers (stored as extra_headers). You are responsible for valid names and values.

connection

A backend instance; deprecated in 6.1 in favour of using (see Migrating from EMAIL_* to MAILERS).

A complete message and what message() returns (catalog/tests.py)
import email.message

from django.core import mail
from django.core.mail import EmailMessage
from django.test import TestCase


class EmailMessageTests(TestCase):
    def test_full_message(self):
        message = EmailMessage(
            subject="Hello",
            body="Body goes here",
            from_email="from@example.com",
            to=["to1@example.com", "to2@example.com"],
            bcc=["bcc@example.com"],
            reply_to=["another@example.com"],
            headers={"X-Campaign": "autumn"},
        )
        self.assertEqual(message.send(), 1)
        self.assertEqual(
            message.recipients(),
            ["to1@example.com", "to2@example.com", "bcc@example.com"],
        )
        raw = mail.outbox[0].message()
        self.assertIsInstance(raw, email.message.EmailMessage)
        self.assertEqual(raw["Reply-To"], "another@example.com")
        self.assertNotIn("bcc@example.com", raw.as_string())

Multipart messages built from templates

Real applications render the text and HTML bodies from templates, so designers can edit them and autoescaping protects the HTML. EmailMultiAlternatives is an EmailMessage that can carry extra versions of the body. The plain text goes in body, and attach_alternative(content, mimetype) adds the HTML. Email clients show the richest version they understand and fall back to the text. Keep the text version: it is also what spam filters and screen readers rely on.

catalog/templates/catalog/email/review_approved.txt
Hello {{ name }},

Your review of "{{ book.title }}" by {{ book.author }} has been approved and is now visible.

The Bookshelf team
catalog/templates/catalog/email/review_approved.html
<p>Hello {{ name }},</p>
<p>Your review of <em>{{ book.title }}</em> by {{ book.author }} has been approved and is now visible.</p>
<p>The Bookshelf team</p>
catalog/emails.py
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string


def send_review_approved(review):
    """Send the multipart 'your review was approved' message to the reviewer."""
    user = review.reviewer
    context = {"name": user.get_username(), "book": review.book}
    msg = EmailMultiAlternatives(
        subject=f"Your review of {review.book.title} is live",
        body=render_to_string("catalog/email/review_approved.txt", context),
        to=[user.email],
        reply_to=["editor@bookshelf.example"],
        headers={"List-Unsubscribe": "<mailto:unsubscribe@bookshelf.example>"},
    )
    msg.attach_alternative(
        render_to_string("catalog/email/review_approved.html", context), "text/html"
    )
    return msg.send()

Because render_to_string() renders the .txt template with autoescaping too, a book title such as Q&A would appear as Q&A in the text version if the template engine escapes it. Render the text template with {% autoescape off %} around the variables, or build the plain text without a template, when titles can contain &, < or quotes.

Since Django 5.2 the extra versions are listed in msg.alternatives as EmailAlternative named tuples (content, mimetype), attachments in msg.attachments as EmailAttachment named tuples (filename, content, mimetype), and body_contains(text) tells you whether a string is in the body and in every text/* alternative:

Testing the multipart message (catalog/tests.py)
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase

from catalog.emails import send_review_approved
from catalog.models import Author, Book, Review


class MultipartTemplateTests(TestCase):
    def test_review_approved(self):
        author = Author.objects.create(name="Ursula K. Le Guin")
        book = Book.objects.create(title="The Dispossessed", author=author)
        user = get_user_model().objects.create_user("ana", email="ana@example.com")
        review = Review.objects.create(book=book, reviewer=user, body="Great")
        self.assertEqual(send_review_approved(review), 1)
        msg = mail.outbox[0]
        self.assertEqual(msg.to, ["ana@example.com"])
        self.assertEqual(
            msg.extra_headers["List-Unsubscribe"],
            "<mailto:unsubscribe@bookshelf.example>",
        )
        self.assertEqual(len(msg.alternatives), 1)
        self.assertEqual(msg.alternatives[0].mimetype, "text/html")
        self.assertIs(msg.body_contains("The Dispossessed"), True)
        self.assertIs(msg.body_contains("<em>"), False)

By default the main content type of an EmailMessage is text/plain. Setting msg.content_subtype = "html" makes the body itself text/html; the documentation advises leaving it alone unless you know every recipient can read HTML, and the multipart form above is the safer choice.

Attachments and inline images

There are three ways to attach something, and a fourth that is new in Django 6.0:

  • msg.attach(filename, content, mimetype) attaches in-memory data. If mimetype is omitted it is guessed from the file name. For text/* types content should be a string; binary data given to a text type is decoded as UTF-8 and, if that fails, the type becomes application/octet-stream.

  • msg.attach_file(path, mimetype=None) reads a file from disk.

  • The attachments=[…​] constructor argument takes the same tuples, EmailAttachment tuples or MIMEPart objects.

  • msg.attach(mimepart) takes a Python email.message.MIMEPart (6.0). Use it when the attachment needs extra headers or parameters, for example an inline image with a Content-ID. Passing a legacy email.mime.base.MIMEBase object still works but is deprecated since 6.0.

A CSV from memory, a file from disk (catalog/tests.py)
from pathlib import Path
from tempfile import TemporaryDirectory

from django.core import mail
from django.core.mail import EmailMessage
from django.test import TestCase


class AttachmentTests(TestCase):
    def test_attachments(self):
        msg = EmailMessage("Report", "See attached", "from@example.com", ["to@example.com"])
        msg.attach("report.csv", "title,price\nDune,9.99\n", "text/csv")
        with TemporaryDirectory() as tmp:
            path = Path(tmp) / "cover.png"
            path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
            msg.attach_file(path)
        msg.send()
        sent = mail.outbox[0]
        self.assertEqual([a.filename for a in sent.attachments], ["report.csv", "cover.png"])
        self.assertEqual(sent.attachments[1].mimetype, "image/png")
An inline image referenced from the HTML with a Content-ID (catalog/tests.py)
import email.utils
from email.message import MIMEPart

from django.core import mail
from django.core.mail import EmailMultiAlternatives
from django.test import TestCase


class InlineImageTests(TestCase):
    def test_inline_image_with_mimepart(self):
        msg = EmailMultiAlternatives("Logo", "text", "from@example.com", ["to@example.com"])
        cid = email.utils.make_msgid()                 # random Content-ID, with angle brackets
        inline = MIMEPart()
        inline.set_content(
            b"\x89PNG\r\n\x1a\n" + b"\x00" * 16,       # real code: the image bytes
            maintype="image", subtype="png", disposition="inline", cid=cid,
        )
        msg.attach(inline)
        msg.attach_alternative(f'<img src="cid:{cid[1:-1]}">', "text/html")  # no angle brackets in cid:
        msg.send()
        raw = mail.outbox[0].message().as_string()
        self.assertIn("Content-ID: " + cid, raw)
        self.assertIn("Content-Disposition: inline", raw)

EmailMessage.message(*, policy=email.policy.default) (the policy argument is new in 6.0) returns the Python email.message.EmailMessage that will be serialised. The SMTP backend asks for email.policy.SMTP so lines end in \r\n as the protocol requires. Override message(), not undocumented underscore methods, if you subclass:

Choosing a policy (catalog/tests.py)
import email.policy

from django.core.mail import EmailMessage
from django.test import SimpleTestCase


class PolicyTests(SimpleTestCase):
    def test_message_policy(self):
        msg = EmailMessage("Hi", "line one\nline two", "f@example.com", ["t@example.com"])
        self.assertIn(b"line one\r\nline two", msg.message(policy=email.policy.SMTP).as_bytes())
        self.assertIn(b"line one\nline two", msg.message().as_bytes())

Non-ASCII subjects and bodies need no special handling: the modern API is Unicode-friendly, and a message with the subject Café ☕ serialises as UTF-8 (Content-Transfer-Encoding: 8bit for the body) on the default policy.

Addresses and header injection

An address can carry a display name, "Fred" <fred@example.com>, and the display name may contain commas and quotes. Two different attacks live in that syntax, and Django’s documentation treats both as the application’s problem or Django’s:

  • Address injection is yours to prevent. Never build an address by formatting strings, for example f'"{name}" <{email}>': a name such as Bob" <attacker@evil.example>, "x smuggles in a second recipient. Use email.headerregistry.Address, which Django’s built-in backends accept directly in to, cc, bcc and reply_to. If a third-party backend rejects it, pass str(address).

  • CRLF (header) injection is blocked by Django. A carriage return or line feed in any header makes the message refuse to serialise with ValueError. Before 6.0 some cases raised django.core.mail.BadHeaderError; that class is deprecated and Python’s modern policy raises ValueError instead. A custom backend that does not call EmailMessage.message() (or calls it with the legacy compat32 policy) must do its own CRLF protection.

Both protections, tested (catalog/tests.py)
from email.headerregistry import Address

from django.core import mail
from django.core.mail import EmailMessage, send_mail
from django.test import TestCase


class SecurityTests(TestCase):
    def test_header_injection_raises_value_error(self):
        msg = EmailMessage(
            "Hello\r\nBcc: victim@example.com", "x", "from@example.com", ["to@example.com"]
        )
        with self.assertRaises(ValueError):
            msg.message()

    def test_address_object_is_safe(self):
        evil = 'Bob" <attacker@evil.example>, "x'
        address = Address(display_name=evil, addr_spec="bob@example.com")
        send_mail("Hi", "Body", None, [address])
        header = mail.outbox[0].message()["To"]
        self.assertEqual(len(header.addresses), 1)
        self.assertEqual(header.addresses[0].addr_spec, "bob@example.com")

In a real helper that greets a user by name, build the Address from user.get_full_name() and user.email, exactly as the documentation’s send_mail_to_user() does. Beyond injection, any page that can send email to an address typed by an anonymous visitor (a "share this page" box, a newsletter sign-up, a contact form that auto-replies) is an abuse target: rate-limit it, verify addresses before sending anything with real content, and never let the visitor control the subject, the body or the recipient list. The topic page points to OWASP’s email validation cheat sheet for the authentication side of this.

A view that sends after commit

The editor approves a review with a staff-only POST. The email is scheduled with transaction.on_commit(), so it is sent only if the approval was really saved; Django’s ATOMIC_REQUESTS or an explicit transaction.atomic() block would otherwise let the message go out before a rollback.

catalog/views.py
from django.contrib.admin.views.decorators import staff_member_required
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_POST

from .emails import send_review_approved
from .models import Review


@staff_member_required
@require_POST
def approve_review(request, pk):
    review = get_object_or_404(Review, pk=pk)
    review.approved = True
    review.save(update_fields=["approved"])
    # Send only if the transaction that approved the review is really committed.
    transaction.on_commit(lambda: send_review_approved(review))
    return HttpResponse("approved", status=202)
catalog/urls.py
from django.urls import path

from . import views

app_name = "catalog"
urlpatterns = [
    path("reviews/<int:pk>/approve/", views.approve_review, name="approve-review"),
]

With the project’s console mailer, runserver prints the message. The session below logs in as a staff user through the admin login form (the CSRF token goes in the X-CSRFToken header; the password is read from the PW environment variable) and posts to the view. The server was started with python manage.py runserver 46101 --noreload:

Approving a review with curl
J=cookies.txt
curl -s -c $J -b $J http://127.0.0.1:46101/admin/login/ -o /dev/null
T=$(grep csrftoken $J | awk '{print $7}')
curl -s -c $J -b $J -o /dev/null -w 'login %{http_code}\n' \
  -d "username=eve&password=$PW&csrfmiddlewaretoken=$T&next=/admin/" \
  -H "Referer: http://127.0.0.1:46101/admin/login/" http://127.0.0.1:46101/admin/login/
T=$(grep csrftoken $J | awk '{print $7}')
curl -s -i -b $J -c $J -X POST -H "X-CSRFToken: $T" -H "Referer: http://127.0.0.1:46101/" \
  http://127.0.0.1:46101/reviews/1/approve/ | sed -n '1p;$p'
curl -s -o /dev/null -w 'GET -> %{http_code}\n' -b $J http://127.0.0.1:46101/reviews/1/approve/
login 302
HTTP/1.1 202 Accepted
approved
GET -> 405

The development server’s terminal showed the message that the console backend wrote:

MIME-Version: 1.0
Content-Type: multipart/alternative;
 boundary="===============0844861002314230432=="
Subject: Your review of The Dispossessed is live
From: "Bookshelf" <noreply@bookshelf.example>
To: ana@example.com
Reply-To: editor@bookshelf.example
Date: Sat, 03 Oct 2026 00:50:42 +0000
Message-ID: <…>
List-Unsubscribe: <mailto:unsubscribe@bookshelf.example>

--===============0844861002314230432==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable

Hello ana,
…

The matching test uses captureOnCommitCallbacks(execute=True), because Django’s TestCase wraps each test in a transaction that never commits, so on_commit callbacks would otherwise never run:

Testing the view (catalog/tests.py)
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase

from catalog.models import Author, Book, Review


class ApproveViewTests(TestCase):
    def test_approve_sends_email_after_commit(self):
        author = Author.objects.create(name="Ursula K. Le Guin")
        book = Book.objects.create(title="The Dispossessed", author=author)
        reader = get_user_model().objects.create_user("ana", email="ana@example.com")
        staff = get_user_model().objects.create_user("eve", is_staff=True)
        review = Review.objects.create(book=book, reviewer=reader, body="Great")
        self.client.force_login(staff)
        with self.captureOnCommitCallbacks(execute=True) as callbacks:
            response = self.client.post(f"/reviews/{review.pk}/approve/")
        self.assertEqual(response.status_code, 202)
        self.assertEqual(len(callbacks), 1)
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(mail.outbox[0].to, ["ana@example.com"])
        self.assertEqual(self.client.get(f"/reviews/{review.pk}/approve/").status_code, 405)

Sending from async code

Because there is no async email API, call the blocking function through sync_to_async, which runs it in a worker thread so the event loop stays free. This is a stop-gap for a message you must send inside the request; for anything that can wait, enqueue a task instead.

Sending from an async view or test (catalog/tests.py)
from asgiref.sync import sync_to_async
from django.core.mail import send_mail
from django.test import TestCase


class AsyncSendTests(TestCase):
    async def test_send_from_async_code(self):
        sent = await sync_to_async(send_mail)("Hi", "Body", None, ["to@example.com"])
        self.assertEqual(sent, 1)

Multiple mailers and the using argument

The point of an alias is that different mail takes different routes: internal alerts through a local relay, receipts through a transactional provider, newsletters through a bulk service. Define one alias each, then name it when sending:

config/settings.py
import os

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": "smtp.example.net",
            "use_tls": True,
            "username": os.environ["EMAIL_ACCOUNT_ID"],
            "password": os.environ["EMAIL_API_KEY"],
        },
    },
    "notifications": {
        "BACKEND": "example.third.party.EmailBackend",
        "OPTIONS": {
            "api_key": os.environ["THIRD_PARTY_API_KEY"],
            "region": "eu",
        },
    },
    "admin": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {"host": "localhost"},
    },
}

example.third.party.EmailBackend is the documentation’s placeholder for a provider backend; it is not a real package, so this exact file cannot be run without replacing it. The credentials come from the environment and never appear in the source. In the scratch project the same shape was run with locmem aliases:

Choosing an alias (catalog/tests.py)
from django.core import mail
from django.core.mail import EmailMessage, send_mail
from django.test import TestCase, override_settings

MAILERS = {
    "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"},
    "notifications": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"},
}


class UsingTests(TestCase):
    @override_settings(MAILERS=MAILERS)
    def test_using_selects_alias(self):
        send_mail("A", "a", "from@example.com", ["to@example.com"])
        send_mail("B", "b", "from@example.com", ["to@example.com"], using="notifications")
        EmailMessage("C", "c", "from@example.com", ["to@example.com"]).send(using="notifications")
        self.assertEqual(
            [m.sent_using for m in mail.outbox], ["default", "notifications", "notifications"]
        )

Every sending function accepts using: send_mail(), send_mass_mail(), mail_admins(), mail_managers() and EmailMessage.send(using=…​). A few rules from the 6.1 documentation:

  • using cannot be combined with the deprecated fail_silently, auth_user, auth_password or connection arguments; doing so raises TypeError. Earlier releases silently ignored some of them when a connection was also given.

  • An unknown alias raises MailerDoesNotExist; any other configuration problem (for example an unknown OPTIONS key) raises InvalidMailer. Both are importable from django.core.mail.

  • If there is no "default" alias, the system check mail.W001 (new in 6.1) warns at start-up: the message Your MAILERS setting has no 'default' entry comes with the hint to add one or pass using explicitly.

  • Features that send mail for you take an alias too: the logging AdminEmailHandler has a using option (see Error-reporting mail).

Sending many messages efficiently

Opening an SMTP connection costs a TCP handshake, usually TLS, and authentication. django.core.mail.mailers (new in 6.1) is a dict-like object that returns backend instances, and a backend instance has send_messages(), open() and close(). Pass send_messages() a list of EmailMessage objects and they all go through one connection, which is how send_mass_mail() works inside. When you want to keep the connection open across several calls, use the backend as a context manager so it closes even if an error occurs:

Reusing one connection (catalog/tests.py)
from django.core import mail
from django.core.mail import EmailMessage
from django.test import TestCase, override_settings

MAILERS = {
    "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"},
    "notifications": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"},
}


class ManyMessagesTests(TestCase):
    @override_settings(MAILERS=MAILERS)
    def test_send_messages_with_context_manager(self):
        emails = [EmailMessage("Hi", "x", None, [f"u{i}@example.com"]) for i in range(3)]
        with mail.mailers["notifications"] as backend:
            self.assertEqual(backend.send_messages(emails), 3)
        self.assertEqual(len(mail.outbox), 3)
        self.assertEqual(mail.mailers.default.alias, "default")

send_messages() returns the number of messages delivered. If the connection was not open, it opens it and closes it afterwards; if you opened it, it stays open. mailers.default is shorthand for mailers["default"], and it raises MailerDoesNotExist when no default is configured. Do not call a backend class directly (EmailBackend(…​)): obtaining the instance from mailers is what applies your OPTIONS and the alias.

Backends

A backend is a class with a send_messages(email_messages) method. Django ships five. Besides SMTP they are meant for development and testing, and 6.1 adds the deployment-only check mail.E001, which fails manage.py check --deploy when the default mailer uses one of them:

Deploy check with the console backend as default
ERRORS:
?: (mail.E001) Your MAILERS setting uses a development-only email backend in the 'default' entry (django.core.mail.backends.console.EmailBackend).
	HINT: Use a production-ready email backend, such as the SMTP backend, otherwise email will not be sent.
Backend path OPTIONS Use

django.core.mail.backends.smtp.EmailBackend

host (required), port, username, password, use_tls, use_ssl, ssl_certfile, ssl_keyfile, timeout, fail_silently

Production: your own relay or most commercial providers over SMTP.

django.core.mail.backends.console.EmailBackend

stream (default stdout), fail_silently

Development: print each message.

django.core.mail.backends.filebased.EmailBackend

file_path (required; string or Path), fail_silently

Development: one file per backend session in that directory.

django.core.mail.backends.locmem.EmailBackend

none

Tests and development: append messages to mail.outbox.

django.core.mail.backends.dummy.EmailBackend

none

Throw messages away, but still report them as sent.

SMTP

host is required. If port is omitted it follows the security option: 587 with use_tls, 465 with use_ssl, 25 for neither. Set at most one of use_tls (explicit TLS, STARTTLS) and use_ssl (implicit TLS). username and password are the SMTP authentication credentials, not your From: address. timeout is in seconds; if omitted the socket default applies, which is no timeout at all, so a stalled provider can block a worker indefinitely. Set one. Since Django 4.2 the SMTP backend verifies the server’s hostname and certificate; a private CA or a self-signed certificate must be added to the system trust store (or SSL_CERT_FILE/SSL_CERT_DIR used), and ssl_certfile/ssl_keyfile are only for client-certificate authentication.

A production SMTP mailer (config/settings.py)
import os

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": "smtp.example.net",
            "use_tls": True,
            "username": os.environ["MY_APP_SMTP_USER"],
            "password": os.environ["MY_APP_SMTP_PASSWORD"],
            "timeout": 10,
        },
    },
}

The fail_silently option exists for backward compatibility. It ignores every OSError while connecting and every smtplib.SMTPException while talking to the server, which hides transient glitches and serious misconfiguration alike, and it does not cover errors while serialising the message. Django’s documentation does not recommend it.

Console, file, locmem and dummy

The console backend (the 6.1 startproject default) writes each message to stream, by default standard output. The file backend writes to file_path and creates the directory if needed:

A file mailer, tested against a temporary directory (catalog/tests.py)
from pathlib import Path
from tempfile import TemporaryDirectory

from django.core.mail import send_mail
from django.test import TestCase, override_settings


class FileBackendTests(TestCase):
    def test_file_backend(self):
        with TemporaryDirectory() as tmp:
            with override_settings(MAILERS={"default": {
                "BACKEND": "django.core.mail.backends.filebased.EmailBackend",
                "OPTIONS": {"file_path": tmp},
            }}):
                send_mail("Hi", "x", "f@example.com", ["t@example.com"])
            files = list(Path(tmp).glob("*.log"))
            self.assertEqual(len(files), 1)
            self.assertIn("Subject: Hi", files[0].read_text())

The locmem backend keeps messages in django.core.mail.outbox, a list that is created when the first message is sent, and, since Django 6.1, annotates each message with sent_using, the alias that carried it. The dummy backend does nothing and returns the number of messages it was given, so send_mail() still returns 1.

A local SMTP server for development

When you want to see what a real SMTP conversation produces without leaving the machine, run the aiosmtpd debugging server the Django documentation recommends and point an SMTP mailer at it. aiosmtpd 1.4.5 is the first release confirmed for Python 3.12.

Start the server and a settings module that targets it
python -m pip install "aiosmtpd >= 1.4.5"
python -u -m aiosmtpd -n -l localhost:46025
config/smtp_settings.py
from .settings import *  # noqa: F403

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {"host": "localhost", "port": 46025, "timeout": 10},
    },
}
Sending through it
DJANGO_SETTINGS_MODULE=config.smtp_settings python manage.py shell -v 0 -c "
from django.core.mail import send_mail
print(send_mail('Hello from Bookshelf', 'Sent through a real SMTP conversation.', None, ['reader@example.com']))"

The shell printed 1 and the aiosmtpd terminal showed:

---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0
Subject: Hello from Bookshelf
From: "Bookshelf" <noreply@bookshelf.example>
To: reader@example.com
Date: Sat, 03 Oct 2026 00:49:46 +0000
Message-ID: <…>
X-Peer: ('::1', …)

Sent through a real SMTP conversation.
------------ END MESSAGE ------------

The sendtestemail management command sends a canned message to the addresses you give it and, since 6.1, has --using to pick the alias (plus --admins and --managers to test those lists), which makes it the quickest check that a production mailer works: python manage.py sendtestemail --using notifications you@example.com. Management commands are covered on Management Commands & the Shell.

Writing a backend

A custom backend subclasses BaseEmailBackend and implements send_messages(), returning the number of messages delivered; add open() and close() if it has a connection. Since 6.1 the constructor contract changed so that OPTIONS can be passed as keyword arguments: accept an explicit keyword for each option, accept kwargs, and give only the unused kwargs (which now include alias) to the superclass. The superclass no longer ignores unknown keywords: an OPTIONS key nobody consumed raises InvalidMailer.

catalog/backends/recording.py
import logging

from django.core.mail.backends.base import BaseEmailBackend

logger = logging.getLogger(__name__)


class EmailBackend(BaseEmailBackend):
    """Logs a one-line summary per message instead of sending it."""

    def __init__(self, *, level=logging.INFO, **kwargs):
        # Explicit keyword for every OPTIONS key; the rest goes to the superclass,
        # which also receives the new ``alias`` argument.
        super().__init__(**kwargs)
        self.level = level

    def send_messages(self, email_messages):
        for message in email_messages:
            logger.log(
                self.level,
                "[%s] %s -> %s",
                self.alias,
                message.subject,
                ", ".join(message.recipients()),
            )
        return len(email_messages)
Using it, and the error for an unknown option (catalog/tests.py)
import logging

from django.core import mail
from django.core.mail import send_mail
from django.test import TestCase, override_settings


class CustomBackendTests(TestCase):
    @override_settings(MAILERS={
        "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"},
        "recording": {
            "BACKEND": "catalog.backends.recording.EmailBackend",
            "OPTIONS": {"level": logging.WARNING},
        },
    })
    def test_custom_backend(self):
        with self.assertLogs("catalog.backends.recording", level="WARNING") as logs:
            send_mail("Hi", "x", "f@example.com", ["t@example.com"], using="recording")
        self.assertEqual(
            logs.output, ["WARNING:catalog.backends.recording:[recording] Hi -> t@example.com"]
        )

    @override_settings(MAILERS={"default": {
        "BACKEND": "catalog.backends.recording.EmailBackend",
        "OPTIONS": {"colour": "red"},
    }})
    def test_unknown_option_is_invalid_mailer(self):
        with self.assertRaises(mail.InvalidMailer):
            mail.mailers.default

A reusable backend that must also run on older Django can detect the mode with getattr(self, "alias", None): None means the project has no MAILERS, so the deprecated EMAIL_* settings are still the source of configuration. Such code must not read settings.EMAIL_HOST and friends when an alias is set, because with MAILERS defined that access raises AttributeError. A library can test for support with hasattr(django.core.mail, "mailers") or django.VERSION >= (6, 1).

Providers with django-anymail

SMTP is enough for many sites, but most email providers also have an HTTP API with features SMTP lacks: delivery tracking, tags, templates, bounce webhooks and inbound mail. Django’s documentation points to community backends for these and does not bless one. The most widely used is django-anymail, which supports the common providers behind one API. At the time of writing, 2026-10-03, the latest release is 15.2 (2026-09-05), and its quickstart documents the Django 6.1 MAILERS form; its PyPI classifiers stop at Django 6.0, which is a lag rather than an incompatibility: it ran unchanged on Django 6.1.1 in the scratch project.

The configuration is a normal mailer whose backend is an Anymail class, plus anymail in INSTALLED_APPS. The provider’s credentials go in OPTIONS (the exact keys differ per provider; read them from the environment). Anymail ships a test backend that accepts every Anymail feature and sends nothing, which is what the scratch project used, so no provider account was needed:

config/anymail_settings.py
from .settings import *  # noqa: F403

INSTALLED_APPS = [*INSTALLED_APPS, "anymail"]  # noqa: F405
MAILERS = {
    "default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"},
    # In production: "anymail.backends.<esp>.EmailBackend" plus the provider's OPTIONS.
    # The test backend ships with Anymail, accepts every Anymail extension and sends nothing.
    "transactional": {"BACKEND": "anymail.backends.test.EmailBackend"},
}

Ordinary Django code keeps working: send_mail(…​, using="transactional") goes through the provider. Anymail adds attributes to the message objects, for example tags, metadata and click tracking, and reports the outcome in anymail_status:

Anymail extensions on a message (shell)
from anymail.message import AnymailMessage
from django.core import mail

msg = AnymailMessage(subject="Order shipped", body="On its way", to=["ana@example.com"])
msg.tags = ["shipping"]
msg.metadata = {"order_id": 42}
msg.track_clicks = True
print(msg.send(using="transactional"))
sent = mail.outbox[0]
print(sent.tags, sent.metadata, sent.anymail_status.status)
1
['shipping'] {'order_id': 42} {'sent'}

Anymail’s own backend used a single EMAIL_BACKEND string plus an ANYMAIL settings dictionary until Django 6.1; its documentation still shows that form for "Django 6.0 or earlier". If you cannot use MAILERS yet, that is the supported way. The webhook features need extra care: Anymail’s tracking and inbound endpoints are ordinary URLs open to the internet, so serve them over HTTPS and configure a webhook secret generated with django.utils.crypto.get_random_string(), as its installation guide instructs. That topic belongs to the provider documentation and is not repeated here.

Migrating from EMAIL_* to MAILERS

A project that already sends email keeps working on 6.1 with the old settings; the warnings are your cue to move. The migration is usually one settings edit. The how-to’s mapping is complete:

Deprecated setting MAILERS key Notes

EMAIL_BACKEND

"BACKEND"

Default when omitted: the SMTP backend.

EMAIL_HOST

"host" in OPTIONS

Required by the SMTP backend; the old default was localhost.

EMAIL_PORT

"port"

May be omitted when it is the default for the security mode (465 SSL, 587 TLS, 25 plain).

EMAIL_HOST_USER

"username"

The one name that breaks the pattern.

EMAIL_HOST_PASSWORD

"password"

Read it from the environment.

EMAIL_USE_TLS, EMAIL_USE_SSL

"use_tls", "use_ssl"

Booleans; set at most one.

EMAIL_SSL_CERTFILE, EMAIL_SSL_KEYFILE

"ssl_certfile", "ssl_keyfile"

Client certificates only.

EMAIL_TIMEOUT

"timeout"

Seconds.

EMAIL_FILE_PATH

"file_path"

File backend only.

Before: a Django 6.0 style project (settings.py)
import os

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "mail.example.net"
EMAIL_USE_TLS = True
EMAIL_PORT = 587
EMAIL_HOST_USER = "user@example.net"
EMAIL_HOST_PASSWORD = os.environ["EMAIL_PASSWORD"]
After
import os

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": "mail.example.net",
            "use_tls": True,
            # port is not needed: it defaults to 587 with use_tls True.
            "username": "user@example.net",
            "password": os.environ["EMAIL_PASSWORD"],
        },
    },
}

The migration has four more parts, needed only by the code that uses the features concerned:

  • get_connection() and connection=. mail.get_connection() with no arguments becomes mail.mailers.default, and send_mail(…​, connection=mail.get_connection()) simply drops the argument because the default mailer is already the default. A connection built with a custom backend path and options becomes its own MAILERS alias that you refer to with using or mail.mailers["alias"].

  • fail_silently. The right replacement depends on what the caller meant: wrap the call in try/except mail.MailerDoesNotExist to send only when email is configured; except Exception to ignore everything (an error handler that must not fail); except OSError to ignore only network trouble. To ignore typos in recipient addresses, delete the argument, because recipient errors are generally not detectable at send time. To keep fail-silent behaviour for many calls, add "fail_silently": True to the OPTIONS of a dedicated alias and use using. fail_silently=False is the default and can be removed.

  • auth_user and auth_password. Define another alias with its own username and password options and use using.

  • Custom backends. See Writing a backend.

Replacing fail_silently with an explicit exception handler (catalog/tests.py)
from django.core import mail
from django.core.mail import send_mail
from django.test import TestCase, override_settings


class FailSilentlyReplacementTests(TestCase):
    def test_fail_silently_replacement(self):
        def notify():
            try:
                send_mail("A", "a", "f@example.com", ["t@example.com"])
            except mail.MailerDoesNotExist:
                return 0
            return 1

        with override_settings(MAILERS={}):
            self.assertEqual(notify(), 0)
        self.assertEqual(notify(), 1)

Two behaviours observed on 6.1.1 are worth knowing before you rely on warnings to find the work. With the old settings only, the EMAIL_BACKEND setting warns (The EMAIL_BACKEND setting is deprecated. Migrate to MAILERS before Django 7.0.), and with -W error::PendingDeprecationWarning that warning becomes an exception, a quick way to find every code path that sends email in a test run. In the scratch runs, though, fail_silently=True, connection= and get_connection() did not emit a warning, even though the documentation lists them as deprecated; search the code for those names instead of waiting for a warning. And after you define MAILERS, reading an old setting or calling get_connection() with a backend path fails immediately:

Errors raised once MAILERS is defined (shell)
AttributeError: The EMAIL_HOST setting is not available when MAILERS is defined.
RuntimeError: get_connection(backend, ...) is not supported with MAILERS.

These errors mean some third-party package still reads the old settings. Look for a newer release of it; if there is none, remove MAILERS and go back to the EMAIL_* settings until the package is updated. Packages that merely call send_mail() keep working. The old settings and functions (EMAIL_*, get_connection() and connection=, fail_silently= and the two auth_* arguments) will be removed in the release the documentation calls Django 2028, formerly planned as 7.0.

Error-reporting mail

Two Django features send mail on their own: the logging AdminEmailHandler (mail to ADMINS for server errors) and BrokenLinkEmailsMiddleware (mail to MANAGERS about 404s from your own pages). Their old email_backend argument is deprecated in favour of a using option. Once MAILERS is defined, they stop ignoring transient send failures silently; if you rely on that, define an alias with "fail_silently": True and point both at it.

config/logging_settings.py
from .settings import *  # noqa: F403

MAILERS = {
    "default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"},
    "admin-logging": {
        "BACKEND": "django.core.mail.backends.locmem.EmailBackend",  # production: SMTP with "fail_silently": True
    },
}
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "mail_admins": {
            "level": "ERROR",
            "class": "django.utils.log.AdminEmailHandler",
            "using": "admin-logging",
        },
    },
    "loggers": {"django": {"handlers": ["mail_admins"], "level": "ERROR"}},
}
Logging an error and reading the message it sent (shell)
import logging
from django.core import mail

logging.getLogger("django").error("Payment gateway unreachable")
m = mail.outbox[0]
print(m.sent_using, "|", m.subject, "|", m.to, "|", m.from_email)
admin-logging | [Django] ERROR: Payment gateway unreachable | ['admin@bookshelf.example'] | errors@bookshelf.example

For BrokenLinkEmailsMiddleware, subclass it, set using = "admin-logging", and put the dotted path of your subclass in MIDDLEWARE (see Middleware):

catalog/middleware.py (excerpt)
from django.middleware.common import BrokenLinkEmailsMiddleware


class CustomBrokenLinkEmailsMiddleware(BrokenLinkEmailsMiddleware):
    using = "admin-logging"

The mail.W001 warning for a missing default alias is easy to meet while migrating. This is what a settings module with only a "alerts" mailer produced:

?: (mail.W001) Your MAILERS setting has no 'default' entry. Sending email without a valid mailer will fail.
	HINT: Add a 'default' entry to MAILERS, or pass 'using' when sending email.

Testing with mail.outbox

Django’s test runner swaps the BACKEND of every MAILERS alias for locmem, so tests never send real mail and can inspect what would have been sent. django.core.mail.outbox is the list of EmailMessage objects. It exists only while the locmem backend is in use, so access it as mail.outbox after from django.core import mail; importing the name directly does not work. Django’s TestCase empties it at the start of every test, and mail.outbox = [] empties it by hand.

The documentation’s test, with the 6.1 sent_using check (catalog/tests.py)
from django.core import mail
from django.core.mail import send_mail
from django.test import TestCase


class EmailTest(TestCase):
    def test_send_email(self):
        sent = send_mail(
            "Subject here",
            "Here is the message.",
            "from@example.com",
            ["to@example.com"],
        )
        self.assertEqual(sent, 1)
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(mail.outbox[0].subject, "Subject here")
        self.assertEqual(mail.outbox[0].sent_using, "default")  # 6.1: which mailer sent it

Two limits to remember. The outbox holds EmailMessage objects, not bytes, so assert on fields (to, subject, body, alternatives, attachments, extra_headers) or call .message() to inspect the real serialised form, as the tests above do. And because the locmem backend replaces the real one, a test passes even if your production OPTIONS are wrong: cover that with manage.py check --deploy (mail.E001) and a sendtestemail --using smoke test against a staging mailer. Use override_settings(MAILERS=…​) for tests that need several aliases, as the examples above do; the aliases of the project’s real MAILERS are replaced automatically. The test client and the other test tools are covered on a later page of this section.

What changed recently

Version Change

4.0

attach_alternative() and attach() raise ValueError instead of AssertionError for invalid content or MIME type.

4.2

The SMTP backend verifies the server hostname and certificate; the old looser behaviour needs a subclass overriding ssl_context.

5.2

attachments and alternatives are lists of named tuples (EmailAttachment, EmailAlternative); new EmailMultiAlternatives.body_contains().

6.0

Messages are built on Python’s modern email API: EmailMessage.message() returns email.message.EmailMessage and accepts a policy; attach() takes a MIMEPart; BadHeaderError becomes ValueError; legacy MIMEBase attachments, SafeMIMEText/SafeMIMEMultipart and BadHeaderError are deprecated; optional parameters of the sending functions and all but the first four of EmailMessage must be keyword arguments; ADMINS/MANAGERS as (name, address) tuples deprecated. Undocumented mixed_subtype/alternative_subtype removed.

6.1

MAILERS setting, using argument, mail.mailers, mailers.default, sent_using in the outbox, sendtestemail --using, system checks mail.W001 and mail.E001, startproject writes a console mailer. Deprecated: all EMAIL_* settings, get_connection(), connection=, fail_silently=, auth_user/auth_password, AdminEmailHandler email_backend, constructing a backend class directly. BaseEmailBackend rejects unknown keyword arguments.

Django 5.2 LTS (supported until April 2028) has none of the 6.x email changes: EmailMessage.message() there returns the legacy SafeMIMEText/SafeMIMEMultipart objects and configuration is the EMAIL_* settings. A library that wants to run on 5.2 and 6.1 must support both. Moving a 5.2 LTS project to 6.2 LTS (April 2027) is the natural time to adopt MAILERS: 6.2 is the last release before the calendar-versioned ones in which, according to the documentation, the old settings are removed.

Where the books are outdated

The four reference books were written for Django 1.11–2.1, before any of this. Their email chapters, the Melé blog’s "share by email" form and order confirmations, the Marani contact form tested with mail.outbox, and the Vincent password-reset flow with a console backend and an SMTP provider, all configure email with EMAIL_BACKEND, EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD and EMAIL_USE_TLS in settings.py. Those settings still work on 6.1 but are deprecated; use MAILERS as above and read credentials from the environment instead of committing them. The mail.outbox testing technique is unchanged and still the right one; only sent_using is new. Where an example catches BadHeaderError, catch ValueError on 6.0 and later. Where it builds "Name <address>" by string formatting, use email.headerregistry.Address. Where it passes fail_silently=True, apply the replacement that matches the intent (Migrating from EMAIL_* to MAILERS). Where it hand-builds MIME objects from email.mime, use EmailMessage.attach() with a MIMEPart. None of the books cover MAILERS, using, mail.mailers or the modern email API, since all arrived after them.

Pitfalls

  • Sending inside the request before the transaction commits, so a rolled-back change still produces an email.

  • Calling send_mail() in a loop over thousands of users; use send_messages() on one connection, or a background task.

  • recipient_list with many users when each should get a private message: they all see each other. Use one message per user.

  • Building From: or To: strings with an f-string and a user’s name.

  • Leaving fail_silently=True (or the SMTP fail_silently option) in place: it hides misconfiguration.

  • No SMTP timeout: a stalled provider ties up workers indefinitely.

  • HTML-only messages; keep a plain-text body as well.

  • Putting SMTP or API credentials in settings.py instead of the environment.

  • Forgetting that tests use locmem: a green test does not prove production delivery works.

  • Importing outbox by name instead of using mail.outbox.

  • Shipping the console backend as the production default; check --deploy reports mail.E001.

  • Defining MAILERS while a dependency still reads settings.EMAIL_*: it fails with the AttributeError shown above.