Skip to content

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:

SPFIgnoreResults true
SPFSelfValidate true

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

sudo dnf install opendmarc

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:

AuthservID mail.example.com
TrustedAuthservIDs mail.example.com

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:

grep -m1 '^Authentication-Results:' /var/vmail/*/*/cur/* 2>/dev/null | head -1

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 (and permit_auth_destination if 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_client is 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