diff --git a/.gitignore b/.gitignore index 117c18b..9ab5d28 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist/ server/data/ .vite/ coverage/ + +# Worktrees used by parallel agents; never part of a commit. +.claude/worktrees/ diff --git a/FEATURES.md b/FEATURES.md index f388498..f78ce3e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -261,6 +261,18 @@ same query string — so what it builds can be read, edited and learned from. - **Show original**, **Show headers**, **Download (.eml)** and **Print**. - **Unsubscribe** where the message carries `List-Unsubscribe`. - **Sender details** expand to the full From/To/Cc/Reply-To with addresses. +- **What the spam filter said** sits in those details, read back off the + message rather than scored here: the verdict, the score, the threshold it was + measured against, and the rules that moved it, largest mover first and signed + so which way each pushed is visible. Both the SpamAssassin-shaped `X-Spam-*` + set that Stalwart's own filter writes and Rspamd's `X-Spamd-Result` are read; + anything else is left alone rather than guessed at. Two things it 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 and the number alone is + not something a reader can act on — where no threshold was stated, it says + so; and where the filter recorded no verdict, none is invented from the score, + since the filter applies policy ihasmail cannot see. Mail that arrived without + these headers shows nothing. - **Message body theming** is off by default — sender HTML is left exactly as it was designed, on a light card. One setting lets mail that brings no colours of its own follow the app's theme instead. diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 9fe22dd..3ccd56c 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -128,6 +128,14 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, "header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? ", " : null, "header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null, + // Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so + // delivered mail carries it and mail this account wrote does not. + "header:X-Spam-Status:asText": + o.mailbox === "junk" + ? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no" + : o.mailbox === "inbox" + ? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]" + : null, }; emails.push(e); return e; diff --git a/web/src/lib/__tests__/spamScore.test.ts b/web/src/lib/__tests__/spamScore.test.ts new file mode 100644 index 0000000..8aee66c --- /dev/null +++ b/web/src/lib/__tests__/spamScore.test.ts @@ -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(); + }); +}); diff --git a/web/src/lib/spamScore.ts b/web/src/lib/spamScore.ts new file mode 100644 index 0000000..925e9b1 --- /dev/null +++ b/web/src/lib/spamScore.ts @@ -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>; + +/** + * 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" }; +} diff --git a/web/src/store/mail.ts b/web/src/store/mail.ts index 49c9680..d74ab14 100644 --- a/web/src/store/mail.ts +++ b/web/src/store/mail.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import type { FolderRef } from "@/lib/sieveFolders"; +import { SPAM_HEADER_PROPS } from "@/lib/spamScore"; import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate"; import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client"; import type { @@ -90,6 +91,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"]; diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 1d3509b..3100b0d 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1377,6 +1377,16 @@ button.dp-open:disabled { cursor: default; opacity: .5; } .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); } + /* The placeholder reference under a template's body. */ .placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; } .placeholder-row { display: contents; } diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index d269827..6b4421a 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -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] && <>
{translate("Message-ID")}
{e.messageId[0]}
} {e["header:List-Id:asText"] && <>
{translate("List")}
{e["header:List-Id:asText"]}
}
{translate("Size")}
{formatSize(e.size)}
+ {spam && <>
{translate("Spam filter")}
} {receiptRequested && <>
{translate("Receipt")}
{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}
} )} @@ -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 ( +
+
+ {verdict === "spam" && {translate("Marked as spam")}} + {verdict === "clean" && {translate("Not spam")}} + {verdict === null && {translate("No verdict recorded")}} + {score !== null && ( + + {" — "} + {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) })} + + )} +
+ {rules.length > 0 && ( +
    + {rules.map((r, i) => ( +
  • + {r.name} + {r.detail && {r.detail}} + {/* Signed, because which way a rule pushed is the whole point. */} + 0 ? "bad" : r.score < 0 ? "good" : ""}`}> + {r.score > 0 ? `+${r.score}` : String(r.score)} + +
  • + ))} +
+ )} +
+ ); +} + /* ---------- Attachments ---------- */ export function attachmentIcon(type: string, name?: string | null) {