Show what the spam filter said, in the message details

The filter in front of the mailbox scores every delivered message and
writes its working into headers, and none of it was being read. A message
in Junk gave no reason for being there.

Nothing here scores anything. The headers are parsed and shown, so this
cannot disagree with the filter that actually made the decision.

Two formats cover what sits in front of a Stalwart mailbox in practice:
the SpamAssassin-shaped X-Spam-* set, which Stalwart's own filter writes,
and Rspamd's X-Spamd-Result. A header in neither shape is left unread
rather than guessed at, since a misparsed score shown confidently is worse
than no panel at all. Mail that arrived without any of them shows nothing.

Rules are listed largest mover first and signed, because which way a rule
pushed is the point, and the biggest contributor is the answer to why the
message scored what it did.

Two things it deliberately will not do. A score is always given the
threshold it was measured against, because 6.7 is damning against 5 and
unremarkable against 15 -- the number alone is not something a reader can
act on; where no threshold was stated, it says so rather than implying
one. And where the filter recorded no verdict, none is derived from score
against threshold: the filter applies policy we cannot see, and putting a
verdict in its mouth would be inventing one.

The mock writes the same headers at delivery -- spam in Junk, clean in the
Inbox, nothing on mail this account wrote -- so the panel can be developed
and demoed against it.
This commit is contained in:
2026-09-01 21:45:22 -07:00
parent 34578ba426
commit 63c2839602
7 changed files with 335 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { spamReport } from "@/lib/spamScore";
const sa = (v: string) => spamReport({ "header:X-Spam-Status:asText": v });
const rs = (v: string) => spamReport({ "header:X-Spamd-Result:asText": v });
describe("spamReport, SpamAssassin-shaped headers", () => {
it("reads the verdict, score, threshold and tests", () => {
const r = sa("Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001, URIBL=2.2] autolearn=no");
expect(r).not.toBeNull();
expect(r!.verdict).toBe("spam");
expect(r!.score).toBe(6.7);
expect(r!.threshold).toBe(5);
expect(r!.source).toBe("spamassassin");
// Biggest mover first, so the reason it was scored reads off the top.
expect(r!.rules.map((x) => x.name)).toEqual(["BAYES_99", "URIBL", "HTML_MESSAGE"]);
});
it("reads a negative score and a clean verdict", () => {
const r = sa("No, score=-2.6 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7]");
expect(r!.verdict).toBe("clean");
expect(r!.score).toBe(-2.6);
expect(r!.rules[0]).toEqual({ name: "BAYES_00", score: -1.9 });
});
it("survives a folded header, which is how they arrive", () => {
const r = sa("Yes, score=6.7\n\trequired=5.0 tests=[BAYES_99=3.5,\n\tURIBL=2.2]");
expect(r!.score).toBe(6.7);
expect(r!.rules).toHaveLength(2);
});
it("keeps a verdict that states no score, and a score that states no verdict", () => {
expect(sa("Yes")!.verdict).toBe("spam");
expect(sa("Yes")!.score).toBeNull();
const scoreOnly = sa("score=1.2 required=5.0");
expect(scoreOnly!.verdict).toBeNull();
expect(scoreOnly!.score).toBe(1.2);
});
it("says nothing when there is nothing it understands", () => {
expect(sa("")).toBeNull();
expect(sa("something else entirely")).toBeNull();
expect(spamReport({})).toBeNull();
});
it("drops a malformed test rather than scoring it as zero", () => {
const r = sa("Yes, score=3.0 tests=[GOOD=1.0, BROKEN=, =2.0, ALSO_GOOD=2.0]");
expect(r!.rules.map((x) => x.name)).toEqual(["ALSO_GOOD", "GOOD"]);
});
});
describe("spamReport, Rspamd", () => {
it("reads the action, score, threshold and rules with their notes", () => {
const r = rs("default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[example.com]; SUBJ_CAPS(2.00)[]");
expect(r!.verdict).toBe("clean");
expect(r!.score).toBe(1.2);
expect(r!.threshold).toBe(15);
expect(r!.source).toBe("rspamd");
expect(r!.rules[0]).toEqual({ name: "SUBJ_CAPS", score: 2 });
expect(r!.rules.find((x) => x.name === "DKIM_ALLOW")?.detail).toBe("example.com");
// An empty bracket is not a note.
expect(r!.rules[0]!.detail).toBeUndefined();
});
it("treats the acting verdicts as spam and False as clean", () => {
expect(rs("default: True [20.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: reject [20.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: add_header [16.00 / 15.00];")!.verdict).toBe("spam");
expect(rs("default: False [1.00 / 15.00];")!.verdict).toBe("clean");
});
it("declines to call greylisting a verdict about the message", () => {
const r = rs("default: greylist [8.00 / 15.00];");
expect(r!.verdict).toBeNull();
expect(r!.score).toBe(8);
});
it("says nothing for a header it cannot read", () => {
expect(rs("")).toBeNull();
expect(rs("default: False")).toBeNull();
});
});
describe("spamReport, precedence and fallback", () => {
it("prefers the SpamAssassin set, which is what Stalwart's own filter writes", () => {
const r = spamReport({
"header:X-Spam-Status:asText": "Yes, score=6.7 required=5.0",
"header:X-Spamd-Result:asText": "default: False [1.20 / 15.00];",
});
expect(r!.source).toBe("spamassassin");
expect(r!.verdict).toBe("spam");
});
it("falls back to a bare score, with no threshold to read it against", () => {
const r = spamReport({ "header:X-Spam-Score:asText": "+4.1" });
expect(r!.score).toBe(4.1);
expect(r!.threshold).toBeNull();
expect(r!.verdict).toBeNull();
expect(r!.rules).toEqual([]);
});
it("does not invent a verdict from score against threshold", () => {
// Above the threshold, but the filter did not say "Yes" -- so neither do we.
const r = sa("score=9.9 required=5.0 tests=[X=9.9]");
expect(r!.verdict).toBeNull();
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* What the spam filter in front of the mailbox already said, read back off the
* message.
*
* Nothing here scores anything. The server has already done that at delivery
* and written its working into headers; this only reads them, which is why it
* costs nothing and cannot disagree with the filter that actually made the
* decision.
*
* Two formats cover what sits in front of a Stalwart mailbox in practice: the
* SpamAssassin-shaped `X-Spam-*` set, which Stalwart's own filter writes, and
* Rspamd's `X-Spamd-Result`. Anything else is left unread rather than guessed
* at -- a misparsed score shown confidently is worse than no panel.
*
* The one editorial decision: **a score is not shown without its threshold
* where the header states one.** 6.7 is damning against a threshold of 5 and
* unremarkable against 15, so the number alone is not something a reader can
* act on. Where no threshold is stated, that is said rather than assumed.
*/
export interface SpamRule {
/** The rule's own name, as the filter wrote it. */
name: string;
/** What it contributed. Negative moves the message towards clean. */
score: number;
/** Rspamd's bracketed note, where there is one. */
detail?: string;
}
export interface SpamReport {
/**
* What the filter concluded, and only where it said so outright. Left `null`
* rather than derived from score against threshold: the filter applies
* policy we cannot see, and inventing a verdict it did not state would be
* putting words in its mouth.
*/
verdict: "spam" | "clean" | null;
score: number | null;
threshold: number | null;
/** Biggest movers first; ties keep the order the filter listed them in. */
rules: SpamRule[];
source: "spamassassin" | "rspamd";
}
/** Headers requested for a full message; kept beside the parser that reads them. */
export const SPAM_HEADER_PROPS = [
"header:X-Spam-Status:asText",
"header:X-Spam-Score:asText",
"header:X-Spamd-Result:asText",
] as const;
/** Headers arrive folded, so tabs and newlines are whitespace like any other. */
function flatten(v: string | null | undefined): string {
return (v ?? "").replace(/\s+/g, " ").trim();
}
function num(raw: string | undefined): number | null {
if (raw === undefined) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
/** Biggest absolute contribution first: what moved the message, in order. */
function byWeight(rules: SpamRule[]): SpamRule[] {
return [...rules].sort((a, b) => Math.abs(b.score) - Math.abs(a.score));
}
/**
* `Yes, score=6.7 required=5.0 tests=[BAYES_99=3.5, HTML_MESSAGE=0.001]`
* The verdict word is the only part guaranteed to be there.
*/
function parseSpamAssassin(raw: string): SpamReport | null {
const s = flatten(raw);
if (!s) return null;
const verdictWord = /^(yes|no)\b/i.exec(s);
const score = num(/\bscore=(-?[\d.]+)/i.exec(s)?.[1]);
const threshold = num(/\b(?:required|require)=(-?[\d.]+)/i.exec(s)?.[1]);
// Nothing usable: not a header we understand, so say so by returning null
// rather than rendering an empty panel.
if (!verdictWord && score === null) return null;
const rules: SpamRule[] = [];
const tests = /\btests=\[([^\]]*)\]/i.exec(s)?.[1];
if (tests) {
for (const part of tests.split(",")) {
const m = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(-?[\d.]+)\s*$/.exec(part);
const value = num(m?.[2]);
if (m?.[1] && value !== null) rules.push({ name: m[1], score: value });
}
}
return {
verdict: verdictWord ? (verdictWord[1]!.toLowerCase() === "yes" ? "spam" : "clean") : null,
score,
threshold,
rules: byWeight(rules),
source: "spamassassin",
};
}
/**
* `default: False [1.20 / 15.00]; MIME_GOOD(-0.10)[text/plain]; DKIM_ALLOW(-0.20)[]`
* The action word before the brackets is the verdict; `False` is not spam.
*/
function parseRspamd(raw: string): SpamReport | null {
const s = flatten(raw);
if (!s) return null;
const head = /^[^:]*:\s*(\S+)\s*\[\s*(-?[\d.]+)\s*\/\s*(-?[\d.]+)\s*\]/.exec(s);
if (!head) return null;
const action = head[1]!.toLowerCase();
const rules: SpamRule[] = [];
// Each rule after the head, `NAME(score)` with an optional bracketed note.
for (const m of s.matchAll(/([A-Z][A-Z0-9_]*)\(\s*(-?[\d.]+)\s*\)(?:\[([^\]]*)\])?/g)) {
const value = num(m[2]);
if (value === null) continue;
const detail = m[3]?.trim();
rules.push(detail ? { name: m[1]!, score: value, detail } : { name: m[1]!, score: value });
}
return {
// "False" is Rspamd saying the message is not spam; "True", and the named
// actions that reject or bin it, are it saying the opposite. Anything else
// -- greylisting, for one -- is not a verdict about the message.
verdict: action === "false" ? "clean" : action === "true" || action === "reject" || action === "add_header" || action === "rewrite_subject" ? "spam" : null,
score: num(head[2]),
threshold: num(head[3]),
rules: byWeight(rules),
source: "rspamd",
};
}
type HeaderBag = Partial<Record<(typeof SPAM_HEADER_PROPS)[number], string | null>>;
/**
* Read whichever of the two the message carries, preferring the SpamAssassin
* set because it is what Stalwart's own filter writes; a message that has been
* through both keeps the nearer verdict.
*/
export function spamReport(e: HeaderBag): SpamReport | null {
const sa = parseSpamAssassin(e["header:X-Spam-Status:asText"] ?? "");
if (sa) return sa;
const rspamd = parseRspamd(e["header:X-Spamd-Result:asText"] ?? "");
if (rspamd) return rspamd;
// Last resort: a bare score with nothing to read it against. Worth showing,
// because the alternative is hiding the only thing the filter said.
const bare = num(flatten(e["header:X-Spam-Score:asText"]).replace(/^\+/, "") || undefined);
if (bare === null) return null;
return { verdict: null, score: bare, threshold: null, rules: [], source: "spamassassin" };
}
+2
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import type { FolderRef } from "@/lib/sieveFolders";
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
import type {
Comparator,
@@ -89,6 +90,7 @@ export const FULL_PROPS = [
"header:Auto-Submitted:asText",
"header:Precedence:asText",
"header:Authentication-Results:asText",
...SPAM_HEADER_PROPS,
];
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
+10
View File
@@ -1376,3 +1376,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
.composer-field label .link-btn:hover { color: var(--accent); }
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
/* The spam filter's own working, in the message details. */
.spam-summary { display: flex; flex-direction: column; gap: 6px; }
/* Capped, so a rule with no note does not fling its score to the far side of a
wide reading pane. */
.spam-rules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-width: 30rem; }
.spam-rules li { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: baseline; }
.spam-weight { font-family: var(--font-mono); font-size: 12px; text-align: right; color: var(--fg-muted); }
.spam-weight.bad { color: var(--danger); }
.spam-weight.good { color: var(--success); }
+50
View File
@@ -10,6 +10,7 @@ import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client";
import { spamReport, type SpamReport } from "@/lib/spamScore";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
@@ -112,6 +113,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const isHighPriority = /^[12]/.test(e["header:X-Priority:asText"] ?? "") || /high/i.test(e["header:Importance:asText"] ?? "");
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
const spam = useMemo(() => spamReport(e), [e]);
const openSource = async () => {
setShowSource(true);
@@ -265,6 +267,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{e.messageId?.[0] && <><dt>{translate("Message-ID")}</dt><dd className="mono small">{e.messageId[0]}</dd></>}
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
{spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>}
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
</dl>
)}
@@ -552,6 +555,53 @@ function TextBody({ text }: { text: string }) {
);
}
/* ---------- Spam ---------- */
/**
* What the filter said, not what we think of it. The verdict line only claims
* as much as the header did: where the filter stated one, it is shown; where
* it only left a score, the score is shown on its own rather than being turned
* into a verdict here.
*
* A score is always given its threshold where the header carried one, because
* the number is unreadable without it -- 6.7 is damning against 5 and
* unremarkable against 15. Where none was stated, that is said.
*/
function SpamSummary({ report }: { report: SpamReport }) {
const { verdict, score, threshold, rules } = report;
return (
<div className="spam-summary">
<div>
{verdict === "spam" && <strong>{translate("Marked as spam")}</strong>}
{verdict === "clean" && <strong>{translate("Not spam")}</strong>}
{verdict === null && <strong>{translate("No verdict recorded")}</strong>}
{score !== null && (
<span className="hint">
{" — "}
{threshold !== null
? translate("scored {score} against a threshold of {threshold}", { score: String(score), threshold: String(threshold) })
: translate("scored {score}, with no threshold stated", { score: String(score) })}
</span>
)}
</div>
{rules.length > 0 && (
<ul className="spam-rules">
{rules.map((r, i) => (
<li key={`${r.name}-${i}`}>
<span className="mono small">{r.name}</span>
{r.detail && <span className="hint truncate">{r.detail}</span>}
{/* Signed, because which way a rule pushed is the whole point. */}
<span className={`spam-weight ${r.score > 0 ? "bad" : r.score < 0 ? "good" : ""}`}>
{r.score > 0 ? `+${r.score}` : String(r.score)}
</span>
</li>
))}
</ul>
)}
</div>
);
}
/* ---------- Attachments ---------- */
export function attachmentIcon(type: string, name?: string | null) {