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.
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
| 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
| 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
| 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
| 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 isYOUR RECEIPT FROM EXAMPLE. Template.MISSING_DATE(1.4) — the queue worker serialises the message without aDateand 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 30The -t flag appends the full test report with each rule's score and description. For a running spamd:
spamc -R < message.emlOr 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.