./run quando-autenticare-non-vuol-dire-fidarsi
The spam that passed DMARC: when authenticating doesn't mean trusting
I migrated a self-hosted mail server from scratch with Postfix, Dovecot and Rspamd. The hardest lesson wasn't TLS or DKIM: it was a whitelist that gave -7 to anyone…
- status
- shipped
- project
- antilia-newserver
- updated
- 2026-07-08
- tags
Rebuilding a production mail server from scratch: Postfix, Dovecot, Rspamd, all the email authentication (SPF, DKIM, DMARC, DANE), WireGuard, DNS. Then a 10-chapter runbook so I’d never have to remember it by heart again. The catch of this journal, though, isn’t any of those pieces taken individually. It’s a config line that looked perfect and instead let spam land in the inbox — coming through the front door, complete with a valid signature.
The problem
An old server (example.com, let’s call it that) had to be decommissioned. It had to be reborn identical on a new machine (<MAILHOST>): same stack, same version of Postfix (3.7.11) and Dovecot (2.3.19.1), 9 mailboxes across 3 local domains plus 9 relay-only domains. Migration done in one night: rsync --checksum of the mailboxes (34G the biggest domain), DNS cutover, shutdown of the old one.
The interesting technical part isn’t “how to move the mail”. It’s the authentication. A self-hosted mail server is only useful if others trust it — and if it doesn’t trust just anyone who knocks. On this I’d convinced myself I’d done everything right: SPF with -all, RSA-2048 DKIM on all domains, DMARC in quarantine. And yet.
The architecture, in brief
No Amavis, no SpamAssassin: filtering is entirely Rspamd, hooked into Postfix as a milter.
# Postfix main.cf
smtpd_milters = inet:localhost:11332
milter_protocol = 6
milter_default_action = accept
Behind Rspamd, four dedicated Redis instances — not one:
| Port | Role | Persistence |
|---|---|---|
| 6381 | history, DMARC, ratelimit, greylist | RDB |
| 6382 | Bayes classifier | RDB + AOF |
| 6383 | fuzzy hash (expire 90d) | RDB |
| 6384 | IP/SPF/DKIM/domain reputation | RDB |
The separation isn’t fussiness: Bayes learns over time, and a FLUSHDB on the wrong instance wipes out months of training. Bayes is the only one with appendonly yes — it’s the only data that must survive a crash; the others can afford to lose a few minutes.
The catch: -7 to anyone
Analyzing the post-migration flow, I find obvious spam ending up in the inbox with a negative score. The cause was this rule, inherited and seemingly sensible:
# whitelist.conf — WRONG
WHITELIST_DMARC {
valid_dmarc = true;
score = -7.0; # -7 to ANYONE who passes DMARC
}
The naive reasoning: “if a mail passes DMARC it’s authenticated, so trust it”. False. A DMARC “pass” guarantees one thing only: that the mail really does come from the domain it claims. It says nothing about whether that domain is legitimate. A modern spammer registers spam-of-the-day.tld, sets up valid SPF and DKIM for their own domain, and from that moment sails through DMARC. With that rule, they’d score -7 and land straight in the mailbox.
The fix is to move from “trust whoever is authenticated” to “trust whoever is authenticated and on my list”:
# whitelist.conf — CORRECT
ISPC_WHITELIST_DMARC {
valid_dmarc = true;
domains = [ "$LOCAL_CONFDIR/local.d/maps.d/dmarc_whitelist.inc" ];
score = -7.0;
inverse_symbol = "ISPC_BLACKLIST_DMARC";
}
The domains = [...] field restricts the bonus to only the truly trusted domains — clients, partners, the user’s Workspace/M365. Without the map, no negative penalty is granted. Alternatively, if you don’t want to manage maps: tiny weights (-0.5 for valid SPF, -1.0 for SPF+DKIM) that reward authentication without ever being enough on their own to save a spam.
And while I was at it, I recalibrated the DMARC weights downward, where Rspamd’s default is too timid: DMARC_POLICY_QUARANTINE from 1.5 to 3.5. At 1.5, a spam passing its own DMARC was penalized by a whole lot of nothing. And the action thresholds: add_header from 6 to 4, rewrite_subject from 8 to 6 — two points of margin so that Bayes contributes before the pain point, not after.
Honest gotchas
“Version ≠ capability.” I wrote this one in giant letters. The two nodes (main mailer and relay) had “identical” Rspamd configs but different versions — 4.0.1 vs 3.6. Rspamd 4.0’s reputation module emits different symbol names than 3.6, and the logs silently filled up with:
unknown symbol IP_REPUTATION_HAM_SPAM
unknown symbol URL_REPUTATION
Symbol emitted at runtime but not declared in groups.conf = score lost without a single visible error. Symmetric config isn’t symmetric functionality: the only proof is the runtime symbol dump (/symbols via HTTP API), not a diff of the files. Hence an audit script in cron on both nodes.
The bug configtest doesn’t catch. In the replies module I had password 'xxx' without =. rspamadm configtest said syntax OK. The module, however, silently failed to connect to Redis. Always password = "xxx" with the equals sign and the quotes.
The wrong character nobody reports. Years of different clients leave duplicate IMAP folders: Sent + Inviati + Inviata, and my favorite, Cestino and Cestino — with a trailing space. They’re two distinct mailboxes to IMAP. You only find them with doveadm mailbox list -u $U | cat -A | grep -i cestino: if you see Cestino $, there’s the space.
How it’s going
Shipped. Migration complete, old server off, DNS cleaned of every reference to the old IP. All end-to-end tests green: STARTTLS on 25 in TLSv1.3, submission 587, IMAPS 993 with multi-domain SNI (each domain serves its own certificate), DKIM signing, Received-SPF: Pass, empty queue. Minimum TLS forced to 1.2 (in practice clients always negotiate 1.3), DANE outbound (smtp_tls_security_level = dane with smtp_dns_support_level = dnssec), certificates via dehydrated with DNS-01 challenge on Cloudflare — automatic renewal every night at 3:00, no port 80/443 to expose.
One thing the runbook declares but that is not yet closed, for honesty’s sake: the major-version alignment of Rspamd between the two nodes. The relay is stuck on 3.6 because it has a hybrid OS (bookworm userland with kernel and libbinutils from trixie) and the 4.x package won’t install in either direction without first fixing up the OS. Documented as pending, not hidden.
What I learned
- Authenticating isn’t trusting. SPF/DKIM/DMARC prove the origin, not the legitimacy. Any bonus based on authentication alone, without a list, is an open door.
- Silence is the enemy. The worst bug isn’t the one screaming in the logs: it’s the one that leaves them clean while it loses you score (
passwordwithout=, unknown symbols after a major upgrade). The only defense is to verify the runtime, not the config. - A runbook is worth the migration itself. Redoing 4 Redis instances, multi-domain SNI, tuned Rspamd weights and DNS-01 hooks from memory is impossible. The real value isn’t the new server: it’s the document that makes the next server an afternoon instead of a week.


