Postfix Inbound Hardening: OpenDMARC + a DNSBL Check¶
Two more layers for a Postfix/Dovecot mail server, on top of the milter-based spam scanning covered in Inbound Spam Filtering: DMARC policy evaluation (does this sender's domain even allow this message to claim to be from them?) and a DNSBL check (is the connecting IP a known-bad source?). Neither of these catches everything — DMARC only helps once a domain has actually published a policy, and DNSBLs only catch senders already on a blocklist — but both are cheap, standard, well-understood layers that a milter-only setup is otherwise missing.
Why bother if spam scanning already exists¶
Content-based scanning (SpamAssassin/Bayes) and identity-based checks (DMARC, DNSBL) catch different things. A message that legitimately fails a spam-content scan and a message that's an outright domain spoof are different problems: the first is "this content looks like spam," the second is "this sender has no right to claim this identity, regardless of content." A well-crafted spoof of a domain with a strict DMARC policy can look completely clean to a spam filter — DMARC is the layer built specifically for that case.
OpenDMARC: skip the separate SPF milter¶
The usual DMARC writeups have you deploy two milters: an SPF checker (pypolicyd-spf-milter, or a Postfix policy service) that writes an Authentication-Results header, plus OpenDMARC reading that header to complete its alignment check (SPF-aligned OR DKIM-aligned passes DMARC).
On Fedora, that's unnecessary. OpenDMARC's packaged default config already sets:
SPFSelfValidate means OpenDMARC performs its own SPF check internally via libspf2 (a dependency the package pulls in automatically) whenever it can't find a trusted SPF result in the message headers already. SPFIgnoreResults means it never trusts an external SPF header even if one existed — combined, this is "always do SPF myself." One milter, both signals, no separate SPF service to deploy, configure, or keep in sync.
Verify this before skipping the SPF milter, don't just assume it
Confirm your OpenDMARC version's shipped default with grep -v '^#' /etc/opendmarc.conf right after installing (before you edit it) — these are Fedora's packaged defaults, not guaranteed upstream/OpenDMARC-project defaults on every distro. If your platform's default doesn't set these two booleans, either set them yourself or deploy a real SPF milter.
Install and wire in¶
Fedora's default /etc/opendmarc.conf (relevant lines):
Socket local:/run/opendmarc/opendmarc.sock
SoftwareHeader true
SPFIgnoreResults true
SPFSelfValidate true
Syslog true
UMask 007
UserID opendmarc:mail
Add, explicitly:
Don't rely on both filters' hostname defaults happening to agree
AuthservID (OpenDMARC) and OpenDKIM's own authserv-id (the string OpenDKIM stamps into Authentication-Results: when it verifies inbound DKIM) each have their own independent "default to the MTA's hostname" logic. They usually resolve to the same value in practice, but that's coincidence, not a guarantee — and if they silently disagree, OpenDMARC's TrustedAuthservIDs won't match OpenDKIM's header, DKIM alignment will never be found, and every message will evaluate as DMARC-fail on the DKIM side regardless of whether the mail is legitimately signed. Confirm the actual value live before configuring anything:
Whatever authserv-id shows up there (e.g. mail.example.com;) is what both AuthservID and TrustedAuthservIDs must match — set it explicitly in both filters' configs rather than trusting the defaults to line up.
Socket permissions and service, same pattern as every other milter in this stack:
sudo systemctl enable --now opendmarc
sudo usermod -aG opendmarc postfix
sudo systemctl restart postfix # restart, not reload — group membership is read at start
Wire it into the milter chain after OpenDKIM, so it can read OpenDKIM's DKIM verification result:
postconf -e 'smtpd_milters = local:/run/opendkim/opendkim.sock local:/run/opendmarc/opendmarc.sock unix:/run/spamass-milter/spamass-milter.sock'
Order matters here specifically because OpenDMARC's DKIM half depends on reading an Authentication-Results header OpenDKIM already wrote — running it before OpenDKIM would mean there's nothing to read yet. (OpenDMARC does not need to be adjacent to the SPF-related milters, because per above there aren't any — it does that check itself.)
Roll out tag-only first — RejectFailures defaults to off, keep it that way initially¶
OpenDMARC's own default (undocumented as a "safe mode," just how the option works) is not to reject or defer anything regardless of the DMARC evaluation outcome — it adds an Authentication-Results header (dmarc=pass/dmarc=fail) and nothing else, unless RejectFailures is explicitly set to true.
Don't flip RejectFailures to true on day one
A domain that fails DMARC and has published p=reject is exactly the case OpenDMARC is designed to catch — but a filter with zero track record against your real inbound traffic is the wrong place to find out about edge cases the hard way. The classic false-positive sources are legitimate senders that rely on SPF-only alignment (no DKIM signing) whose SPF record doesn't cover every sending path, and mailing-list or forwarding relays that rewrite the envelope and break SPF alignment without re-signing DKIM (the well-known "DMARC breaks mailing lists" problem). Run tag-only for a soak period, check Authentication-Results: dmarc=fail occurrences against known-legitimate mail, then decide on enforcement.
Verify: a real inbound SMTP test, not local injection¶
sendmail/local pickup bypasses smtpd_milters entirely (it only goes through non_smtpd_milters), so testing via echo | sendmail proves nothing about the milter chain. Send a real message through port 25:
import smtplib
from email.mime.text import MIMEText
msg = MIMEText("verification test")
msg["Subject"] = "opendmarc test"
msg["From"] = "test@your-own-domain.com" # spoof your OWN domain from an untrusted IP
msg["To"] = "you@your-own-domain.com"
s = smtplib.SMTP("your.mailserver.ip", 25, timeout=15)
s.ehlo("verify-test.local")
s.sendmail(msg["From"], [msg["To"]], msg.as_string())
s.quit()
Run this from a host that isn't in your SPF record and isn't authenticated — i.e. genuinely spoofing your own domain. Check the delivered message's headers:
Authentication-Results: mail.example.com; dmarc=fail (p=reject dis=none) header.from=example.com
Authentication-Results: mail.example.com; spf=fail smtp.mailfrom=example.com
This single test proves three things at once: SPF self-validation is working (correctly failed an IP with no authorization), DMARC is reading your domain's real published policy (p=reject reflects whatever's actually in your _dmarc TXT record), and — the important part while RejectFailures is off — dis=none confirms no enforcement action was taken even though the policy says to reject. If dis shows anything other than none while you believe RejectFailures is off, stop and re-check the config immediately.
DNSBL: reject known-bad connecting IPs before greylisting¶
A DNSBL (DNS-based blocklist) check rejects SMTP connections from IPs already known to be spam/botnet sources, based on a real-time DNS lookup against a blocklist provider. Spamhaus ZEN is a widely-used blended list (combining their SBL/XBL/PBL lists into one zone).
smtpd_recipient_restrictions =
permit_mynetworks,
permit_sasl_authenticated,
reject_rbl_client zen.spamhaus.org,
check_policy_service unix:postgrey/socket,
reject
Two placement points worth being deliberate about:
- After the permits, before greylisting.
permit_mynetworks/permit_sasl_authenticated(andpermit_auth_destinationif you use virtual mailboxes) must come before the DNSBL check so trusted and authenticated senders never get RBL-checked at all — the same pattern already used for greylisting. - Before greylisting, not after.
reject_rbl_clientis an instant reject; greylisting introduces a delay every legitimate first-time sender has to sit through. Filtering out definite bad actors first, cheaply, means fewer senders hit the (slower) greylist step — and DNSBL lookups themselves are cheap once you have a local recursive resolver (see the open-resolver trap in the spam-filtering article's §8b — the same trap applies here, since this is also a DNS blocklist query).
Verify without a real blocklisted source IP¶
You can't test the actual reject behavior by connecting from a listed IP (you can't forge a real TCP source address), but RFC 5782 defines two mandatory test points every compliant DNSBL must answer correctly, which is enough to prove the plumbing works:
# 127.0.0.2 must ALWAYS be listed (positive test)
dig +short 2.0.0.127.zen.spamhaus.org
# → real listing codes, e.g. 127.0.0.4, 127.0.0.2, 127.0.0.10
# 127.0.0.1 must NEVER be listed (negative test)
dig +short 1.0.0.127.zen.spamhaus.org
# → empty
A real result on the first query and an empty result on the second together confirm: your resolver reaches Spamhaus, Spamhaus is responding (not silently blocked — see the open-resolver trap linked above), and the zone behaves per spec. Pair that with a real inbound SMTP test from a legitimate (non-listed) IP to confirm you haven't over-blocked anything.
Gotchas recap¶
| Symptom | Cause | Fix |
|---|---|---|
| DMARC always evaluates DKIM-only, even for domains that publish SPF | Deployed OpenDMARC without checking whether SPFSelfValidate was already on by default |
Check grep -v '^#' /etc/opendmarc.conf before assuming; set SPFSelfValidate true / SPFIgnoreResults true if not already present, or deploy a real SPF milter |
DKIM-aligned mail evaluates as dkim=fail/absent in DMARC even though DKIM verification is fine |
OpenDMARC's TrustedAuthservIDs doesn't match OpenDKIM's actual authserv-id |
Check a real delivered message's Authentication-Results: header, pin AuthservID/TrustedAuthservIDs explicitly to that value in both filters |
| Legitimate SPF-only or mailing-list mail gets rejected once DMARC is live | RejectFailures = true deployed without a soak period |
Start tag-only (the package default), watch Authentication-Results: dmarc=fail against known-good senders, enforce later |
sendmail/local test shows no DMARC evaluation at all |
Local injection bypasses smtpd_milters (only non_smtpd_milters applies) |
Test via a real SMTP connection on port 25, not sendmail |
DNSBL lookups return 127.255.255.254 / never seem to match |
Querying via a public/open resolver (Spamhaus refuses those) — same trap as SpamAssassin's own DNSBL rules | Run a local recursive resolver (unbound) |
Can't tell if reject_rbl_client actually works |
No real blocklisted IP available to test from | Use the RFC 5782 test points (127.0.0.2 always listed, 127.0.0.1 never listed) |
See also¶
- Inbound Spam Filtering: spamass-milter + SpamAssassin Bayes — the content-scanning layer this sits alongside; also covers
milter_default_actionand whyacceptis the wrong default - Self-Hosted Recursive DNS with unbound — required for reliable DNSBL lookups (open-resolver trap)
- Fleet Log Aggregation — Loki + Grafana + Alloy — where to wire an alert for milter failures once
milter_default_action = tempfailis in play