Filter concepts by levelShowing all levels.

Django · Email Systems

Email Systems

One API, four backends, and a delivery lifecycle that starts where send() returns

  • HTML mail is an alternative, not a bodyattach_alternative(html, "text/html") — the plain-text part stays
  • EMAIL_BACKEND swaps the transportsmtp · console · filebased · locmem (mail.outbox) · dummy — no call site changes
  • Commit, then enqueue — on_commita task queued mid-transaction outruns its own row, or survives a rollback
  • Backoff and jitter, or the backlog retries in one secondand never retry a refused address
  • "Sent" means your relay accepted ithard bounce (5xx) suppresses the address; soft (4xx) is retried

1.The email API, and the backend underneath it

Django ships one email API with two entry points. `send_mail(subject, message, from_email, recipient_list)` is the one-liner, and it returns the number of messages delivered — `1` or `0`, because it can only send one. `EmailMessage` is the object you build when you need more than that: cc, bcc, `reply_to`, extra headers, attachments. `EmailMultiAlternatives` adds `attach_alternative(html, "text/html")`, which is how you send an HTML email that still has a plain-text body. Underneath both sits a **backend**, chosen by the `EMAIL_BACKEND` setting, and swapping it is how the same code writes to your console in development and to an SMTP server in production.

Key features

  • `send_mail()` returns the number of messages delivered — `0` or `1`, since it sends only one
  • `EmailMessage(subject, body, from_email, to, bcc=, cc=, reply_to=, headers=, attachments=, connection=)`
  • HTML mail is `EmailMultiAlternatives` plus `attach_alternative(html, "text/html")` — the body stays plain text
  • `attach(filename, content, mimetype=None)` guesses the type from the filename when `mimetype` is omitted
  • `EMAIL_BACKEND` swaps SMTP for `console`, `filebased`, `locmem` (tests) or `dummy` without touching call sites
  • `fail_silently=True` swallows the exception — including the one that means nothing was sent

The backends, and what each one is actually for

The backends, and what each one is actually for
`EMAIL_BACKEND`Where mail goesUse it for
`...backends.smtp.EmailBackend`a real SMTP serverproduction — this is the default
`...backends.console.EmailBackend`stdoutlocal development; you read the mail in the runserver log
`...backends.filebased.EmailBackend`one file per message in `EMAIL_FILE_PATH`local development when you want to open the HTML in a browser
`...backends.locmem.EmailBackend``django.core.mail.outbox`, a listtests — assert on `len(mail.outbox)` and on the message
`...backends.dummy.EmailBackend`nowherea load test, or an environment that must never send

Which class to reach for

Which class to reach for
You needUseWhy
one plain message, nothing special`send_mail(...)`returns 1 or 0; no object to build
cc, bcc, `reply_to`, headers`EmailMessage(...)``send_mail` exposes none of these
an HTML version as well`EmailMultiAlternatives``attach_alternative(html, "text/html")`
a file on the message`.attach()` / `.attach_file()`content in memory vs a path on disk
many messages at once`connection.send_messages([...])`one handshake, not one per message
Three substitutable layers — and the one setting that swaps the bottom one

Your code

builds a message: subject, text body, HTML alternative, attachments

EmailMessage / EmailMultiAlternatives

knows nothing about how it will travel — that is the point

Connection

get_connection() — open it yourself and one handshake serves the whole batch

Backend — EMAIL_BACKEND

smtp in production · console/filebased in dev · locmem in tests · dummy for never

An SMTP server, stdout, a file, or a list

the only layer that differs between your laptop and production

  1. Your code — builds a message: subject, text body, HTML alternative, attachments
  2. EmailMessage / EmailMultiAlternatives — knows nothing about how it will travel — that is the point
  3. Connection — get_connection() — open it yourself and one handshake serves the whole batch
  4. Backend — EMAIL_BACKEND — smtp in production · console/filebased in dev · locmem in tests · dummy for never
  5. An SMTP server, stdout, a file, or a list — the only layer that differs between your laptop and production

