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.
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*.cfin that directory. All of them load. - RHEL/Alma/Rocky:
/etc/mail/spamassassin/local.cfand*.cfalongside 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 intolocal.cfand 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.
# amavis stack (Postfix + amavisd-new): SA is embedded
systemctl restart amavis
# pure spamd / spamc setup:
systemctl restart spamassassin # or 'spamd' on some distrosAnd always lint first. A single malformed regex silently disables the *entire* ruleset, not just the broken line:
spamassassin --lint && systemctl restart amavisAnatomy 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 0Note 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.
Meta rules: boolean and arithmetic combination
Metas come in two flavors, and you'll use both.
Boolean — fire when a logical expression is true:
meta LOCAL_INVOICE_SCAM (__L_SUBJ_INVOICE && __L_GIFTCARD && (__L_SHORTENER || __FROM_FREEMAIL) && !__L_KNOWN_VENDOR)Arithmetic / counting — the N-of-M pattern, far more robust than demanding every signal:
meta LOCAL_MULTI (__L_A + __L_B + __L_C + __L_D >= 3)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 noautolearnWalk 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 -100moves it ahead in the queue. - Renaming is silent death. Rename a rule that a meta references and the meta quietly sees
0for it forever —--lintdoes *not* flag the dangling reference. Grep your.cffiles 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:
# -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 | lessFor 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 --lintpasses with zero errors- Every component test uses the
__prefix and is scored0 - The meta has a
describeline (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
.cffor your distro, and it actually loads amavis
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.


