Inbound Spam Filtering: spamass-milter + SpamAssassin Bayes on Postfix/Dovecot¶
How to add inbound spam scanning to a Postfix/Dovecot virtual-mailbox server on Fedora: SpamAssassin scans every inbound message via spamass-milter, spam is tagged (never rejected), Dovecot's Sieve files it into the user's Junk folder, and a site-wide Bayes database — shared between the scan path and manual sa-learn training — learns from your real mail.
This is a "tag and quarantine" design (not "reject at SMTP"), which is the safe default: a misfire lands a message in Junk for review rather than bouncing legitimate mail.
Architecture¶
inbound SMTP (25) ─► Postfix smtpd
│ smtpd_milters:
│ 1. OpenDKIM (verify/sign)
│ 2. spamass-milter ─► spamc ─► spamd (SpamAssassin)
│ adds X-Spam-Flag / X-Spam-Status headers
▼
Dovecot LMTP delivery ─► global Sieve
if X-Spam-Flag: YES ─► fileinto "Junk"
else ─► INBOX
Bayes DB /var/lib/spamassassin/bayes/ (site-wide, shared)
├─ spamd auto-learns at scan time
└─ sa-learn manual/scripted training from Maildir folders
1. Install¶
On Fedora the spamass-milter unit runs as the unprivileged sa-milt user and creates its socket at /run/spamass-milter/spamass-milter.sock. Remember that user — the Bayes DB ownership and the socket permissions both hinge on it.
2. Configure spamass-milter — tag-only¶
Edit /etc/sysconfig/spamass-milter:
The -r flag is a footgun
-r nn rejects mail scoring ≥ nn at SMTP time. Omitting -r does NOT mean "never reject" — this build still rejects flagged spam at a low default threshold (a GTUBE test will get 550 Blocked by SpamAssassin). To get pure tag-only behaviour, set the threshold absurdly high (-r 999999) so nothing ever reaches it. Do not use -r -1 — that means "reject anything tagged as spam."
-a— skip messages on authenticated connections, so your own outbound/submission mail isn't scanned or tagged.
The -s flag is a second, easy-to-miss footgun: spamc's default scan ceiling is 500 KB
spamc (the client spamass-milter shells out to) refuses to scan any message over its --max-size, which defaults to 500,000 bytes — with no warning anywhere in the milter's own config or logs that this ceiling exists. Nothing about -a or -r touches it; it's a separate flag entirely.
Everything past the ceiling behaves as if the scanner isn't running at all: spamc exits EX_TOOBIG with empty output, spamass-milter logs Could not extract score from <>, and — critically — there's no spamd: processing message line and no X-Spam-* headers added. If your Sieve routing (§6) or REDIRECT rule (§6b) depends on X-Spam-Flag, it has nothing to act on, so the message goes straight to INBOX indistinguishable from clean mail. milter_default_action doesn't come into play here either — the milter itself completed "successfully," it just did nothing.
This is a real, exploitable evasion, not a theoretical one: padding a message past the ceiling with an oversized attachment or junk filler (an image, a chunk of quoted boilerplate) is a known spam technique specifically because operators don't realize a size cap like this exists. On one production box this was found to have a 100% historical bypass rate for every message over 500 KB since the milter was wired in — the scanner had simply never seen any of them.
Fix — raise the ceiling (flags after -- pass through to spamc; keep it at or under Postfix's own message_size_limit):
Check whether you're already affected — this pattern in the mail log means a message was silently skipped, not scanned-and-passed:
Every hit is a message that reached the mailbox with zero spam evaluation. Cross-reference the timestamp against the stored message to see what got through.
The same 500 KB default exists independently in sa-learn (used for Bayes training, §7) — it isn't shared config with spamc, so fixing one does not fix the other. A sa-learn --spam run against oversized messages will silently report Learned tokens from 0 message(s) with no error; add --max-size there too:
3. Socket permissions (so Postfix can connect)¶
The socket is created 0770 sa-milt:sa-milt only if you widen the unit's umask; by default it's 0755 and Postfix (running as postfix) can't write to it. Two steps:
# 1. Let the socket be group-accessible
sudo install -d /etc/systemd/system/spamass-milter.service.d
printf '[Service]\nUMask=0007\n' | sudo tee /etc/systemd/system/spamass-milter.service.d/socket-perms.conf
# 2. Put postfix in the sa-milt group, then RESTART postfix (group is read at start)
sudo usermod -aG sa-milt postfix
sudo systemctl daemon-reload
sudo systemctl enable --now spamass-milter
Verify: sudo -u postfix test -w /run/spamass-milter/spamass-milter.sock && echo OK.
4. Wire into Postfix¶
Append the milter alongside OpenDKIM — don't replace it. Inbound (smtpd) gets both; local-injected mail (non_smtpd) stays DKIM-only.
postconf -e 'smtpd_milters = local:/run/opendkim/opendkim.sock unix:/run/spamass-milter/spamass-milter.sock'
postconf -e 'milter_default_action = tempfail'
sudo systemctl restart postfix # restart (not reload) to pick up the new group
milter_default_action = accept turns every milter failure into a silent, unscanned delivery
This guide originally recommended accept here on the reasoning "if the milter ever hiccups, mail still flows" — that reasoning is backwards, and following it is exactly how a real spam-scan bypass reached an inbox undetected for two months on a production box (see the -s/spamc-size-ceiling footgun above). With accept, any milter failure — a crashed process, a missing socket, a protocol timeout, or the oversized-message case above — is indistinguishable from "scanned clean": the message delivers with no X-Spam-* headers and no error visible anywhere except an easily-missed log line.
tempfail instead 4xx-defers the SMTP transaction, so a well-behaved sending MTA queues and retries later rather than the message silently slipping through unscanned. The trade-off is real and worth naming: a milter that's genuinely wedged (not just transiently down) now stalls all inbound mail instead of leaking it — so tempfail should be paired with alerting on milter-failure log lines (e.g. a Loki/Grafana rule matching (?i)milter + warning|error|timeout|unavailable), not deployed silently. A milter that's down for seconds during a restart is invisible to senders either way (SMTP retries absorb it); the difference only matters for milters that are broken for longer, which is precisely the case accept was hiding.
5. Site-wide Bayes DB¶
Put the Bayes DB in one fixed location so the scan path and your training script share it. In /etc/mail/spamassassin/local.cf:
Create the directory owned by the scanning user (sa-milt), under /var/lib/spamassassin so it inherits the correct SELinux type (spamd_var_lib_t):
sudo install -d -m 2770 -o sa-milt -g sa-milt /var/lib/spamassassin/bayes
sudo restorecon -Rv /var/lib/spamassassin/bayes
sudo systemctl restart spamassassin
The 2770 setgid + bayes_file_mode 0660 means whether the DB is written by spamd (as sa-milt) or by sa-learn (as root, from a training script), all parties can read and write it.
6. File spam into Junk (Dovecot Sieve)¶
A global Sieve before-script files anything SpamAssassin flagged. /etc/dovecot/sieve/global/spam-to-junk.sieve:
require ["fileinto", "mailbox"];
if anyof (header :contains "X-Spam-Flag" "YES", header :contains "X-Spam-Status" "Yes") {
fileinto :create "Junk";
stop;
}
Register it as a global before-script in dovecot.conf (NOT under plugin {} on Pigeonhole 2.4+ — see warning below), then compile and restart Dovecot:
Pigeonhole 2.4 dropped plugin/sieve_before — it silently does nothing
Before Dovecot/Pigeonhole 2.4, the canonical way to register a global before-script was:
On Dovecot 2.4+, that setting is gone and silently ignored — no warning at start-up, the script never runs, and your X-Spam-Flag mail just lands in INBOX wondering why nothing files it. The 2.4 replacement is a top-level sieve_script block (not inside plugin {}):
Verify with doveconf -n | grep -A2 spam_before. If it doesn't appear, dovecot.conf isn't reading your file — check that !include conf.d/*.conf exists in dovecot.conf (some Fedora rebuilds ship a flat dovecot.conf without it; the block has to live in dovecot.conf directly).
6b. (Optional) Route spam to a separate mailbox — silence iOS push notifications¶
fileinto :create "Junk" moves spam to the user's .Junk folder, but the user's IMAP session still sees a new-message event in INBOX (briefly, before sieve moves it) or in Junk (depending on client subscriptions). For clients with IMAP IDLE + push, that's a notification you don't want — e.g. Spark on iPhone/iPad fires APNS on any new message touching a subscribed folder.
To make spam invisible to the user's mailbox entirely, REDIRECT the envelope at Postfix cleanup (after the milter adds X-Spam-Flag, before LMTP delivery) so spam lands in a separate junk@ mailbox the user doesn't subscribe to:
Use regexp:, not pcre:, on stock Fedora
pcre: requires the postfix-pcre package. regexp: is built into postfix and supports POSIX extended regex — use [[:space:]]+ for whitespace and \\\\ for backslash. The patterns in cleanup_header_checks are simple enough that regexp is plenty.
The Sieve from §6 still runs as a safety net for any tagged message that escapes the cleanup REDIRECT (e.g. a message addressed to the junk@ mailbox itself, or aliases not covered by the REDIRECT rule). Defense in depth.
Train Bayes from the junk@ Maildir instead of (or in addition to) per-user Junk folders:
7. Training the Bayes filter¶
SpamAssassin's Bayes only starts scoring once it has learned ≥ 200 spam AND ≥ 200 ham (bayes_min_spam_num / bayes_min_ham_num). Train from your Maildir folders with sa-learn. Run it as root — root can read every user's Maildir and write the Bayes DB.
# Spam — your Junk folder(s) and any dedicated spam mailbox
sa-learn --spam /var/vmail/example.com/user/.Junk/{cur,new}
# Ham — Sent + Inbox (known-good)
sa-learn --ham /var/vmail/example.com/user/{cur,new}
sa-learn --ham /var/vmail/example.com/user/.Sent/{cur,new}
sa-learn --sync
sa-learn --dump magic | grep -E 'nspam|nham'
bayes_path is read from local.cf, so no --dbpath is needed.
Keep spam and ham roughly balanced
Bayes accuracy drops when one corpus dwarfs the other (aim for within ~3:1). Don't dump a 90,000-message archive of ham against a few hundred spam — it biases everything toward "ham" and spam slips through. Use Sent + recent Inbox for ham, not your entire archive.
Train manually, not from cron — unless your folders are always clean
sa-learn learns whatever is in the folder. If a spam slips into the Inbox, or you haven't yet rescued a false-positive out of Junk, an unattended cron run will mislearn it. Prefer a manual script you run after triaging Junk/Inbox. (sa-learn is idempotent and re-classifies on re-run, so a mistake is fixable: move the message to the right folder and run again.)
7a. Weekly systemd timer (safe when junk@ is dedicated and INBOX is curated)¶
On majormail, Bayes training is done by IMAPSieve, NOT this timer — verified 2026-07-20
This timer is not deployed on the live majormail (systemctl list-timers shows no sa-learn unit; /etc/systemd/system/sa-learn* does not exist). Bayes is nonetheless live and training (nspam 1335 / nham 1472, bayes_toks updated same-day) — via Dovecot IMAPSieve learn-on-move: dragging a message into .Blocked fires sa-learn --spam, dragging out fires --ham. See the IMAPSieve sieve-pipe article and the majormail incident notes.
The two mechanisms should not both run. IMAPSieve trains from a curated, per-message signal; this timer's --ham /var/vmail/.../{cur,new} would re-train ham from the entire INBOX on a schedule, double-counting and biasing the corpus. Treat §7a as the alternative for hosts that lack IMAPSieve, not an addition to it. On majormail, leave it off.
The warning above is the safe default. If you use the §6b REDIRECT-to-junk@ pattern, the junk mailbox is pure spam by design (only X-Spam-Flag:YES envelopes reach it), and your INBOX is curated by hand — the misclassification risk drops to near zero, and a weekly timer becomes both safe and useful. Add --force-expire to age out stale tokens so the Bayes corpus doesn't drift.
# /etc/systemd/system/sa-learn-majormail.service
[Unit]
Description=SpamAssassin Bayes training from majorshouse.com Maildir
After=spamassassin.service
Wants=spamassassin.service
[Service]
Type=oneshot
Nice=10
IOSchedulingClass=idle
ExecStart=/usr/bin/sa-learn --spam --no-sync \
/var/vmail/example.com/junk/cur \
/var/vmail/example.com/junk/new
ExecStart=/usr/bin/sa-learn --ham --no-sync \
/var/vmail/example.com/user/cur \
/var/vmail/example.com/user/new \
/var/vmail/example.com/user/.Sent/cur \
/var/vmail/example.com/user/.Sent/new
ExecStart=/usr/bin/sa-learn --sync
ExecStart=/usr/bin/sa-learn --force-expire
# /etc/systemd/system/sa-learn-majormail.timer
[Unit]
Description=Weekly SpamAssassin Bayes training + expiry
[Timer]
OnCalendar=Sun 04:15
Persistent=true
RandomizedDelaySec=20min
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now sa-learn-majormail.timer
systemctl list-timers sa-learn-majormail.timer
Persistent=true runs the missed job on next boot if the host was off at 04:15. --force-expire is a no-op until SA's expiry heuristic decides tokens are due (typically every few weeks for the default bayes_expiry_max_db_size).
7b. Incremental training — only learn new mail¶
Both the manual script (§7) and the timer (§7a) point sa-learn at whole Maildirs, so every run re-examines every message in each folder — not just new arrivals. sa-learn has to open and tokenize each message to compute its hash before its bayes_seen table can decide the message is already learned and skip it. So the cost of a run is O(folder size), not O(new mail): on a mailbox with a large .Sent folder or a growing junk@ sink, most of each run is spent re-reading mail already in the DB. The tell is in sa-learn's own output — a run that reports Learned tokens from 0 message(s) (700 message(s) examined) did 700 messages of work to learn nothing.
Gate each pass on a stamp file so only messages newer than the last successful run are examined:
#!/bin/bash
# Incremental Bayes training — only learn mail newer than the last good run.
STAMP=/var/lib/spamassassin/.last_learn
NEWSTAMP="$(mktemp "${STAMP}.new.XXXXXX")" # marks the START of this run
trap 'rm -f "$NEWSTAMP"' EXIT
# Add -newer only if a prior stamp exists; the first run trains everything.
NEWER=(); [ -f "$STAMP" ] && NEWER=(-newer "$STAMP")
learn_new() { # learn_new <--spam|--ham> <maildir> [maildir...]
local mode="$1"; shift
local dirs=() base
for base in "$@"; do
[ -d "$base/cur" ] && dirs+=("$base/cur")
[ -d "$base/new" ] && dirs+=("$base/new")
done
[ ${#dirs[@]} -eq 0 ] && return 0
find "${dirs[@]}" -type f "${NEWER[@]}" -print0 | xargs -0 -r sa-learn "$mode"
}
learn_new --spam /var/vmail/example.com/junk
learn_new --ham /var/vmail/example.com/user
learn_new --ham /var/vmail/example.com/user/.Sent
sa-learn --sync
mv -f "$NEWSTAMP" "$STAMP" # promote the stamp ONLY after a clean run
Two subtleties make this correct rather than merely fast:
Stamp at the start, promote at the end
The new stamp is created (mktemp) at the start of the run but only moved into place (mv) after a clean finish. That makes the training window [old stamp .. start of this run), so a message that arrives while the run is in flight is caught on the next run rather than silently skipped. And because the old stamp isn't advanced until success, a run that dies partway simply retries the same window next time instead of losing it.
The first run (no stamp yet) omits -newer and trains the whole folder — identical to the non-incremental version — then drops the stamp. Every run after that is O(new mail). sa-learn's bayes_seen still dedupes as a backstop; the stamp just spares you the wasted re-read. To force a full retrain, delete the stamp (rm /var/lib/spamassassin/.last_learn) and run once.
8. Test¶
Send a GTUBE probe through port 25 (unauthenticated) and a normal message:
# from a host that can reach :25 — GTUBE scores ~1000
printf 'Subject: gtube\n\nXJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\n' \
| sendmail -f test@example.org user@example.com
Confirm in /var/log/maillog that spamd scanned it (result: Y …), the message was delivered (no milter-reject), it landed in .Junk, and the stored message has X-Spam-Flag: YES.
8b. DNSBLs need a local recursive resolver (the open-resolver trap)¶
Bayes catches content, but the biggest single spam signal is the DNS blocklists — Spamhaus ZEN/PBL/SBL and URIBL — worth ~3–4 points on a typical spam. There's a trap that silently disables all of them: Spamhaus and URIBL refuse queries that arrive via a large public/open resolver (Google 8.8.8.8, Cloudflare 1.1.1.1, OpenDNS, or a systemd-resolved forwarding to one). Instead of a listing they return the sentinel 127.255.255.254 ("open resolver"), and SpamAssassin marks the rule ..._BLOCKED and stops querying that zone.
The symptom is spam that Bayes is certain about still reaching the inbox. In /var/log/maillog you'll see the block on nearly every scan:
bayes=1.0 but the total is only 3 (< required_score 5.0) because ZEN/URIBL contributed nothing → delivered. Confirm the resolver is the problem by querying a test record — a public resolver returns the error sentinel, a proper recursor returns real data:
# open/public resolver → refused: "Error: open resolver"
dig +short 2.0.0.127.zen.spamhaus.org TXT
# 127.255.255.254 = blocked; "Listed by XBL/SBL/PBL" = working
Fix: run your own recursive resolver so DNSBL queries originate from your mail server's own IP (low-volume, within the free tier). unbound with its default recursive config is enough:
Point the system resolver at it. On a systemd-resolved host, add a drop-in (this coexists with the stub on 127.0.0.53, which is what /etc/resolv.conf uses):
systemctl restart systemd-resolved
# clear SA's cached "blocked" flags, then let spamd re-read the resolver
find / -name 'dnsblock_*' -delete 2>/dev/null
systemctl restart spamassassin
Re-run the dig test (now via the stub, @127.0.0.53) — you should get Listed by XBL/SBL/PBL. Real spam will now pick up RCVD_IN_ZEN/RCVD_IN_PBL/URIBL_* hits and cross threshold.
Do not point
/etc/resolv.confstraight at8.8.8.8or1.1.1.1on a mail server — that reintroduces the block. A local recursor is the whole point.
For the general setup (install, Debian/Fedora, systemd-resolved vs resolv.conf, Pi-hole upstream), see the dedicated guide: Self-Hosted Recursive DNS with unbound.
Gotchas recap¶
| Symptom | Cause | Fix |
|---|---|---|
Spam gets 550 Blocked by SpamAssassin (you wanted Junk) |
spamass-milter rejects at a default threshold | -r 999999 for tag-only |
Mail over ~500 KB has no X-Spam-* headers at all, reaches INBOX unscanned |
spamc's default --max-size is 500 KB; oversize mail returns EX_TOOBIG with no error the milter surfaces |
-- -s 5000000 passthrough flag (§2); sa-learn --max-size too, it's a separate default |
| A milter outage (crash, oversize, timeout) delivers mail with zero scan and zero warning | milter_default_action = accept fails open silently |
milter_default_action = tempfail, paired with alerting on milter-failure log lines |
| Postfix can't reach the milter socket | socket 0755, postfix not in sa-milt group |
UMask=0007 drop-in + usermod -aG sa-milt postfix + restart postfix |
sa-learn trains but spamd doesn't use it |
per-user vs site Bayes mismatch | set bayes_path in local.cf (site-wide) |
Bayes never scores (BAYES_* absent) |
below the 200/200 learn floor | train more, keep spam/ham balanced |
Bayes-certain spam still reaches inbox; DNSBLs show ..._BLOCKED |
querying via a public/open resolver → Spamhaus/URIBL refuse (127.255.255.254) |
run a local unbound recursor, point the resolver at 127.0.0.1 (§8b) |
| Your own outbound mail gets tagged | scanning authenticated mail | -a flag |
| AVC denials on the Bayes DB (SELinux) | DB outside /var/lib/spamassassin |
keep it under that path (spamd_var_lib_t) + restorecon |
plugin/sieve_before does nothing — spam keeps reaching INBOX |
Pigeonhole 2.4 silently dropped that setting | use the top-level sieve_script <name> { type = before; path = ...; } block instead |
postfix reload fails: unsupported dictionary type: pcre |
pcre: map requires postfix-pcre package |
install it, OR use regexp: (built-in POSIX) |
Sieve fileinto Junk still notifies Spark/iOS |
client subscribes to Junk; LMTP delivery briefly hits INBOX | REDIRECT envelope at Postfix cleanup (§6b) so the message never reaches the user's mailbox at all |
Local sendmail test doesn't trigger REDIRECT |
sendmail bypasses smtpd milters → no X-Spam-Flag added |
inject through SMTP :25 (e.g. swaks) OR pre-set the header in the test message |
See also¶
- Self-Hosted Recursive DNS with unbound (the §8b DNSBL fix, in depth)
- SELinux: Fixing Dovecot Mail Spool Context (/var/vmail)
- Linux Server Hardening Checklist (basic
sa-learnsection) - Postfix Inbound Hardening: OpenDMARC + a DNSBL Check — identity/reputation-based layers on top of content scanning