Command

python
send_mail(subject, message, from_email, recipient_list, fail_silently=False, html_message=None)

`send_mail` returns a count (1 or 0), not a boolean; `EmailMessage` is the object form; `EmailMultiAlternatives.attach_alternative(html, "text/html")` is all "HTML email" means — the plain-text body stays. `EMAIL_BACKEND` swaps SMTP for console, filebased, locmem or dummy without touching a single call site, so tests assert on `mail.outbox`. Open a connection with `get_connection()` before a batch, or you pay one SMTP handshake per message. Treat `fail_silently=True` as data loss you agreed to.

2.Delivery belongs in the background

Sending mail inside a view puts a third party on your request path. An SMTP handshake to a relay that is having a slow day turns a 200 ms signup into a 30 s one, and if the relay is down the signup fails even though the account was created. The fix the roadmap states outright — **do not block critical requests on slow email delivery** — is to commit the database change, enqueue a task, and return. The worker then sends the message and can retry on failure, which a request cannot.

Key features

  • Enqueue inside `transaction.on_commit(...)` — a task that runs before the commit sees no row
  • Pass ids, never model instances: the worker re-reads current state instead of a stale copy
  • Retry with exponential backoff plus jitter, and a cap — an uncapped retry is an amplifier
  • Retry timeouts and 5xx; do not retry an invalid address or a hard bounce
  • A provider HTTP API returns a message id; SMTP does not, and that id is what delivery status hangs off
  • Make the task idempotent — at-least-once delivery means it can run twice

What to do with each failure the send can produce

What to do with each failure the send can produce
FailureRetry?What to do instead
connection timeout, relay refusedyesbackoff + jitter; it is almost always transient
provider 5xxyessame — capped, then dead-letter
provider 429 (rate limited)yeshonour the retry-after the provider gives you
invalid address / 5xx hard bouncenomark the address undeliverable; retrying cannot help
template raised while renderingnoa code bug — fail loudly, do not retry it 25 times
exhausted every attempt—dead-letter queue, and an alert; do not drop it silently
The same signup, with the relay 30 seconds slow

Sending inside the view

  • The user waits for your relay, not for your database
  • A relay outage fails the signup — the account is rolled back
  • No retry is possible: the request is already over
  • Worker threads are held open by a network you do not own
  • A gunicorn timeout kills it mid-send; did it go out or not?

Committing, then enqueueing

  • The user waits for one INSERT
  • A relay outage delays the email; the signup still succeeds
  • The worker retries with backoff, and gives up into a DLQ
  • Request threads return immediately
  • The task is idempotent, so a duplicate run is harmless
  • Sending inside the view
    • The user waits for your relay, not for your database
    • A relay outage fails the signup — the account is rolled back
    • No retry is possible: the request is already over
    • Worker threads are held open by a network you do not own
    • A gunicorn timeout kills it mid-send; did it go out or not?
  • Committing, then enqueueing
    • The user waits for one INSERT
    • A relay outage delays the email; the signup still succeeds
    • The worker retries with backoff, and gives up into a DLQ
    • Request threads return immediately
    • The task is idempotent, so a duplicate run is harmless

Command

python
transaction.on_commit(lambda: send_welcome_email.delay(user.id))

Commit first, enqueue second, and do it with `transaction.on_commit` — a task queued mid-transaction can outrun its own row or survive a rollback. Pass ids, not instances. Retry only transient failures, with backoff *and* jitter so a recovering relay does not meet the whole backlog at once, and never retry a refused address. Make the task idempotent, because at-least-once delivery means it will sometimes run twice. The rule the section states plainly: do not block a critical request on somebody else's mail server.

3.Bounces, and what "sent" actually means

