SpamAssassin scores explained: how the number is built, and how to bring it down

What a SpamAssassin score of 4.2 actually means, where the 5.0 threshold comes from, the rules that fire most often on transactional email, how to read the X-Spam-Status header, and what to fix in the template versus the infrastructure.

The Mailclutch teamUpdated 7 min read

SpamAssassin is the oldest spam filter still in wide use, and the only major one whose scoring is fully public. That makes it uniquely useful as a test: Gmail will not tell you why a message went to spam, but SpamAssassin will list every rule that fired, with the points each one cost. This article is about reading that list.

It runs on every message a Mailclutch inbox captures, so the examples below are the kind of thing you see on a staging send — before a real inbox does.

How a score is built

SpamAssassin runs a message through a few thousand rules. Each rule is a test — a regex on a header, a structural check on the MIME tree, a DNS lookup, a Bayesian probability bucket — and each has a score, positive or negative. The message's total is the sum of the scores of every rule that matched.

The default threshold is 5.0. At or above it, the message is tagged as spam. That number is a convention from the project's tuning — the rule scores are optimized as a set so that 5.0 separates the training corpora well — and many operators lower it. A message at 4.9 is not "safe"; it is one rule away from the line at the default setting and already over it at a stricter one.

The result arrives as headers:

X-Spam-Status: No, score=3.4 required=5.0 tests=HTML_IMAGE_ONLY_16,HTML_MESSAGE,
        MISSING_DATE,MISSING_MID,RDNS_NONE,T_KAM_HTML_FONT_INVALID autolearn=no
        autolearn_force=no version=4.0.1
X-Spam-Level: ***

score=3.4 is the total, required=5.0 the threshold, tests= the rules that fired. X-Spam-Level is the score as asterisks, for filters that only do string matching. Everything you need to fix the message is in the tests= list — the question is what each name means and whether it is the template's fault or the server's.

The rules that fire on transactional email

Marketing mail trips different rules than application mail. These are the ones that show up on password resets, receipts and notifications, grouped by where the fix lives.

Missing or malformed headers — fix in the mailer

Missing or malformed headers — fix in the mailer
Rule Score Meaning
MISSING_MID ~1.4 No Message-ID header.
MISSING_DATE ~1.4 No Date header.
MISSING_HEADERS ~1.0 To: is missing.
MISSING_SUBJECT ~1.8 No Subject:.
DATE_IN_PAST_96_XX / DATE_IN_FUTURE_* 0.5–3.5 Date is hours or days off. Usually a wrong server clock or a timezone bug.
INVALID_DATE ~1.1 Date not in RFC 5322 format.
NO_RELAYS / NO_RECEIVED ~0 to -0.1 Informational; common in a sandbox.

These are the cheapest points to recover. Every framework mailer sets Message-ID and Date; hand-rolled SMTP code and some queue serialisers drop them. A sandbox capture makes it visible in one line.

The From and the domain — fix in DNS or the sending path

The From and the domain — fix in DNS or the sending path
Rule Score Meaning
RDNS_NONE ~0.8 The sending IP has no reverse DNS.
SPF_FAIL / SPF_SOFTFAIL ~0.9 / ~0.7 The envelope sender's SPF does not authorise the IP.
DKIM_INVALID ~0.1 A signature is present and does not verify. Low score, high signal.
DKIM_VALID, DKIM_VALID_AU -0.1 each A valid signature, and one aligned with From. Small negatives.
DMARC_MISSING ~0.1 No DMARC record on the From domain.
FREEMAIL_FROM ~0.001 From is a free mailbox provider. Combines with others.
HEADER_FROM_DIFFERENT_DOMAINS ~0.25 From: domain differs from Return-Path: domain. Normal for ESPs; adds up.
FROM_LOCAL_HEX, FROM_LOCAL_DIGITS 0.1–1.0 The local part looks machine-generated.
TO_EQ_FROM ~1.0 Sending to yourself — common in tests, a signal in the wild.

Most of these are the Gmail requirements seen from SpamAssassin's side. A note on sandbox results: the sending host in staging is your staging host, so RDNS_NONE and an SPF_* result there describe staging, not production. What transfers is everything about the message itself.

The body — fix in the template

