From e4926cfa7dd4414543433156ecbb87691c99dfb2 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 22:03:29 -0700 Subject: [PATCH] Show the language model's opinion on a message When inbuxa-server's AI spam classification is on, it records the model's answer in an X-Spam-LLM header: a tag (LLM_[_]) and, in parentheses, the model's explanation. The full message now asks for it, and where it's there: - the message details show "Language model's opinion" beside the spam filter's own working, with category, confidence and explanation; - a message in Junk carries a banner saying the same. Both say it's one of several signals the spam filter weighed, never the reason on its own, as the server's spec requires. The explanation is model output and is only ever rendered as text. Nothing shows without the header, so a server without the feature, or with it off, looks as before. Translations: two new strings, "Language model's opinion" and "One of several signals the spam filter weighed", in all eight catalogues (16 entries). Category and confidence come from the server and aren't translated. --- web/src/jmap/types.ts | 2 + web/src/lib/__tests__/llmOpinion.test.ts | 52 ++++++++++++ web/src/lib/llmOpinion.ts | 80 +++++++++++++++++++ web/src/locales/de.ts | 3 + web/src/locales/es.ts | 3 + web/src/locales/fr.ts | 3 + web/src/locales/ja.ts | 3 + web/src/locales/nl.ts | 3 + web/src/locales/pt-BR.ts | 3 + web/src/locales/ru.ts | 3 + web/src/locales/uk.ts | 3 + web/src/locales/zh-Hans.ts | 3 + web/src/store/mail/props.ts | 3 + web/src/styles/app.css | 7 ++ web/src/views/mail/LlmOpinion.tsx | 67 ++++++++++++++++ web/src/views/mail/MessageView.tsx | 8 ++ .../views/mail/__tests__/llm-opinion.test.tsx | 73 +++++++++++++++++ 17 files changed, 319 insertions(+) create mode 100644 web/src/lib/__tests__/llmOpinion.test.ts create mode 100644 web/src/lib/llmOpinion.ts create mode 100644 web/src/views/mail/LlmOpinion.tsx create mode 100644 web/src/views/mail/__tests__/llm-opinion.test.tsx diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index 9796008..1187411 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -268,6 +268,8 @@ export interface Email { "header:Received:asText:all"?: string[] | null; "header:X-Spam-Status:asText"?: string | null; "header:X-Spam-Result:asText"?: string | null; + /** inbuxa: the language model's opinion, when AI spam classification is on (lib/llmOpinion). */ + "header:X-Spam-LLM:asText"?: string | null; } export interface Thread { diff --git a/web/src/lib/__tests__/llmOpinion.test.ts b/web/src/lib/__tests__/llmOpinion.test.ts new file mode 100644 index 0000000..b2331ee --- /dev/null +++ b/web/src/lib/__tests__/llmOpinion.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { LLM_HEADER_PROP, llmOpinion, parseLlmOpinion } from "@/lib/llmOpinion"; + +/* + * The header as inbuxa-server writes it (crates/features/src/ai/answer.rs): + * `X-Spam-LLM: `, optionally followed by the explanation in one pair of + * parentheses, folded at 78 columns. + */ +describe("parseLlmOpinion", () => { + it("reads category, confidence and explanation", () => { + expect(parseLlmOpinion("LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about)")).toEqual({ + tag: "LLM_UNSOLICITED_HIGH", + category: "Unsolicited", + confidence: "High", + explanation: "Promotes a product the reader never asked about", + }); + }); + + it("reads a tag with no confidence and no explanation", () => { + expect(parseLlmOpinion("LLM_LEGITIMATE")).toEqual({ + tag: "LLM_LEGITIMATE", + category: "Legitimate", + confidence: null, + explanation: null, + }); + }); + + it("keeps an operator's multi-word category whole", () => { + const o = parseLlmOpinion("LLM_COLD_OUTREACH_MEDIUM"); + expect(o?.category).toBe("Cold outreach"); + expect(o?.confidence).toBe("Medium"); + // An unknown last word is part of the category, not a confidence. + expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.category).toBe("Cold outreach"); + expect(parseLlmOpinion("LLM_COLD_OUTREACH")?.confidence).toBeNull(); + }); + + it("unfolds a folded header and keeps inner parentheses", () => { + const o = parseLlmOpinion("LLM_HARMFUL_LOW (Asks for a password\r\n (urgently) via a link)"); + expect(o?.explanation).toBe("Asks for a password (urgently) via a link"); + }); + + it("returns null for anything that isn't the server's tag", () => { + for (const raw of [null, undefined, "", " ", "Yes, score=6.7", "LLM_", "llm_unsolicited_high", "X LLM_SPAM"]) { + expect(parseLlmOpinion(raw), String(raw)).toBeNull(); + } + }); + + it("reads the JMAP property a full message carries", () => { + expect(llmOpinion({ [LLM_HEADER_PROP]: "LLM_LEGITIMATE_HIGH" })?.category).toBe("Legitimate"); + expect(llmOpinion({})).toBeNull(); + }); +}); diff --git a/web/src/lib/llmOpinion.ts b/web/src/lib/llmOpinion.ts new file mode 100644 index 0000000..362b485 --- /dev/null +++ b/web/src/lib/llmOpinion.ts @@ -0,0 +1,80 @@ +/** + * inbuxa: the language model's opinion, read back off the message. + * + * When the server's AI spam classification is on (inbuxa-server, + * docs/spec/features/ai-spam-classification.md), it writes the model's answer + * into an `X-Spam-LLM` header at delivery: + * + * X-Spam-LLM: LLM_UNSOLICITED_HIGH (Promotes a product the reader never asked about) + * + * a tag, then optionally the model's explanation in parentheses. The tag is + * `LLM_` + category, or `LLM_` + category + `_` + confidence, uppercased with + * anything outside A-Z and 0-9 turned into `_`. The explanation is already + * sanitized by the server and may arrive as encoded words, which the JMAP + * `asText` form decodes. + * + * Like `spamScore`, nothing here judges anything: it only reads what the + * server wrote. It is one signal the spam filter weighed among many, and the + * UI says so. + */ + +/** The JMAP property that carries the header, decoded and unfolded. */ +export const LLM_HEADER_PROP = "header:X-Spam-LLM:asText" as const; + +/** + * Confidence words the fork's default prompt uses. A tag ending in one of + * these is read as category + confidence; anything else is all category, + * since an operator's own categories may contain underscores. + */ +const CONFIDENCES = new Set(["LOW", "MEDIUM", "HIGH"]); + +export interface LlmOpinion { + /** The tag as the server wrote it, e.g. `LLM_UNSOLICITED_HIGH`. */ + tag: string; + /** Readable category, e.g. `Unsolicited`. */ + category: string; + /** Readable confidence, e.g. `High`, where the tag carried one. */ + confidence: string | null; + /** The model's own explanation, as plain text, where there is one. */ + explanation: string | null; +} + +/** `UNSOLICITED_BULK` -> `Unsolicited bulk`. */ +function readable(words: string[]): string { + const s = words.join(" ").toLowerCase(); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** 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(); +} + +export function parseLlmOpinion(raw: string | null | undefined): LlmOpinion | null { + const s = flatten(raw); + const m = /^(LLM_[A-Z0-9_]+)(?:\s+(.*))?$/.exec(s); + if (!m) return null; + const tag = m[1]!; + const parts = tag.slice("LLM_".length).split("_").filter(Boolean); + if (parts.length === 0) return null; + + let confidence: string | null = null; + if (parts.length > 1 && CONFIDENCES.has(parts[parts.length - 1]!)) { + confidence = readable([parts.pop()!]); + } + + let explanation: string | null = null; + const rest = (m[2] ?? "").trim(); + if (rest) { + // The server wraps the explanation in one pair of parentheses. + const inner = rest.startsWith("(") && rest.endsWith(")") ? rest.slice(1, -1).trim() : rest; + explanation = inner || null; + } + + return { tag, category: readable(parts), confidence, explanation }; +} + +/** The opinion on a message, if the server recorded one. */ +export function llmOpinion(email: { [LLM_HEADER_PROP]?: string | null }): LlmOpinion | null { + return parseLlmOpinion(email[LLM_HEADER_PROP]); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index ecd35de..75992a2 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1740,6 +1740,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben", "Turn off legacy protocols": "Ältere Mailprotokolle ausschalten", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet. Nur {app} und JMAP-Apps können sich anmelden.", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "Einschätzung des Sprachmodells", + "One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index cec942b..0db2617 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1713,6 +1713,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Para confirmar, escriba {phrase}", "Turn off legacy protocols": "Desactivar los protocolos de correo heredados", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Los protocolos de correo heredados están desactivados para su organización. Solo {app} y las aplicaciones JMAP pueden iniciar sesión.", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "Opinión del modelo de lenguaje", + "One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 48cee04..5836f0a 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1718,6 +1718,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}", "Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Les protocoles de messagerie historiques sont désactivés pour votre organisation. Seuls {app} et les applications JMAP peuvent se connecter.", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "Avis du modèle de langage", + "One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index c7cdc07..dc4f986 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1721,6 +1721,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "確認のため {phrase} と入力してください", "Turn off legacy protocols": "従来のメールプロトコルをオフにする", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "言語モデルの見解", + "One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 6dc5666..9e73caa 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1710,6 +1710,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Typ ter bevestiging {phrase}", "Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie. Alleen {app} en JMAP-apps kunnen inloggen.", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "Oordeel van het taalmodel", + "One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 8bbf8ae..3dea47e 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1716,6 +1716,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Para confirmar, digite {phrase}", "Turn off legacy protocols": "Desativar os protocolos de e-mail legados", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Os protocolos de e-mail legados estão desativados para sua organização. Só {app} e aplicativos JMAP podem entrar.", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "Opinião do modelo de linguagem", + "One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 233fa51..2e31394 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1715,6 +1715,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Для подтверждения введите {phrase}", "Turn off legacy protocols": "Отключить устаревшие почтовые протоколы", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "Мнение языковой модели", + "One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index e87a019..98908ed 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1709,6 +1709,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "Для підтвердження введіть {phrase}", "Turn off legacy protocols": "Вимкнути застарілі поштові протоколи", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "Думка мовної моделі", + "One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index e9fafd8..850799a 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1720,6 +1720,9 @@ export const catalog: Catalog = { "To confirm, type {phrase}": "请输入 {phrase} 以确认", "Turn off legacy protocols": "关闭传统邮件协议", "Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。", + // ── Spam filter: the language model's opinion (inbuxa) ────────── + "Language model's opinion": "语言模型的判断", + "One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一", }, plurals: { // ── Administration: legacy mail protocols (INBUXA) ────────────── diff --git a/web/src/store/mail/props.ts b/web/src/store/mail/props.ts index 48f3c81..0ded0c2 100644 --- a/web/src/store/mail/props.ts +++ b/web/src/store/mail/props.ts @@ -1,4 +1,5 @@ import { SPAM_HEADER_PROPS } from "@/lib/spamScore"; +import { LLM_HEADER_PROP } from "@/lib/llmOpinion"; /* @@ -67,6 +68,8 @@ export const FULL_PROPS = [ "header:Precedence:asText", "header:Authentication-Results:asText", ...SPAM_HEADER_PROPS, + // inbuxa: the language model's opinion, when the server wrote one + LLM_HEADER_PROP, ]; 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 6cd9063..b6af138 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -2387,6 +2387,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; } .spam-weight.bad { color: var(--danger); } .spam-weight.good { color: var(--success); } +/* inbuxa: the language model's opinion, in the details and above a message in + Junk (views/mail/LlmOpinion.tsx). The explanation is the model's own words, + so it keeps its line breaks out and wraps rather than widening the pane. */ +.llm-opinion { display: flex; flex-direction: column; gap: 4px; } +.llm-heading { display: inline-flex; flex-wrap: wrap; gap: .4em; align-items: baseline; } +.llm-explanation { overflow-wrap: anywhere; } + /* 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/LlmOpinion.tsx b/web/src/views/mail/LlmOpinion.tsx new file mode 100644 index 0000000..3d9364f --- /dev/null +++ b/web/src/views/mail/LlmOpinion.tsx @@ -0,0 +1,67 @@ +import { Bot } from "lucide-react"; +import type { LlmOpinion } from "@/lib/llmOpinion"; +import { t as translate } from "@/lib/i18n"; + +/* + * inbuxa: the language model's opinion on a message, where the server's AI + * spam classification recorded one (lib/llmOpinion). + * + * Two rules, both from the server's spec. It is always labeled as one signal + * the spam filter weighed among several, never as the reason a message was + * filed where it was: the model can add a bounded amount to the score and no + * more. And the explanation is the model's own output, so it is only ever + * rendered as text. + * + * The category and confidence come from the server's configuration and aren't + * translated; only the two framing strings are. + */ + +function Verdict({ opinion }: { opinion: LlmOpinion }) { + return ( + <> + {opinion.category} + {opinion.confidence && {` · ${opinion.confidence}`}} + + ); +} + +/** In the message details, beside the spam filter's own working. */ +export function LlmOpinionDetail({ opinion }: { opinion: LlmOpinion }) { + return ( +
+
+ +
+ {opinion.explanation &&
{opinion.explanation}
} +
{translate("One of several signals the spam filter weighed")}
+
+ ); +} + +/** Above a message that's in Junk. */ +export function LlmOpinionBanner({ opinion }: { opinion: LlmOpinion }) { + return ( +
+ + + + {translate("Language model's opinion")} + + + + + {opinion.explanation && {opinion.explanation}} + {translate("One of several signals the spam filter weighed")} + +
+ ); +} + +/** The banner shows only for a message in Junk that carries an opinion. */ +export function llmBannerOpinion( + opinion: LlmOpinion | null, + mailboxIds: Record, + junkId: string | null | undefined, +): LlmOpinion | null { + return opinion && junkId && mailboxIds[junkId] ? opinion : null; +} diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 5aa22a4..d12f1bf 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -15,6 +15,8 @@ import { emlFilename } from "@/lib/text/emlName"; import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef"; import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings"; import { spamReport, type SpamReport } from "@/lib/spamScore"; +import { llmOpinion } from "@/lib/llmOpinion"; +import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "./LlmOpinion"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { displayName, domainOf, formatAddress } from "@/lib/address"; import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html"; @@ -187,6 +189,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn 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]); + // inbuxa: the language model's opinion, where the server's AI spam classification wrote one + const llm = useMemo(() => llmOpinion(e), [e]); + const junkId = useMail((st) => st.roleId("junk")); + const llmBanner = llmBannerOpinion(llm, e.mailboxIds, junkId); const identities = useMail((st) => st.identities); /* * Only computed when the warning is on, because the domains it compares @@ -374,6 +380,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn {e["header:List-Id:asText"] && <>
{translate("List")}
{e["header:List-Id:asText"]}
}
{translate("Size")}
{formatSize(e.size)}
{spam && <>
{translate("Spam filter")}
} + {llm && <>
{translate("Language model's opinion")}
} {receiptRequested && <>
{translate("Receipt")}
{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}
} )} @@ -425,6 +432,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn )} + {llmBanner && } {externalSender && (
diff --git a/web/src/views/mail/__tests__/llm-opinion.test.tsx b/web/src/views/mail/__tests__/llm-opinion.test.tsx new file mode 100644 index 0000000..74bf9ad --- /dev/null +++ b/web/src/views/mail/__tests__/llm-opinion.test.tsx @@ -0,0 +1,73 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "../LlmOpinion"; +import { parseLlmOpinion, type LlmOpinion } from "@/lib/llmOpinion"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/* + * The framing is the feature: the model's opinion is always one signal among + * several, never presented as why a message is where it is, and its + * explanation is model output, so it must never be rendered as markup. + */ +const opinion = (raw: string) => parseLlmOpinion(raw) as LlmOpinion; + +describe("the language model's opinion", () => { + let host: HTMLDivElement; + let root: Root; + + const render = async (node: React.ReactNode) => { + await act(async () => { + root.render(node); + }); + }; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + it("shows category, confidence and explanation, as one signal of several", async () => { + await render(); + expect(host.textContent).toContain("Unsolicited"); + expect(host.textContent).toContain("High"); + expect(host.textContent).toContain("Sells something unasked"); + expect(host.textContent).toContain("One of several signals the spam filter weighed"); + }); + + it("renders the explanation as text, never markup", async () => { + await render( bold)')} />); + expect(host.querySelector("img")).toBeNull(); + expect(host.querySelector("b")).toBeNull(); + expect(host.textContent).toContain(''); + }); + + it("leaves out what the header didn't carry", async () => { + await render(); + expect(host.querySelector(".llm-explanation")).toBeNull(); + expect(host.textContent).not.toContain("·"); + }); + + it("banners a message in Junk with the same framing", async () => { + await render(); + expect(host.textContent).toContain("Language model's opinion"); + expect(host.textContent).toContain("Unsolicited"); + expect(host.textContent).toContain("Bulk newsletter"); + expect(host.textContent).toContain("One of several signals the spam filter weighed"); + }); + + it("banners only a message that's in Junk and carries an opinion", () => { + const o = opinion("LLM_UNSOLICITED_HIGH"); + expect(llmBannerOpinion(o, { junk1: true }, "junk1")).toBe(o); + expect(llmBannerOpinion(o, { inbox1: true }, "junk1")).toBeNull(); + expect(llmBannerOpinion(o, { junk1: true }, null)).toBeNull(); + expect(llmBannerOpinion(null, { junk1: true }, "junk1")).toBeNull(); + }); +}); -- 2.54.0