Merge pull request #196 from Coffey-Labs/feat/spam-score-panel
Show what the spam filter said, in the message details
This commit is contained in:
@@ -6,3 +6,6 @@ dist/
|
|||||||
server/data/
|
server/data/
|
||||||
.vite/
|
.vite/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Worktrees used by parallel agents; never part of a commit.
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
+12
@@ -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**.
|
- **Show original**, **Show headers**, **Download (.eml)** and **Print**.
|
||||||
- **Unsubscribe** where the message carries `List-Unsubscribe`.
|
- **Unsubscribe** where the message carries `List-Unsubscribe`.
|
||||||
- **Sender details** expand to the full From/To/Cc/Reply-To with addresses.
|
- **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
|
- **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
|
was designed, on a light card. One setting lets mail that brings no colours of
|
||||||
its own follow the app's theme instead.
|
its own follow the app's theme instead.
|
||||||
|
|||||||
@@ -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] },
|
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") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
|
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
|
||||||
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : 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);
|
emails.push(e);
|
||||||
return e;
|
return e;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { FolderRef } from "@/lib/sieveFolders";
|
import type { FolderRef } from "@/lib/sieveFolders";
|
||||||
|
import { SPAM_HEADER_PROPS } from "@/lib/spamScore";
|
||||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||||
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
||||||
import type {
|
import type {
|
||||||
@@ -90,6 +91,7 @@ export const FULL_PROPS = [
|
|||||||
"header:Auto-Submitted:asText",
|
"header:Auto-Submitted:asText",
|
||||||
"header:Precedence:asText",
|
"header:Precedence:asText",
|
||||||
"header:Authentication-Results:asText",
|
"header:Authentication-Results:asText",
|
||||||
|
...SPAM_HEADER_PROPS,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
|
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
|
||||||
|
|||||||
@@ -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:hover { color: var(--accent); }
|
||||||
.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
.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. */
|
/* The placeholder reference under a template's body. */
|
||||||
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
|
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
|
||||||
.placeholder-row { display: contents; }
|
.placeholder-row { display: contents; }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useContacts } from "@/store/contacts";
|
|||||||
import { useCalendar } from "@/store/calendar";
|
import { useCalendar } from "@/store/calendar";
|
||||||
import { startAppointment } from "@/lib/appointment";
|
import { startAppointment } from "@/lib/appointment";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
|
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||||
import { displayName, formatAddress } from "@/lib/address";
|
import { displayName, formatAddress } from "@/lib/address";
|
||||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
|
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 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 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 authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
|
||||||
|
const spam = useMemo(() => spamReport(e), [e]);
|
||||||
|
|
||||||
const openSource = async () => {
|
const openSource = async () => {
|
||||||
setShowSource(true);
|
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.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></>}
|
{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>
|
<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></>}
|
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
|
||||||
</dl>
|
</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 ---------- */
|
/* ---------- Attachments ---------- */
|
||||||
|
|
||||||
export function attachmentIcon(type: string, name?: string | null) {
|
export function attachmentIcon(type: string, name?: string | null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user