Files
ihasmail/web/src/lib/spamScore.ts
T

147 lines
5.8 KiB
TypeScript

/**
* 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 toward 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" };
}