Django Email: A Production Delivery Pipeline
Build reliable Django email with commit-safe intent, queued delivery, bounded retries, authenticated webhooks, suppressions, monitoring, and tests.
Production Django email is a durable delivery workflow, not a call to
send_mail(). Commit the business change first, record one immutable email
intent, dispatch it through a worker, distinguish provider acceptance from
recipient-server delivery, process authenticated webhooks idempotently, and
stop sending to recipients who have bounced or complained.
Django already gives you solid message composition, pluggable backends, and a safe in-memory test backend. The missing production work sits around that API: transaction boundaries, retries, duplicate control, provider outcomes, suppression policy, and evidence that identity-critical messages are working.
This guide builds that delivery pipeline for transactional messages such as email verification, password reset, invoices, and account alerts.
On this page: define delivery evidence · audit the current boundary · configure the sender · commit email intent · dispatch and retry · process webhooks · operate suppressions · test the pipeline
Define the email evidence ladder
“Sent” is too vague for an operational state. A message moves through several systems, and each system can prove only its own step.
| State | What it proves | What it does not prove |
|---|---|---|
| Intent committed | the application decided to notify this recipient | a worker has attempted the message |
| Dispatch claimed | one worker owns the current attempt | the provider accepted the request |
| Provider accepted | the email service queued the message | the receiving mail server accepted it |
| Delivered | the receiving mail server accepted it | the message reached the inbox or was read |
| Opened or clicked | a provider observed a tracked interaction | the intended human performed it |
| Deferred, rejected, or bounced | delivery has a temporary or permanent problem | every failure should be retried |
This is the email evidence ladder. Store the evidence you actually have and
name it precisely. Do not create a sent=True field that conflates a successful
database write, a successful provider API request, an SMTP handoff, and inbox
placement.
Anymail's current tracking model makes the boundary explicit: queued means
the email service accepted the message, while delivered means the receiving
mail transfer agent accepted it. Its documentation also warns that delivered
does not guarantee the user saw the message; spam filtering can still happen
after that handoff. Mailgun uses the same distinction between an
accepted event
and a delivered event.
An append-only event history is safer than overwriting one status. A delayed bounce can arrive after an earlier delivery event, and provider webhooks can be retried. Keep the raw transition evidence, then derive the current operational summary.
Audit the current Django email boundary
Start by tracing one real message from the domain event to the provider. Record where each transition happens, which component retries it, and what the user sees when it fails.
On the current Djass main branch:
- local development sends through MailHog;
- production uses Anymail's Mailgun backend when
MAILGUN_API_KEYis present; - production falls back to the console backend when that key is absent;
- the custom allauth adapter logs confirmation-email failures, allows signup to complete, and tells the user to retry from the account page;
- an
EmailSentrow is written after the backend call returns, not after a provider delivery webhook.
That is a useful failure boundary: an unavailable provider does not erase a
newly created account. It also exposes a naming limit. The current EmailSent
record proves that the backend call returned without raising; it is not evidence
of recipient-server delivery. A production pipeline should either rename that
state to accepted or add provider events before using it in support tooling or
service-level reporting.
Use the environment-variable reference
to locate Djass's sender settings, and the
Django allauth production guide to review the
verification and recovery lifecycle around those messages. The same audit
should cover every direct send_mail() call, model signal, task, and third-party
package that can send on your application's behalf.
Configure a real Django email sender
Django's email API separates message construction from delivery. The official
django.core.mail documentation
supports SMTP and third-party email-service backends, plus console, file,
in-memory, and dummy backends for development and tests. Do not let a
development backend become a quiet production fallback unless that is an
explicit fail-closed policy with an alert.
Configure these values deliberately:
- a transactional email provider or operated SMTP relay;
- an authenticated sending domain separate from user-supplied addresses;
DEFAULT_FROM_EMAILandSERVER_EMAILwith stable, monitored mailboxes;- provider credentials from the runtime secret store;
- a finite network timeout;
- separate local and test backends that cannot contact production recipients.
If you use Mailgun, verify the sending domain and publish the exact DNS records the provider gives you. Mailgun's current domain-verification guide requires SPF and DKIM records for sending authentication. Treat DNS status as a deployment gate, not as a one-time setup screenshot.
Record region as part of the configuration too. Djass exposes
MAILGUN_API_URL because Mailgun's US and EU APIs are separate. A key, domain,
and endpoint from different regions can look syntactically valid while every
send fails.
Django 6.1 introduces the MAILERS setting and named mailer aliases, while the
older EMAIL_BACKEND settings are on a migration path toward removal in Django
7.0. Pin the documentation to your deployed Django version. Do not paste 6.1
configuration into a 5.x or 6.0 project without checking compatibility.
Commit email intent with the business change
Never send a business email before the related database transaction commits. Otherwise a rollback can leave the recipient holding a verification link, invoice notice, or membership alert for state that does not exist.
Django's
transaction.on_commit()
runs a callback after a successful commit and discards it on rollback. It is a
good place to enqueue work, but an in-memory callback is not a durable outbox:
the process can exit after the commit and before the queue accepts the job.
For important messages, write the domain change and email intent in one transaction:
from django.db import models, transaction
from django.utils import timezone
from django_q.tasks import async_task
class OutboundEmail(models.Model):
class Status(models.TextChoices):
PENDING = "pending"
CLAIMED = "claimed"
ACCEPTED = "accepted"
FAILED = "failed"
dedupe_key = models.CharField(max_length=200, unique=True)
message_type = models.CharField(max_length=80)
recipient = models.EmailField()
template_version = models.CharField(max_length=40)
context = models.JSONField(default=dict)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
)
attempt_count = models.PositiveIntegerField(default=0)
next_attempt_at = models.DateTimeField()
provider_message_id = models.CharField(max_length=255, blank=True)
def invite_member(*, workspace, email, actor):
with transaction.atomic():
invitation = workspace.invitations.create(email=email, invited_by=actor)
outbound = OutboundEmail.objects.create(
dedupe_key=f"workspace-invite:{invitation.pk}",
message_type="workspace_invite",
recipient=email,
template_version="v1",
context={"invitation_id": invitation.pk},
next_attempt_at=timezone.now(),
)
transaction.on_commit(
lambda: async_task("emails.dispatch", outbound.pk)
)
return invitation
The unique dedupe_key gives the domain event one email intent. The on-commit
enqueue reduces latency. A scheduled dispatcher that scans due pending rows
is the recovery path if the process dies before enqueueing or the queue call
fails.
Keep context small and intentional. A durable outbox is operational data, not permission to retain password-reset tokens, full rendered bodies, or arbitrary user records forever. Store the minimum inputs needed to render the selected template version, define retention, and redact sensitive fields from logs.
Generated Djass projects can include MJML templates and Django Q2. The generator-option catalog tells you what is present at build time; the Q2 background-jobs guide covers the worker boundary. You still own the outbox model, retry policy, and message-specific state transitions.
Dispatch with bounded retries
The dispatcher should claim one due row, render the message, call the backend, and persist the provider result. Keep database locks around the claim, not around template rendering or a network request.
Classify failures before retrying:
| Failure | Typical action |
|---|---|
| queue or provider timeout | retry with backoff; flag the acceptance result as unknown |
| provider rate limit or temporary 5xx | retry after the provider delay or bounded backoff |
| invalid local address format | permanent failure; do not call the provider |
| provider rejection or known suppression | permanent failure; record the reason |
| rendering or missing-template error | dead-letter and alert the owning team |
| worker crash after provider acceptance | reconcile by provider message ID; a duplicate may still be possible |
Anymail's transient-error guidance is clear that it does not add its own retry loop around provider network errors. Put retry ownership in your queue or outbox dispatcher and wait to mark the attempt accepted until the backend returns successfully.
Retries create an uncertainty window. If a worker times out after the provider accepted the request but before the response reached Django, retrying may send a duplicate. A database dedupe key prevents two application intents; it cannot make a provider call exactly once. Use a provider idempotency feature where one is documented, retain the provider message ID when available, and make transactional templates safe to receive twice. Never claim exactly-once email delivery unless every boundary in the chain supports it.
Set a maximum attempt count and a final state. Infinite retries turn a bad address or broken template into queue pressure. A dead-lettered password reset needs a visible resend path; a failed invoice notice may need an operator alert or an in-product fallback.
Process provider webhooks as untrusted events
Provider webhooks close the gap between backend acceptance and later delivery
outcomes. Anymail can normalize provider callbacks into events such as
queued, rejected, bounced, deferred, delivered, complained, and
unsubscribed. Its
tracking documentation
also exposes a provider message ID and, when available, an event ID for
duplicate control.
Treat the webhook like any public API:
- Require HTTPS and verify the provider signature or a dedicated webhook secret.
- Reject events outside the expected account, domain, and message namespace.
- Insert each provider event under a unique provider/event identifier.
- Return quickly after durable capture; queue slow reconciliation work.
- Apply state changes idempotently and keep the original event timestamp.
- Never log the entire payload by default; it may contain recipient addresses, message metadata, or content.
Anymail's webhook setup requires a separate webhook secret and explicitly warns against reusing the Django secret key or provider API key. Mailgun retries non-successful webhook posts on a bounded schedule for most event types, so duplicate-safe processing is a normal requirement, not an edge case.
Do not use open or click events as proof of human action. Image proxies, security scanners, and privacy features can create those events. Verification is proven when the user presents a valid, single-use application token; billing acceptance is proven by the billing domain; access changes are proven by your database state.
Make suppression part of domain policy
A permanent bounce, spam complaint, or unsubscribe is not another transient worker error. It changes whether the application should attempt a future message to that address.
Keep suppression decisions explicit by message class:
- stop marketing messages after unsubscribe or complaint;
- stop repeated delivery to a hard-bounced address;
- allow a user to replace and reverify an invalid identity address;
- preserve required in-product notices when email is unavailable;
- define whether a security alert may use a separately verified fallback address or channel.
Mailgun distinguishes temporary and permanent failures and maintains bounce, complaint, and unsubscribe suppressions. Your application still needs to decide what those provider facts mean for authentication, support, and legal notice workflows.
Identity flows need abuse controls alongside deliverability. The OWASP email verification guidance recommends single-use, time-limited verification tokens, anti-enumeration responses, rate limits, and monitoring. Rate-limit both the public request and the queued intent so a retry endpoint cannot create thousands of distinct messages for the same account.
Monitor the pipeline by transition
A single “emails sent” counter hides the failure location. Measure each rung of the evidence ladder:
| Signal | Operational question |
|---|---|
| oldest pending intent | is dispatch keeping up? |
| intent-to-accepted latency | are queue or provider calls slow? |
| retry and dead-letter counts by message type | which workflow is unhealthy? |
| accepted-to-delivered latency | is the provider or recipient network delaying mail? |
| permanent bounce and complaint rate | should sending pause or a campaign be reviewed? |
| webhook age and duplicate count | are callbacks delayed or replaying? |
| verification/resend completion | can users finish the job that required the email? |
Choose thresholds from each message's user impact and normal traffic. A five-minute delay may be acceptable for a weekly report and unacceptable for a login code. Alert on sustained transition failure, not on one provider callback that your retry policy already contains.
Link structured email events to the request or domain event that created the intent, but do not put full addresses, tokens, or rendered bodies into broad application logs. Use the Django logging guide for a stable redacted event contract and the Django Sentry guide for provider errors and worker failure ownership.
Test each boundary separately
Django's test runner uses the in-memory email backend and exposes messages in
django.core.mail.outbox. That is ideal for message composition tests, but it
cannot prove provider authentication, DNS, webhooks, or inbox placement.
Build a layered test suite:
- Message contract: assert recipient, sender, subject, text alternative, HTML alternative, stable template version, and safe links in the locmem outbox.
- Transaction boundary: roll back the domain change and prove no outbox intent survives; commit it and prove one intent exists.
- Dedupe: repeat the same domain command and assert the unique dedupe key prevents a second intent.
- Dispatcher: simulate success, timeout, rate limit, invalid address, rendering failure, retry exhaustion, and worker restart.
- Webhook security: reject missing or invalid authentication before any state change.
- Webhook idempotency: deliver the same event twice and assert one durable event plus one derived transition.
- Lifecycle: run one staging message through provider acceptance, recipient-server delivery, bounce, complaint, and suppression paths using controlled addresses.
Do not send real mail from ordinary CI. Use locmem for content, fakes at the
backend boundary, and a separate staging canary for provider behavior. Django
6.1 also adds sendtestemail --using <alias> for checking a configured mailer;
pin that command to the deployed version before adding it to a runbook.
Ship the operating contract
Before enabling a production Django email workflow, record:
- the domain event and dedupe key that create each message;
- the business transaction and durable outbox boundary;
- the worker owner, retryable exceptions, backoff, and final failure state;
- the provider account, region, authenticated domain, and secret locations;
- accepted, delivered, deferred, bounced, complained, and suppressed meanings;
- webhook authentication, idempotency, retention, and privacy rules;
- user-visible resend or fallback behavior;
- tests, canary, dashboards, alerts, and incident owner.
Djass can generate a maintained Django repository with email-template and background-job foundations. Review the available Django starter modules and current Djass pricing, then apply this delivery contract to the messages your product actually depends on. The provider can move bytes; your application still owns intent, correctness, evidence, and recovery.