SpamAssassin Custom Rules and Meta Scoring: Catch the Pattern, Not the Word
Individual spam signals are deliberately weak, and SpamAssassin's additive engine sums them blindly. Meta rules let you score the combination instead — here's how to build component tests scored at zero, wire them into boolean and N-of-M metas, and tune one high-confidence score without spraying false positives.
EvilMail TeamJuly 22, 202611 min read
A message lands on your MX. The From is a fresh Gmail address. The subject reads Invoice #4471 overdue. The body asks, politely, for four $100 gift cards to settle the balance today, and the only link in it is a t.co shortener. You know what this is in half a second. SpamAssassin, running stock rules, does not.
Here's why. FREEMAIL_FROM fires, worth maybe 0.0 to 0.8 depending on your config. The word "invoice" hits nothing on its own — real invoices contain it constantly. A shortened URL nudges the URIBL-adjacent rules a little. Add it up and you're at something like 2.3 against a required_score of 5.0. Delivered. Not because any single signal was wrong, but because SpamAssassin sums rule scores independently and structurally cannot see that these three ordinary signals arriving *together* are a fake-invoice scam.
That correlation is exactly the intelligence a flat additive model throws away. Meta rules are how you get it back. Instead of inflating individual weights until "invoice" starts nuking real vendor mail, you write weak component tests scored at zero, then add points only when the *combination* fires. Stop bumping single-rule scores to catch a pattern. Encode the pattern.
SpamAssassin Custom Rules and Meta Scoring Guide (2026) — EvilMail Blog
Where SpamAssassin actually reads your rules
Before any syntax, know which file wins, or you'll spend an afternoon editing something that never loads.
Debian/Ubuntu:/etc/spamassassin/local.cf, plus any other *.cf in that directory. All of them load.
RHEL/Alma/Rocky:/etc/mail/spamassassin/local.cf and *.cf alongside it.
sa-update managed rules:/var/lib/spamassassin/<version>/. These get overwritten on every update. Never edit them. Copy the rule you want to change into local.cf and override it there.
Load order matters: later files win, and among score lines, the last one parsed takes effect. That's the entire override mechanism — redefine a stock rule's score by writing a new score line in local.cf.
The trap that catches everyone at least once: how you restart depends on your stack. In a typical Postfix + amavis setup, SpamAssassin runs as an embedded Perl library *inside* amavisd — there is no spamd in the mail path at all. Restarting spamd does nothing to your live filtering.
bash
# amavis stack (Postfix + amavisd-new): SA is embedded
systemctl restart amavis
# pure spamd / spamc setup:
systemctl restart spamassassin # or 'spamd' on some distros
And always lint first. A single malformed regex silently disables the *entire* ruleset, not just the broken line:
bash
spamassassin --lint && systemctl restart amavis
Anatomy of a custom rule
Every rule matches against a specific representation of the message. Pick the wrong type and your regex tests text that doesn't exist in that form.
header — decoded headers, including pseudo-headers like To, From, Subject, ALL.
body — decoded, HTML-stripped, whitespace-collapsed, line-joined text. This is what a human reads, not the raw source.
rawbody — raw, decoded MIME parts *with* markup intact. Use it when the HTML structure itself is the signal.
uri — URIs extracted from the message. Match links here, not in body.
full — the entire raw message, headers included.
mimeheader — headers of individual MIME parts (Content-Type tricks).
meta / eval — combinations and plugin functions.
The three-line idiom every real rule uses:
header __L_SUBJ_INVOICE Subject =~ /\binvoice\s+#?\d{3,}/i
describe __L_SUBJ_INVOICE Subject looks like an invoice with a number
score __L_SUBJ_INVOICE 0
Note the leading double underscore. A rule whose name begins with `__` is never scored and never reported. It exists only to be referenced inside a meta. This is the most important convention here: score-0 sub-rules still *evaluate* — SpamAssassin runs the test and records the hit — they just don't contribute points or clutter the X-Spam-Report. That's exactly what makes them usable as building blocks.
Scoring: the four-column score line
You'll see stock rules with lines like score BAYES_99 0 0 3.5 4.0 and wonder what the four numbers mean. They map to which subsystems are active at scan time:
SpamAssassin picks the column based on whether Bayes and network tests are enabled during the scan. Stock rules ship four values so they behave sanely regardless of setup. For your handful of local rules you almost never need this — a single number is perfectly valid and easier to reason about.
Two facts to keep straight: required_score 5.0 is the default delivery threshold, and score RULE 0 disables *reporting* but not *evaluation*. You tune the meta. You leave the parts at zero.
In arithmetic context, each rule hit counts as 1, a miss as 0. So >= 3 means "any three of these four weak signals," which survives a spammer dropping one indicator. Requiring all four (&&) is brittle; requiring three of four is a filter that ages well.
Available operators: &&, ||, !, arithmetic + and -, and comparisons (>=, >, ==, and so on). Metas can reference other metas and any built-in rule — BAYES_99, DKIM_VALID, SPF_FAIL, FREEMAIL_FROM. That composability is the whole point: you're building a small expert system on top of hundreds of existing tests.
A real meta: the fake-invoice / gift-card scam
Here's the complete rule set for the message that opened this article. Drop it in local.cf.
# --- component sub-rules, all scored 0, never reported ---
header __L_SUBJ_INVOICE Subject =~ /\binvoice\s+#?\d{3,}/i
body __L_GIFTCARD /\bgift\s?cards?\b/i
uri __L_SHORTENER /\b(?:bit\.ly|t\.co|tinyurl\.com|is\.gd)\//i
# __FROM_FREEMAIL comes from the FreeMail plugin; __L_KNOWN_VENDOR
# is your own allowlist of real senders (define it or drop the clause)
score __L_SUBJ_INVOICE 0
score __L_GIFTCARD 0
score __L_SHORTENER 0
# --- the meta: points enter ONLY on the combination ---
meta LOCAL_INVOICE_SCAM (__L_SUBJ_INVOICE && __L_GIFTCARD && (__L_SHORTENER || __FROM_FREEMAIL) && !__L_KNOWN_VENDOR)
score LOCAL_INVOICE_SCAM 4.5
describe LOCAL_INVOICE_SCAM Fake invoice + gift-card ask + shortener/freemail
tflags LOCAL_INVOICE_SCAM noautolearn
Walk the arithmetic. On the scam message, each component contributes 0, so nothing shows in the report until the meta resolves true and drops 4.5 on the pile. That alone nearly crosses 5.0. Stack a mediocre BAYES_50 (+0.8) on top and you're at 5.3 — a clean, confident hit. On a *legitimate* vendor invoice, __L_GIFTCARD never fires and __L_KNOWN_VENDOR short-circuits the whole expression to false: the meta contributes exactly 0.
Now compare the naive alternative: score each of the three parts at 1.5. On the scam you get 4.5 — *under* the line, so it still gets delivered. And that legitimate vendor invoice mentioning a gift-card promotion? It just picked up 3.0 for nothing, and now sits one weak rule away from quarantine. You've made the filter worse in both directions.
Evaluation order and the traps that cost you a weekend
Metas are declarative, but they run over a real evaluation engine with ordering rules. These are the ones that bite:
Network rules resolve asynchronously. DNSBL, DKIM, and SPF checks run at negative priority and may not have resolved when a naive meta evaluates. If your meta depends on a network rule, tag it: tflags LOCAL_X net. That tells SpamAssassin to defer the meta until network results are in.
A meta only sees rules that actually ran. If a component is gated behind a MIME type or condition that never triggered, it evaluates to 0, not "undefined." Your boolean math still works, but reason about it as "did-not-fire = 0," not "unknown."
Force a sub-rule earlier when you must:priority __L_X -100 moves it ahead in the queue.
Renaming is silent death. Rename a rule that a meta references and the meta quietly sees 0 for it forever — --lint does *not* flag the dangling reference. Grep your .cf files after any rename.
Protect Bayes from your volatile metas. A meta still being tuned shouldn't feed autolearn, or a bad week poisons your Bayes DB. tflags LOCAL_X noautolearn.
Test it before it eats real mail
Never assign a score you haven't measured. Save a few real .eml samples and run local-only test mode:
bash
# -L = local tests only (no network), -t = test mode, don't modify
spamassassin -t -L < sample.eml | grep LOCAL
# full syntax check — run this on every edit
spamassassin --lint
# verbose rule-firing trace when a meta won't fire and you don't know why
spamassassin --lint -D 2>&1 | less
For anything you're going to trust in production, build a corpus: a ham/ directory of known-good mail and a spam/ directory of known-bad, then run mass-check to get real hit-rates and, critically, false-positive counts *before* you commit to a score. For a large ruleset you'd reach for the perceptron score optimizer, but for a handful of local metas, manual scoring against your own corpus is faster and you'll understand every number.
Deployment checklist — clear every box:
spamassassin --lint passes with zero errors
Every component test uses the __ prefix and is scored 0
The meta has a describe line (it shows up in reports and logs)
Tested against your ham/ corpus with zero false positives
Metas depending on DNSBL/DKIM/SPF carry tflags ... net
Volatile metas carry tflags ... noautolearn
Written to the correct .cf for your distro, and it actually loads
amavis (embedded) or spamassassin/spamd (standalone) restarted — the right one
Hit-rate and FP count watched in maillog for a full week before trusting it
The flat additive model was never wrong about the individual signals. It's just blind to the fact that a freemail invoice demanding gift cards through a URL shortener is not three coincidences. Meta rules are where you write down the thing you already know.