SPF, DKIM, DMARC: Getting Mail Delivered
Last week ended with Stalwart running on two nodes. At that point I had a mail server in the same sense that a teenager with a learner’s permit has a car: technically true, not yet trusted by anyone. The big providers treat unknown mail servers as guilty until proven innocent, and the proof is a stack of DNS records and protocols that grew up as patches on a protocol from 1982.
The trick is realizing the acronyms aren’t a checklist, they’re one coherent story you tell the receiving server. SPF says which machines may send for the domain. DKIM puts a cryptographic signature on each message proving it wasn’t altered and really came from the domain. DMARC declares what receivers should do when either check fails, and asks them to send you reports about it. MTA-STS tells other servers to insist on TLS when delivering to you, and TLSRPT asks them to report when that fails. Any one of them alone is decoration. Together they’re an identity.
The alignment triangle
The part no tutorial warned me about is that the DNS records are the easy half. The hard half is making three things agree with each other: the IP your mail actually leaves from, the reverse DNS on that IP, and the name the server calls itself.
Both mail nodes send through Exoscale Elastic IPs, and outbound traffic has to actually leave through that IP for SPF to pass, which meant getting the SNAT rules right. The PTR record on each Elastic IP has to resolve to the mail hostname, and the hostname Stalwart announces in its SMTP banner has to match that PTR. I got each of these subtly wrong at least once. The failure mode is never an error; it’s your mail arriving in spam folders while every individual component looks correctly configured.
For DKIM I sign with two keys: Ed25519 for verifiers that understand modern signatures, RSA-2048 for the rest of the internet. Stalwart signs with both, receivers verify whichever they support.
The gotcha of the week
My favorite config-format bug of the whole epic: MTA-STS policies have a max-age field, and I set it to 604800, seven days in seconds, like the RFC examples for other protocols suggest. Stalwart wanted the duration string "7d". The policy served fine, validators disagreed about it, and I burned an evening on the difference between a number and a string that means a number.
Once everything aligned, mail-tester.com scored the setup 9/10 or better consistently, and mail started landing in inboxes rather than spam folders. There is no single moment of victory in deliverability; there’s just a day when you realize nobody has complained in a while.
The other direction
Inbound spam filtering is Stalwart’s built-in engine. It scores each message, writes the verdict into X-Spam-Status and a rule-by-rule breakdown into X-Spam-Result, and then, deliberately, does nothing else. Messages score above the threshold of 5.0 and still land in the inbox with headers attached.
That was a real decision. Auto-moving to Junk means a false positive silently disappears, and for family members who will never check a Junk folder, a lost legitimate email costs more trust than ten visible spam messages. Everyone gets a Sieve filter that moves flagged mail to Junk if they want it; the default is honest delivery. The score headers also make the filter debuggable: when something gets flagged, the reasons are right there in the message source.
Reports from strangers
DMARC reports deserve a mention because nobody tells you what they feel like: once the record is live, mail servers around the world start sending you small XML summaries of every message claiming to be from your domain. It’s the closest thing email has to observability. Watching them confirmed the SPF and DKIM alignment from real traffic, not just from test tools, before I tightened the DMARC policy.
Deliverability isn’t done, it’s maintained. Later in this series: the day I tightened every domain to p=reject, and much later, the DANE experiment that took my inbound mail down in a way none of these mechanisms even hint at. Next week, though: the private mesh network underneath everything.
You May Also Like
One Binary for All of Email
Deploying Stalwart across two nodes, with every piece of state pushed out into managed backends.
LUKS on Every Data Volume
Full-disk encryption for servers I will never physically see, and why the unlock step stays manual.
Secrets That Live in Git
How SOPS and two GPG keys let me commit every credential this platform needs to the repository itself.