The body — fix in the template
Rule Score Meaning
HTML_MESSAGE ~0.001 The message has an HTML part. Baseline; not a problem.
HTML_IMAGE_ONLY_XX 0.5–1.8 HTML with an image and very little text. _16, _24, _32 are the character counts.
HTML_IMAGE_RATIO_XX 0.5–1.5 Too much image area relative to text.
MPART_ALT_DIFF ~0.8 The text/plain and text/html parts say different things.
MPART_ALT_DIFF_COUNT ~0.3 Same, by link count.
HTML_FONT_LOW_CONTRAST ~0.7 Text coloured close to its background — hidden text.
HTML_FONT_SIZE_HUGE / _LARGE 0.1–0.7 Very large type.
T_KAM_HTML_FONT_INVALID ~0.01 Malformed <font> markup.
SUBJ_ALL_CAPS ~1.5 Subject is entirely uppercase.
SUBJECT_NEEDS_ENCODING ~1.5 Non-ASCII subject without RFC 2047 encoding.
URI_TRY_3LD / URI_NOVOWEL ~0.5–1.5 Suspicious-looking hostnames in links.
T_REMOTE_IMAGE ~0.01 A remotely loaded image; tracking pixels count.
BASE64_LENGTH_79_INF ~0.5 Base64 lines longer than 76 characters.
EMPTY_MESSAGE ~2.3 No body at all.
UPPERCASE_50_75 ~0.5 Half the text is capitals.

Three of these account for most of the template-side points on real transactional mail:

HTML_IMAGE_ONLY_* — a header image, a button, and one line of text. Give the message a real paragraph of text. The rule is measuring characters, not intent.

MPART_ALT_DIFF — the plain-text part was generated from an old version of the template, or is a placeholder like "Please view this email in an HTML client". Generate the text part from the same source as the HTML, and keep the links the same.

SUBJ_ALL_CAPS — URGENT: ACTION REQUIRED costs a point and a half by itself. The template team knows not to do this; the person who wrote the security-alert copy at 2 a.m. did not.

Bayes and the network tests

Bayes and the network tests
Rule Score Meaning
BAYES_00 … BAYES_99 -1.9 to +3.5 Bayesian classifier probability. Needs training; a fresh install has none.
RCVD_IN_* varies The sending IP or a URL's host is on a DNS blocklist.
URIBL_* 1.0–3.0 A URL in the body is on a URI blocklist.
DNS_FROM_* ~1.0 The From domain is on a blocklist.

Bayes results depend entirely on the installation's training corpus; a sandbox's SpamAssassin has no reason to have opinions about your mail, so BAYES_* will be neutral or absent there and may be significant at a receiver that has been trained on real traffic. Blocklist hits, on the other hand, transfer directly — if a link in your template points at a host on a URIBL, every receiver that checks will see it.

Reading a real result

A staging capture of a receipt email:

X-Spam-Status: No, score=4.6 required=5.0
    tests=HTML_IMAGE_ONLY_24,HTML_MESSAGE,MISSING_DATE,MPART_ALT_DIFF,
          RDNS_NONE,SUBJ_ALL_CAPS,T_REMOTE_IMAGE

Walking it:

  • SUBJ_ALL_CAPS (1.5) — the subject is YOUR RECEIPT FROM EXAMPLE. Template.
  • MISSING_DATE (1.4) — the queue worker serialises the message without a Date and the SMTP client does not add one. Mailer.
  • HTML_IMAGE_ONLY_24 (1.0) — the receipt is an image of a receipt. Template.
  • MPART_ALT_DIFF (0.8) — the text part is a stale template. Template.
  • RDNS_NONE (0.8) — staging host. Ignore in staging; verify in production.
  • HTML_MESSAGE, T_REMOTE_IMAGE — baseline.

Fix the subject, the date and the alternative part and the message is at 1.8 — with RDNS_NONE still in, which production will not have. Nothing about that required guessing; the list said what to do.

What "high but under threshold" means

Some tools, Mailclutch included, flag a message before it crosses 5.0. Our rule is at 60% of the threshold: a score above 3.0 is reported as a warning, because at that level one additional rule — a URL that lands on a blocklist next month, a Bayes bucket at a trained receiver — puts the message over. A score under 3.0 is a message with margin; a score over it is a message that is one bad day from the spam folder.

What the score is not

SpamAssassin is a test, not a receiver. Gmail, Outlook and Yahoo do not run it and their filters weigh things — sender reputation, engagement, complaint rate — that SpamAssassin cannot see. A message at 0.5 can still be filtered by a receiver that distrusts your domain; a message at 6.0 will probably be filtered by everyone.

The right use is as a lint step: a public, deterministic, explainable check that catches the mistakes with known fixes, run on every message your staging environment sends, so what reaches a real receiver has already had the cheap problems removed. Everything else is reputation, and reputation is a function of what you have sent before — which is one more reason to send it right.

Running it yourself

If you have a message file:

spamassassin -t < message.eml | tail -n 30

The -t flag appends the full test report with each rule's score and description. For a running spamd:

spamc -R < message.eml

Or send the message to a sandbox and read the same result next to the rendered preview, the headers and the authentication checks — which is the setup we recommend, because a check you have to remember to run is a check that gets skipped.

Related articles

Keep reading

All articles

Read about it here, check it on your staging.

See it on your own mail

Point your staging SMTP at Mailclutch and every message comes back with its headers, its spam score and its authentication results — before a customer sees it.

Free plan · no credit card · running in under a minute