We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.Privacy Policy

EvilMailEvilMail
FeaturesEverything EvilMail can doTemp Mail for…Temp mail for Discord, ChatGPT & moreNetwork ToolsDNS, WHOIS, port & network toolsCompareEvilMail vs other temp mail services
APIREST API reference & guidesTemp Mail APICopy-paste code in every language
AppsPricingBlog
About UsWho we are & what we buildTrust & SecurityHow we protect your dataFAQAnswers to common questionsContactGet in touch with the team
LoginRegister
EvilMailEvilMail
AppsPricingBlog
LoginRegister
HomeBlogSpamAssassin Custom Rules and Meta Scoring: Catch the Pattern, Not the Word
Tutorial

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 Team
July 22, 2026
11 min read
SpamAssassin Custom Rules and Meta Scoring: Catch the Pattern, Not the Word
Contents
Where SpamAssassin actually reads your rulesAnatomy of a custom ruleScoring: the four-column score lineMeta rules: boolean and arithmetic combinationA real meta: the fake-invoice / gift-card scamEvaluation order and the traps that cost you a weekendTest it before it eats real mail

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 *.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:

score BAYES_99 0 0 3.5 4.0 amber cols: Bayes OFF cyan cols: network ON col 1 = no-net / no-bayes col 2 = no-net / bayes col 3 = net / no-bayes col 4 = net / bayes For local rules a single value is fine: score RULE 4.5

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 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.

Meta evaluation pipeline __L_SUBJ_INVOICE header · score 0 __L_GIFTCARD body · score 0 __L_SHORTENER uri · score 0 __FROM_FREEMAIL stock · score 0 meta combiner A && B && (C||D) && !vendor +4.5 running total LOCAL +4.5 BAYES_50 +0.8 = 5.3 gate: total >= required_score 5.0 >=5 → SPAM <5 → deliver Weak parts contribute 0. The meta is where points enter the total.

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.

Why the meta beats bumping single scores 5.0 Naive: score each part 1.5 1.5 1.5 1.5 = 4.5 (UNDER — miss) …and fires 3.0 on a legit vendor invoice → false-positive risk Meta approach parts=0 meta +4.5 +0.8 = 5.3 (OVER — hit) …and 0 on the legit invoice — the !vendor / combination guard holds

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

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.

Share
𝕏 TwitterLinkedInReddit
PreviousCatching Compromised Mailboxes: Outbound Spam Detection with rspamd and Volume BaselinesNextBackscatter and Joe-Jobs: Reject Bad Recipients at SMTP Time, Never Bounce Later

Related Articles

Tutorial

imapsync at Scale: Folder Mapping, Quota, and Rate-Limit Gotchas for 500-Mailbox Migrations

13 min read
Tutorial

Splitting the Postfix Queue Across Per-Destination Transports

12 min read
Tutorial

Running Two Postfix Instances on One Host: Inbound Filtering vs. Outbound Sending

11 min read
EvilMailEvilMail

EvilMail — free temp mail and disposable email service. No registration required.

Product

FeaturesPricingAPITools

Resources

BlogFAQGlossaryRegisterAbout Us

Legal

Privacy PolicyTerms of ServiceTrust & SecurityContact
© 2026 EvilMail. All rights reserved.
.
(embedded) or
spamassassin
/
spamd
(standalone) restarted — the right one
  • Hit-rate and FP count watched in maillog for a full week before trusting it