Postfix header_checks and body_checks: Precision Regex Filtering Without Shooting Yourself in the Foot
RBLs, SPF, DKIM, DMARC and greylisting all ran, and something specific still slipped through. Postfix header_checks and body_checks are the PCRE scalpel for that last mile — cheap, early, and dangerous. Here is how to wield them precisely, test them offline, and keep them from becoming a 2am outage.
EvilMail TeamJuly 11, 202611 min read
Your MX already runs postscreen and a couple of RBLs. SPF, DKIM and DMARC are evaluated by a milter. Greylisting delays the drive-by junk. And yet, once or twice a week, one specific thing still lands in a user's inbox: a .iso dropper wearing an invoice filename, or a From: header that spoofs your CEO's display name over a throwaway Gmail address. None of your reputation-based layers care, because none of them are looking at *that* pattern.
header_checks and body_checks are the tool for exactly this. They are raw PCRE content filters that fire inside Postfix's cleanup(8) daemon, one physical line at a time, before the message is ever queued. That makes them cheap and early. It also makes them blunt and dangerous — they see the MIME stream as it arrives on the wire, not a decoded message, and a single greedy regex can silently shred legitimate mail in a way that is miserable to debug at 2am. This is how to use them like a scalpel instead of a shotgun.
When (and when not) to reach for content checks
Content checks are a last resort, and that ordering matters. By the time
Postfix header_checks & body_checks with PCRE Maps — A Precise Guide — EvilMail Blog
cleanup(8)
runs, connection-stage controls (postscreen, RBLs), sender authentication (SPF/DKIM/DMARC), and reputation scoring have already had their say. A regex map is what you add *after* those, for a pattern they structurally cannot catch.
Use them for surgical, stable, literal patterns:
Block a specific dangerous attachment extension by its MIME filename.
Kill a known display-name spoof of a named executive.
Quarantine a subject line during an active phishing campaign against your users.
Strip internal Received: / X- headers leaking out of your relay.
Do not use them as a spam classifier. Regex maps have no scoring, no learning, and no notion of "probably." A false positive here is a legitimate message dropped with no second opinion. That job belongs to rspamd or SpamAssassin behind a milter. The rule of thumb: if you can write the bad thing as one stable literal pattern in a header or a small text region, a check is the right tool. If catching it needs judgment, it is the wrong tool.
How the checks actually run in the pipeline
This is the load-bearing mechanic most tutorials skip, and it explains every gotcha that follows. All four map types are applied by cleanup(8) as the message is written into the incoming queue. They run against the unfolded header lines — which are still RFC 2047 encoded — and against the raw body lines exactly as they appeared on the wire.
That last part has teeth:
Base64 and quoted-printable are not decoded. A .pdf attachment is a wall of base64, and /invoice\.pdf/ will never match it.
MIME boundaries and Content-Type headers are fully visible — which is exactly why filename matching works.
An encoded-word subject like Subject: =?UTF-8?B?SGVsbG8=?= is matched as that literal string, not as "Hello."
Matching is per physical line. There is no cross-line matching, ever. You cannot write a regex that spans a header and the body, or two body lines.
Evaluation order inside cleanup(8):
The performance cost is worth stating plainly: body_checks runs one PCRE evaluation *per line, per message*. On header and MIME-header work that is nothing. On bodies it is O(n) over the entire content — including that 40MB base64 attachment — and on a busy MX it will pin a CPU core. That is the single most common way people misuse these maps, and we will come back to it.
PCRE vs regexp maps, and why PCRE
Postfix ships two regex map types. regexp: uses POSIX regular expressions; pcre: uses libpcre2 and gives you lookahead, non-greedy *?, \d, \b, and inline flags. For anything non-trivial you want pcre:. Confirm it is compiled in:
bash
postconf -m | grep pcre
If pcre is not listed, install it — on Debian/Ubuntu that is apt install postfix-pcre, then systemctl restart postfix.
The file format is one rule per line:
/pattern/flags ACTION optional text
The flags that matter in practice are i (case-insensitive) and x (extended mode, so you can add whitespace and # comments inside the pattern). m (multiline) is largely irrelevant because matching is already per-line. Two more constructs earn their keep: the !/pattern/ negation form ("act when this does *not* match"), and if /.../ ... endif blocks that gate a group of rules on a cheap prefix test instead of re-anchoring every line.
The action verbs, precisely
This is where people get burned, so here is each verb with its exact runtime behavior:
REJECT *text* — Postfix returns 550 5.7.1, and the sending server generates the bounce/DSN. Loud and honest.
DISCARD *text* — accepts the message, then silently drops it. The sender's server thinks it delivered. Dangerous: no bounce, no trace for the sender, and if you were wrong the mail is simply gone.
HOLD — freezes the message in the hold queue for human review. Recover with postsuper -H (release) or postsuper -r (requeue); inspect with postcat -q QID. This is your friend.
FILTER *transport:nexthop* — reroutes to a content filter. It is sticky and takes effect on the first match, overriding normal routing — easy to misfire.
IGNORE — deletes just the one matched header line and keeps the message. Perfect for stripping leaking internal headers.
REPLACE / PREPEND — rewrite the matched header, or add a new header line. PREPEND is how you tag mail for downstream sorting.
WARN *text* — logs the match and does nothing else. This is how you test a pattern safely in production.
REDIRECT *user@domain* / BCC *addr* — reroute or copy.
The discipline that prevents outages: deploy every new rule as WARN first. Watch the logs, confirm it only fires on what you intended, then promote it to REJECT or HOLD.
Real rules that earn their place
Wire the maps in main.cf, pointing each at a pcre: file:
A mime_header_checks file that blocks dangerous executable attachments by filename — this catches both Content-Type: ...; name= and Content-Disposition: ...; filename=:
A header_checks file — the classic display-name spoof, plus header hygiene:
# 2026-07-01 TICKET-4419 sec: hold CEO display-name spoof over free mail
/^From:.*Jane Okoro.*<[^>]*@(gmail|outlook|yahoo|proton)\.[a-z]+>/i
HOLD possible executive impersonation
# strip internal routing that must never leave the perimeter
/^Received:.*\.internal\.evilmail\.pro/ IGNORE
/^X-Internal-[A-Za-z-]+:/ IGNORE
# tag a live-campaign subject for downstream sorting instead of dropping
/^Subject:.*your (package|parcel) could not be delivered/i
PREPEND X-Campaign-Watch: courier-phish-2026-07
Note the reload semantics, because this trips up people coming from hash: or lmdb: maps: PCRE and regexp maps are read on demand — you do not run `postmap` on them.postmap only builds indexed maps. After editing a pcre: file, all you need is:
bash
postfix reload
Test before you ship, debug when it bites
The single most valuable habit with these maps is that you can run a real message through them without sending a single email. postmap -q matches line by line the way cleanup(8) does, so it closely reproduces production behavior (the one caveat: it will not unfold multi-line headers the way cleanup does):
bash
# run a saved .eml through the header map — see which line triggers what
postmap -q - pcre:/etc/postfix/header_checks < suspicious.eml
# test a single header line
echo 'Subject: your parcel could not be delivered' \
| postmap -q - pcre:/etc/postfix/header_checks
If a rule matches, postmap -q prints the action and text for the offending line and nothing for the rest. Keep a small corpus of known-good .eml files from real senders and run every new rule against it before promotion — that is your regression suite.
When something does bite in production, the logs name the exact rule. Grep the mail log or use journald:
The log line quotes the matched header and the queue ID, so you can tie a rejection straight back to the pattern that fired it. (smtpd_command_filter is a different mechanism that rewrites SMTP commands, not message content — don't confuse the two.)
The false-positive minefield
Every one of these has burned someone in production:
/invoice/i REJECT in body_checks — congratulations, you now bounce every real invoice.
Matching on a subject you *think* reads Payment overdue but arrives as =?UTF-8?B?...?=. Encoded-words are not decoded; your pattern never fires, or fires on the wrong thing.
A body_checks rule on /unsubscribe/ — that word is in the footer of every legitimate newsletter on earth.
Greedy .* with no anchors, matching far more than you intended across the line.
Safety checklist
1.Anchor. Prefer ^ and $ and specific character classes over bare .*. Match the narrowest thing that identifies the threat.
2.Stage as WARN. Every new rule ships as WARN, and you read the logs for 48 hours before promoting to REJECT or HOLD.
3.Test against a corpus. Run postmap -q over known-good .eml files, not just the one bad sample.
4.Keep body_checks minimal. On a busy MX, do the work in header_checks / mime_header_checks where it is one eval per header, not one per body line.
5.Comment every rule with a date, ticket, and owner. A cryptic regex with no context is a future outage.
6.Version-control `/etc/postfix`. Every map edit is a reviewable diff you can roll back.
7.Prefer HOLD over DISCARD. A held message is recoverable; a discarded one is gone with no trace.
8.Respect the decode limits. These maps see the raw wire format. For real content inspection — decoded bodies, scoring, learning — hand off to a milter.
That last point is the whole game. header_checks and body_checks are a scalpel for stable, literal patterns you can name. On the evilmail.pro infrastructure we run a deliberately lean set of header- and MIME-header-only rules — executable-extension blocks, a short spoof watchlist, header hygiene — and let rspamd carry the heavy classification. Keep the regex maps small, keep them anchored, keep them under version control, and they will quietly kill the specific junk that everything else waves through.