Sending mail is easy; arriving is not. Authentication records explained properly, the alignment rule most guides omit, why fresh servers cannot send, asynchronous delivery, bounce handling, and the headers that keep you out of the spam folder.
Your application calls send_mail(), the function returns 1, the log says the message
was accepted, and the customer never receives it. Nothing failed. Somewhere between your server and
their inbox, a receiving system decided your message was not worth delivering, and told nobody.
Deliverability is the discipline of making that decision go your way. It is mostly not about your code — it is about proving that you are entitled to send as your domain, and behaving like a sender worth trusting. This guide covers both, plus the parts of a Django implementation that people routinely get wrong.
Sending is solved: configure a backend, call a function. Arriving depends on receiving systems that score every message on the reputation of the sending address, the reputation of the domain, whether authentication passes, whether recipients engage, and how many of your previous messages bounced or were marked as spam.
You control most of those inputs, but only indirectly and only over time. Reputation is accumulated, not configured — which is why the fix for poor deliverability is never a single setting.
Three DNS records underpin modern email authentication, and they answer different questions.
SPF lists the servers permitted to send for your domain. It answers: did this message come from an authorised machine? DKIM attaches a cryptographic signature to each message. It answers: was this message altered in transit, and does it genuinely come from a holder of the domain's key? DMARC ties both to the visible sender and tells receivers what to do on failure.
All three are required in practice. Major providers now reject or quarantine unauthenticated bulk mail outright, and the trend is one-directional.
An SPF record is a single TXT record at your domain listing authorised senders.
example.com. IN TXT "v=spf1 include:_spf.mailprovider.example ~all"
Two traps. First, you may publish only one SPF record — adding a second for a new provider breaks authentication entirely rather than combining them. Merge the includes into one record instead.
Second, evaluation is limited to ten DNS lookups, and each include may itself contain
includes. Chain three providers together and you can silently exceed the limit, at which point SPF
returns a permanent error and every message fails authentication. Check the count rather than
assuming.
End with ~all (soft fail) while you are still verifying, and move to -all
once you are confident the list is complete.
DKIM signs each outgoing message with a private key; the matching public key is published in DNS under a selector, which is simply a label allowing multiple keys to coexist.
selector1._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCS..."
Selectors make rotation possible: publish a new key under a new selector, switch signing to it, and remove the old one once no mail in flight still carries it. Rotate at least annually. If a signing key leaks, anyone can send perfectly authenticated mail as your domain.
This is where the majority of "everything passes but DMARC fails" confusion comes from. DMARC does not merely require SPF or DKIM to pass. It requires the passing domain to align with the domain your recipients actually see in the From header.
A provider that sends on your behalf may pass SPF for its own domain, not yours. SPF passes; DMARC fails, because the authenticated domain and the visible domain differ. The fix is either to sign with DKIM using your own domain — which is why providers ask you to publish those selector records — or to configure a custom return path within your domain.
When troubleshooting, always ask which domain passed, not merely whether something passed.
DMARC publishes your policy and requests reports. Start in monitoring mode, which changes nothing about delivery but gives you data.
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com; fo=1"
Run that for a few weeks and read the reports. They will reveal senders you had forgotten: a support desk, an invoicing tool, a newsletter platform, a monitoring system. Every one of them needs authenticating before you tighten the policy.
Then move to p=quarantine, watch, and finally p=reject. Jumping straight
to reject is how teams discover their own invoicing system was never authenticated — by having its
mail rejected for a fortnight.
Reports arrive as compressed XML, sent daily by receiving providers. Raw, they are unpleasant. The structure is simple though: for each sending address, how many messages, and whether SPF and DKIM passed and aligned.
What you are looking for is any volume from an address you do not recognise. That is either a forgotten legitimate sender or someone spoofing your domain — and both are worth knowing. A parsing service makes this readable; for a small domain, a weekly skim is enough.
A newly provisioned server is a poor mail sender for reasons entirely outside your control. Its address has no sending history, and it sits in a block belonging to a hosting provider — a range receivers already associate with far more spam than legitimate correspondence.
Many providers also block outbound port 25 by default, so your first attempt fails with a timeout that looks like a network fault. And building a good reputation from scratch takes weeks of consistent, low-volume, well-received sending.
For almost every Django application the correct answer is to relay through a dedicated mail provider that maintains sender reputation as its core business. Run your own mail server only if that is the product you are building.
The default configuration works and will hang your application at the worst moment.
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_TIMEOUT = 10
DEFAULT_FROM_EMAIL = "Example App <noreply@example.com>"
SERVER_EMAIL = "alerts@example.com"
EMAIL_TIMEOUT is the setting that matters most and is almost always omitted. Without
it, an unresponsive mail server blocks the worker until the operating system gives up — potentially
minutes — with your user staring at a spinner. Ten seconds is generous.
Sending mail synchronously couples your response time to a third party's availability. A slow mail server becomes a slow signup page; an unavailable one becomes a failed registration even though the account was created.
@shared_task(bind=True, max_retries=3)
def send_welcome_email(self, user_id):
user = User.objects.get(pk=user_id)
try:
send_mail("Welcome", render_to_string("email/welcome.txt", {"user": user}),
settings.DEFAULT_FROM_EMAIL, [user.email], fail_silently=False)
except SMTPException as exc:
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
Queue the message, return the response, and let a worker deliver with retries and exponential
backoff. Note fail_silently=False: the default swallows errors, which is precisely the
behaviour that lets mail failures go unnoticed for weeks.
This is the piece almost nobody builds. When a message bounces — the address does not exist, the mailbox is full — that signal matters. Continuing to send to addresses that hard-bounce is one of the strongest indicators of a sender who does not maintain their list, and receivers respond by treating everything you send with suspicion.
Providers expose bounces and complaints through webhooks. Consume them, mark the address, and stop sending. A hard bounce should suppress the address permanently; a complaint should unsubscribe them immediately and without argument.
For anything resembling bulk mail, confirm the address before you use it. Single opt-in means a bot or a typo puts a hostile or invalid address on your list, and the resulting bounces and complaints are attributed to you.
Double opt-in — send a confirmation link, only activate on click — removes that entire category. Be aware that some corporate security systems automatically follow links in incoming mail, which can confirm a subscription no human ever intended, so treat sudden clusters of confirmations from corporate domains with scepticism.
Bulk messages should carry an unsubscribe header, letting mail clients offer a one-click unsubscribe outside your message body. Providers now effectively require it, and honouring it promptly is not optional.
List-Unsubscribe: <https://example.com/unsubscribe/TOKEN>, <mailto:unsub@example.com>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Counter-intuitively, making unsubscribing easy improves deliverability. The alternative is that a frustrated recipient marks you as spam, which harms you far more than losing one subscriber.
Send a plain-text alternative alongside HTML. Keep the ratio of links to text reasonable. Avoid link shorteners, which are heavily associated with abuse. Do not send images with almost no text — a common phishing pattern. And make sure the visible sender name and address are consistent across messages, because sudden changes look like account compromise.
Password resets and receipts must arrive. A newsletter that lands in the promotions tab is a nuisance. Mixing them means a poorly received campaign can damage the reputation of your password reset messages.
Send them through separate streams or separate subdomains — for instance
mail.example.com for transactional and news.example.com for bulk — each
with its own authentication records and its own reputation.
After any change to mail configuration, send a real message and inspect the headers at the receiving end. Check that SPF, DKIM and DMARC all pass, and — crucially — that the domain which passed is your own.
Authentication-Results: mx.receiver.example;
spf=pass smtp.mailfrom=example.com;
dkim=pass header.d=example.com;
dmarc=pass header.from=example.com
Three passes with the same domain in all three places is the target. Anything else means alignment is not what you think.
Your logs prove the message was accepted by the next hop. They say nothing about arrival. Watch the metrics that reflect reality: bounce rate, complaint rate, and — as an approximation of engagement — how many recipients open or click.
A bounce rate creeping past a few percent, or complaints above a fraction of a percent, is an early warning. Acting then is straightforward; acting after a provider starts rejecting you is a project.
Publish all three authentication records, and verify alignment rather than mere passes — that is where most failures hide. Introduce DMARC in monitoring mode, read the reports to find the senders you forgot, then tighten in stages. Relay through a provider rather than sending from a fresh server. Set a timeout, send asynchronously with retries, and never let a mail failure pass silently. Consume bounces and complaints and act on them. Confirm addresses before using them, make unsubscribing trivial, and keep transactional mail away from bulk. Then verify arrival by reading the headers of a real message, because that is the only test that reflects what your customers experience.