Back to blog
By Rasul

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_KEY is 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 EmailSent row 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_EMAIL and SERVER_EMAIL with 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:

  1. Require HTTPS and verify the provider signature or a dedicated webhook secret.
  2. Reject events outside the expected account, domain, and message namespace.
  3. Insert each provider event under a unique provider/event identifier.
  4. Return quickly after durable capture; queue slow reconciliation work.
  5. Apply state changes idempotently and keep the original event timestamp.
  6. 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:

  1. Message contract: assert recipient, sender, subject, text alternative, HTML alternative, stable template version, and safe links in the locmem outbox.
  2. Transaction boundary: roll back the domain change and prove no outbox intent survives; commit it and prove one intent exists.
  3. Dedupe: repeat the same domain command and assert the unique dedupe key prevents a second intent.
  4. Dispatcher: simulate success, timeout, rate limit, invalid address, rendering failure, retry exhaustion, and worker restart.
  5. Webhook security: reject missing or invalid authentication before any state change.
  6. Webhook idempotency: deliver the same event twice and assert one durable event plus one derived transition.
  7. 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.