|
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 |
|
Send one plain message |
|
|
Send HTML plus plain text, Cc/Bcc, headers, attachments |
|
|
Send through a second provider or account |
Another |
|
Send many messages over one connection |
|
|
Build an address from a user’s name |
|
|
Develop without sending anything |
Console backend, file backend, or a local |
|
Use SendGrid, Mailgun, Amazon SES, … |
django-anymail backends, configured as a mailer. |
|
Move a 6.0 project to |
Map each |
|
Assert on what was "sent" |
|
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 defview, remember there is no asynchronous email API: in Django 6.1django.core.mailhas noasend_mail()or any othera-prefixed sender (adir()of the module shows none). Wrap the call withasgiref.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.
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.
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:
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:
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.
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.
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.
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 …:
<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 |
|---|---|
|
The first four may be positional. |
|
Lists or tuples, keyword-only. |
|
A list of |
|
A dictionary of extra headers (stored as |
|
A backend instance; deprecated in 6.1 in favour of |
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.
Hello {{ name }},
Your review of "{{ book.title }}" by {{ book.author }} has been approved and is now visible.
The Bookshelf team
<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>
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:
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. Ifmimetypeis omitted it is guessed from the file name. Fortext/*typescontentshould be a string; binary data given to a text type is decoded as UTF-8 and, if that fails, the type becomesapplication/octet-stream. -
msg.attach_file(path, mimetype=None)reads a file from disk. -
The
attachments=[…]constructor argument takes the same tuples,EmailAttachmenttuples orMIMEPartobjects. -
msg.attach(mimepart)takes a Pythonemail.message.MIMEPart(6.0). Use it when the attachment needs extra headers or parameters, for example an inline image with aContent-ID. Passing a legacyemail.mime.base.MIMEBaseobject still works but is deprecated since 6.0.
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")
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:
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 asBob" <attacker@evil.example>, "xsmuggles in a second recipient. Useemail.headerregistry.Address, which Django’s built-in backends accept directly into,cc,bccandreply_to. If a third-party backend rejects it, passstr(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 raiseddjango.core.mail.BadHeaderError; that class is deprecated and Python’s modern policy raisesValueErrorinstead. A custom backend that does not callEmailMessage.message()(or calls it with the legacycompat32policy) must do its own CRLF protection.
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.
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)
from django.urls import path
from . import views
app_name = "catalog"
urlpatterns = [
path("reviews/<int:pk>/approve/", views.approve_review, name="approve-review"),
]
Source: Django: URL dispatcher
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:
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:
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.
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:
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:
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:
-
usingcannot be combined with the deprecatedfail_silently,auth_user,auth_passwordorconnectionarguments; doing so raisesTypeError. Earlier releases silently ignored some of them when aconnectionwas also given. -
An unknown alias raises
MailerDoesNotExist; any other configuration problem (for example an unknownOPTIONSkey) raisesInvalidMailer. Both are importable fromdjango.core.mail. -
If there is no
"default"alias, the system checkmail.W001(new in 6.1) warns at start-up: the messageYour MAILERS setting has no 'default' entrycomes with the hint to add one or passusingexplicitly. -
Features that send mail for you take an alias too: the logging
AdminEmailHandlerhas ausingoption (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:
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:
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.
Source: Django 6.1 release notes - Email
| Backend path | OPTIONS |
Use |
|---|---|---|
|
|
Production: your own relay or most commercial providers over SMTP. |
|
|
Development: print each message. |
|
|
Development: one file per backend session in that directory. |
|
none |
Tests and development: append messages to |
|
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.
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:
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.
python -m pip install "aiosmtpd >= 1.4.5"
python -u -m aiosmtpd -n -l localhost:46025
from .settings import * # noqa: F403
MAILERS = {
"default": {
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"OPTIONS": {"host": "localhost", "port": 46025, "timeout": 10},
},
}
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.
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)
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:
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"},
}
Source: Anymail: 1-2-3 quickstart
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:
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)
Source: Anymail: Anymail additions
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 |
|---|---|---|
|
|
Default when omitted: the SMTP backend. |
|
|
Required by the SMTP backend; the old default was |
|
|
May be omitted when it is the default for the security mode (465 SSL, 587 TLS, 25 plain). |
|
|
The one name that breaks the pattern. |
|
|
Read it from the environment. |
|
|
Booleans; set at most one. |
|
|
Client certificates only. |
|
|
Seconds. |
|
|
File backend only. |
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"]
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()andconnection=.mail.get_connection()with no arguments becomesmail.mailers.default, andsend_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 ownMAILERSalias that you refer to withusingormail.mailers["alias"]. -
fail_silently. The right replacement depends on what the caller meant: wrap the call intry/except mail.MailerDoesNotExistto send only when email is configured;except Exceptionto ignore everything (an error handler that must not fail);except OSErrorto 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": Trueto theOPTIONSof a dedicated alias and useusing.fail_silently=Falseis the default and can be removed. -
auth_userandauth_password. Define another alias with its ownusernameandpasswordoptions and useusing. -
Custom backends. See Writing a backend.
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:
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.
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"}},
}
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):
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.
Source: Django: System check framework
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.
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 |
|
4.2 |
The SMTP backend verifies the server hostname and certificate; the old looser behaviour needs a subclass overriding |
5.2 |
|
6.0 |
Messages are built on Python’s modern email API: |
6.1 |
|
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; usesend_messages()on one connection, or a background task. -
recipient_listwith many users when each should get a private message: they all see each other. Use one message per user. -
Building
From:orTo:strings with an f-string and a user’s name. -
Leaving
fail_silently=True(or the SMTPfail_silentlyoption) 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.pyinstead of the environment. -
Forgetting that tests use
locmem: a green test does not prove production delivery works. -
Importing
outboxby name instead of usingmail.outbox. -
Shipping the console backend as the production
default;check --deployreportsmail.E001. -
Defining
MAILERSwhile a dependency still readssettings.EMAIL_*: it fails with theAttributeErrorshown above.