A successful `send()` means your relay accepted the message, not that anyone received it. Everything after that — the destination server accepting or rejecting it, the mailbox being full, a spam filter discarding it — happens minutes later and reaches you only if you ask for it. A **bounce** is the rejection notice. A **hard bounce** is permanent (the address does not exist) and the address must never be mailed again; a **soft bounce** is temporary (mailbox full, server busy) and is worth retrying. Providers report both, along with delivered/opened/complained, by calling a webhook you expose.

Key features

  • A returned count of `1` means the relay accepted it, nothing more about delivery
  • Hard bounce = SMTP 5xx, permanent — suppress the address; soft = 4xx, transient — retry
  • Store one row per delivery keyed by the provider message id, or events have nothing to attach to
  • Webhooks are at-least-once and can arrive out of order — key on the id and ignore stale states
  • Suppress complained addresses from marketing, but keep sending mail the person requested
  • Open tracking is a pixel; privacy proxies pre-fetch it, so opens overstate real reads

The delivery lifecycle after `send()` returns

The delivery lifecycle after `send()` returns
EventWhat it meansWhat your system should do
`accepted`your relay took custodythe only thing `send()` proves
`delivered`the receiving server accepted itthe honest definition of success
`bounced` (hard, 5xx)the address is permanently unusablesuppress it; stop all future sends
`deferred` (soft, 4xx)temporarily undeliverablethe provider retries; you usually wait
`complained`marked as spam by the recipientsuppress marketing; keep requested mail
`opened` / `clicked`a tracking pixel or link was fetchedweak signal — proxies inflate it
One message, and the states it moves through after your code stops looking
relay takescustodyreceivingserver accepts4xx — mailboxfull, server busyprovider retrysucceedsretriesexhausted5xx — nosuch addressrecipientmarks as spamnever mail thisaddress againmarketingonly

queued

start

accepted by your relay (send() returned 1)

delivered

end

deferred — soft bounce (4xx)

bounced — hard bounce (5xx)

end

complained (spam)

end

address suppressed

end

  • queued (start)
    • → accepted by your relay (send() returned 1) when relay takes custody
  • accepted by your relay (send() returned 1)
    • → delivered when receiving server accepts
    • → deferred — soft bounce (4xx) when 4xx — mailbox full, server busy
    • → bounced — hard bounce (5xx) when 5xx — no such address
  • delivered (end)
    • → complained (spam) when recipient marks as spam
  • deferred — soft bounce (4xx)
    • → delivered when provider retry succeeds
    • → bounced — hard bounce (5xx) when retries exhausted
  • bounced — hard bounce (5xx) (end)
    • → address suppressed when never mail this address again
  • complained (spam) (end)
    • → address suppressed when marketing only
  • address suppressed (end)

Command

python
EmailDelivery.objects.filter(provider_message_id=mid).update(status="bounced")

`send()` returning 1 means your relay accepted the message — it is not delivery. Store a row per delivery keyed by the provider message id, or later events have nothing to attach to. Hard bounce (SMTP 5xx) is permanent: suppress the address, because repeatedly mailing dead addresses is what damages the deliverability of the mail you care about. Soft bounce (4xx) is transient. Webhooks are at-least-once and unordered, so rank the states and refuse to move backwards. Treat opens as a weak signal — privacy proxies fetch the pixel for the recipient.

Remember

  • `send_mail()` returns a count — 1 or 0 — not a boolean. Nothing checks it unless you do.
  • Open a connection with `get_connection()` before a batch, or you pay one SMTP handshake per message.
  • `fail_silently=True` discards the exception that tells you your mail is not going out.
  • A webhook is at-least-once and unordered: rank the states so a late event cannot move a delivery backwards.

Pro tip

  • Use the `filebased` backend locally — you get one file per message and can open the HTML in a browser.
  • Suppression only works if the send path reads it. A suppression table nothing consults is a log with extra steps.

Next up

  • Internationalization and Time Zones
  • Management Commands
  • File Exports and Imports

Sources

  • Sending email — docs.djangoproject.com/en/5.2/topics/email/
  • SMTP reply codes (hard vs soft bounce) — RFC 5321 § 4.2.1
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.