Compare commits

...
8 Commits
Author SHA1 Message Date
jcoffey-dev 119548090e Merge pull request 'Show the language model's opinion on a message' (#15) from feature/llm-opinion into main
ci / version (push) Skipped
ci / node (push) Successful in 2m22s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 2m43s
Reviewed-on: #15
2026-09-23 05:39:19 +00:00
jcoffey-dev e4926cfa7d Show the language model's opinion on a message
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m23s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m24s
When inbuxa-server's AI spam classification is on, it records the model's
answer in an X-Spam-LLM header: a tag (LLM_<category>[_<confidence>]) 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.
2026-09-22 22:03:29 -07:00
jcoffey-dev c0c892dd33 Merge pull request 'Use the server's renamed registry capability, urn:inbuxa:jmap:registry' (#14) from fork/rename-upstream-identifiers into main
ci / version (push) Skipped
ci / node (push) Successful in 1m25s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 16s
Reviewed-on: #14
2026-09-23 04:25:12 +00:00
jcoffey-dev f5dd4e5537 Use the server's renamed registry capability, urn:inbuxa:jmap:registry
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m47s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 3m55s
inbuxa-server renames the identifiers that carried the upstream name (its
SPEC.md §2.4). Upstream's capability for the registry (x:) objects is now
urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap, which is
unchanged. There's no alias, so this lands with the server change and
deploys with it. The mock advertises the new name too. No user-visible
strings change.
2026-09-22 19:00:19 -07:00
jcoffey-dev e7ee09d228 Merge pull request 'Lowercase the name in the page title' (#13) from brand/lowercase-title into main
ci / version (push) Skipped
ci / node (push) Successful in 2m23s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 1m17s
2026-09-23 00:16:23 +00:00
jcoffey-dev 1752276229 Lowercase the name in the page title
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m37s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 40s
The browser tab, and anything that takes its name from the document title,
read INBUXA. The manifest, the server's app name and the sign-in card all
have it lowercase; the title was the one place left in caps.

Prod's APP_NAME override was set to inbuxa at the same time; the code
default already was.
2026-09-22 17:13:39 -07:00
jcoffey-dev acd9cff4ea Merge pull request 'Take the upstream name out of the mock's sample data' (#12) from mock/brand-sample-data into main
ci / version (push) Skipped
ci / node (push) Successful in 1m19s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 15s
2026-09-22 23:06:53 +00:00
jcoffey-dev 413ece3bca Take the upstream name out of the mock's sample data
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m16s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 33s
The mock inbox showed a sender called "Stalwart Labs" at [email protected],
a "Welcome to Stalwart!" subject, a link to stalw.art in the sample HTML
and a start-up banner tagged [mock-stalwart]. None of that belongs in this
fork, and it turns up in any screenshot taken from the mock.

Sample senders and subjects now name inbuxa, the sample link points at
inbuxa.org, and the banner says [mock-server].
2026-09-22 16:03:31 -07:00
28 changed files with 343 additions and 24 deletions
+3 -3
View File
@@ -47,7 +47,7 @@ after(() => {
}); });
/** /**
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the * Stalwart advertises `urn:inbuxa:jmap:registry` only per-account, never in the
* session-level capabilities. Looking for it at the top level alone reported * session-level capabilities. Looking for it at the top level alone reported
* every real 0.16 server as older than 0.16 — and now that the same check * every real 0.16 server as older than 0.16 — and now that the same check
* decides whether a sign-in is allowed at all, that mistake would lock * decides whether a sign-in is allowed at all, that mistake would lock
@@ -57,8 +57,8 @@ test("the session is accepted on a server that advertises the registry per-accou
const res = await call("/api/auth/session"); const res = await call("/api/auth/session");
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(res.body.ihasmail.server.edition, "oss"); assert.equal(res.body.ihasmail.server.edition, "oss");
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look"); assert.equal(res.body.capabilities["urn:inbuxa:jmap:registry"], undefined, "not where a client would first look");
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server"); assert.ok("urn:inbuxa:jmap:registry" in res.body.primaryAccounts, "but here, as on a real server");
}); });
test("the registry reports an account with nothing set up yet", async () => { test("the registry reports an account with nothing set up yet", async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.
* the registry is known to be there. * the registry is known to be there.
*/ */
const STALWART_CAP = "urn:stalwart:jmap"; const STALWART_CAP = "urn:inbuxa:jmap:registry";
const JMAP_CORE = "urn:ietf:params:jmap:core"; const JMAP_CORE = "urn:ietf:params:jmap:core";
/** Stalwart's id for a singleton object; the number it encodes spells this. */ /** Stalwart's id for a singleton object; the number it encodes spells this. */
const SINGLETON = "singleton"; const SINGLETON = "singleton";
+3 -3
View File
@@ -44,7 +44,7 @@ test("locales that carry no language are dropped, not passed through", () => {
test("a server without the registry is not asked for anything", async () => { test("a server without the registry is not asked for anything", async () => {
// Sign-in refuses these, so getAccountInfo should never reach the wire for // Sign-in refuses these, so getAccountInfo should never reach the wire for
// one - and must not, since a server that cannot parse `urn:stalwart:jmap` // one - and must not, since a server that cannot parse `urn:inbuxa:jmap:registry`
// fails the whole request rather than the one call. // fails the whole request rather than the one call.
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} }; const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
const info = await getAccountInfo("session-unsupported", "Basic x", session as never); const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
@@ -57,7 +57,7 @@ test("no capabilities at all is treated the same way", async () => {
}); });
/** /**
* Where Stalwart actually advertises `urn:stalwart:jmap`. * Where Stalwart actually advertises `urn:inbuxa:jmap:registry`.
* *
* Not in the session-level `capabilities`: `Session::new` builds those from a * Not in the session-level `capabilities`: `Session::new` builds those from a
* fixed list that has never carried this capability, in any 0.16.x. It is * fixed list that has never carried this capability, in any 0.16.x. It is
@@ -70,7 +70,7 @@ test("no capabilities at all is treated the same way", async () => {
* This check now decides whether a sign-in is allowed at all, so getting it * This check now decides whether a sign-in is allowed at all, so getting it
* wrong would lock every user out of a perfectly good server. * wrong would lock every user out of a perfectly good server.
*/ */
const STALWART = "urn:stalwart:jmap"; const STALWART = "urn:inbuxa:jmap:registry";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }; const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => { test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
+1 -1
View File
@@ -18,7 +18,7 @@ const PORT = 18799;
process.env.MOCK_PORT = String(PORT); process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]"; process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password"; process.env.MOCK_PASS = "demo-password";
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:inbuxa:jmap:registry
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`; process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-login-guard"; process.env.APP_SECRET = "test-secret-for-login-guard";
+1 -1
View File
@@ -5,7 +5,7 @@ export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../we
export const PORT = Number(process.env.MOCK_PORT ?? 8788); export const PORT = Number(process.env.MOCK_PORT ?? 8788);
/** /**
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested * Omit `urn:inbuxa:jmap:registry` from the session, so a sign-in can be tested
* against a server ihasmail does not support. This is only that: the rest of * against a server ihasmail does not support. This is only that: the rest of
* the mock still behaves like 0.16. Emulating 0.15 properly went with the * the mock still behaves like 0.16. Emulating 0.15 properly went with the
* support for it. * support for it.
+4 -4
View File
@@ -40,10 +40,10 @@ export function putBlob(data: Buffer | string, type: string): string {
export const people = [ export const people = [
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"], ["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"], ["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
["Stalwart Labs", "hello@stalw.art"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"], ["inbuxa", "hello@inbuxa.org"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
]; ];
export const subjects = [ export const subjects = [
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff", "Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to inbuxa!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?", "Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
"Reminder: dentist appointment", "Flight confirmation BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in", "Reminder: dentist appointment", "Flight confirmation BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
]; ];
@@ -169,8 +169,8 @@ export const STYLED_MARKETING_HTML = `<html><head><style>
export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) { export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
const id = `e${seq.counter++}`; const id = `e${seq.counter++}`;
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z"); const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`; const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag &amp; drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`; const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://inbuxa.org">Drag &amp; drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
const textBlob = putBlob(text, "text/plain"); const textBlob = putBlob(text, "text/plain");
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html"); const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
const attachments: Obj[] = []; const attachments: Obj[] = [];
+5 -5
View File
@@ -60,8 +60,8 @@ const session = () => ({
* the only way this stays honest about what can be inferred from a * the only way this stays honest about what can be inferred from a
* capability, which is nothing. * capability, which is nothing.
*/ */
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } }, accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) }, primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:inbuxa:jmap:registry": ACCOUNT }) },
username: USER, username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`, apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
@@ -103,7 +103,7 @@ export const server = createServer(async (req, res) => {
// call that wanted it - which is why an over-eager `using` is so damaging. // call that wanted it - which is why an over-eager `using` is so damaging.
// Stalwart decides this by parsing the urn, not by looking it up in the // Stalwart decides this by parsing the urn, not by looking it up in the
// session, so a capability it hands out per-account is still usable here: // session, so a capability it hands out per-account is still usable here:
// `urn:stalwart:jmap` never appears in the session-level capabilities and // `urn:inbuxa:jmap:registry` never appears in the session-level capabilities and
// the registry calls that name it work all the same. // the registry calls that name it work all the same.
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]); const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
const unknown = (body.using ?? []).find((u) => !known.has(u)); const unknown = (body.using ?? []).find((u) => !known.has(u));
@@ -204,8 +204,8 @@ export const server = createServer(async (req, res) => {
res.writeHead(404, { "content-type": "application/json" }); res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" })); res.end(JSON.stringify({ error: "not found" }));
}).listen(PORT, "127.0.0.1", () => { }).listen(PORT, "127.0.0.1", () => {
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`); console.log(`[mock-server] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
console.log(`[mock-stalwart] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`); console.log(`[mock-server] run the app with: MAIL_SERVER_URL=http://127.0.0.1:${PORT} npm run dev`);
}); });
// Periodically inject a new inbox email to demo push // Periodically inject a new inbox email to demo push
+2 -2
View File
@@ -178,14 +178,14 @@ export function forgetUpstreamSession(sessionId: string): void {
/* Account locale */ /* Account locale */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const STALWART_CAP = "urn:stalwart:jmap"; const STALWART_CAP = "urn:inbuxa:jmap:registry";
const JMAP_CORE = "urn:ietf:params:jmap:core"; const JMAP_CORE = "urn:ietf:params:jmap:core";
/** /**
* Whether this server has Stalwart's JMAP registry — the `x:` objects that * Whether this server has Stalwart's JMAP registry — the `x:` objects that
* carry credentials, account settings and the newer FileNode shape. * carry credentials, account settings and the newer FileNode shape.
* *
* `urn:stalwart:jmap` is the marker, but **not** in the session-level * `urn:inbuxa:jmap:registry` is the marker, but **not** in the session-level
* `capabilities`, which is where a JMAP client would naturally look. Stalwart * `capabilities`, which is where a JMAP client would naturally look. Stalwart
* builds that list from a fixed set that has never included this capability; * builds that list from a fixed set that has never included this capability;
* it hands it out per-account instead, so it turns up in `primaryAccounts` and * it hands it out per-account instead, so it turns up in `primaryAccounts` and
+1 -1
View File
@@ -23,7 +23,7 @@
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" /> <link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>INBUXA</title> <title>inbuxa</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -20,7 +20,7 @@ export const CAP = {
} as const; } as const;
/** Stalwart's own capability, which carries its `x:` registry methods. */ /** Stalwart's own capability, which carries its `x:` registry methods. */
export const STALWART_CAP = "urn:stalwart:jmap"; export const STALWART_CAP = "urn:inbuxa:jmap:registry";
/** INBUXA's own capability (contract C-1), on the signed-in account. */ /** INBUXA's own capability (contract C-1), on the signed-in account. */
export const INBUXA_CAP = "urn:inbuxa:jmap"; export const INBUXA_CAP = "urn:inbuxa:jmap";
@@ -155,7 +155,7 @@ export class JmapClient {
* Whether the server carries a capability at all, wherever it chose to * Whether the server carries a capability at all, wherever it chose to
* advertise it. * advertise it.
* *
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it * Stalwart hands `urn:inbuxa:jmap:registry` out per-account rather than putting it
* in the session-level `capabilities`, so `hasCapability` alone reports every * in the session-level `capabilities`, so `hasCapability` alone reports every
* real 0.16 server as though it were older. Look in all three places. * real 0.16 server as though it were older. Look in all three places.
*/ */
+2
View File
@@ -268,6 +268,8 @@ export interface Email {
"header:Received:asText:all"?: string[] | null; "header:Received:asText:all"?: string[] | null;
"header:X-Spam-Status:asText"?: string | null; "header:X-Spam-Status:asText"?: string | null;
"header:X-Spam-Result: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 { export interface Thread {
+52
View File
@@ -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: <TAG>`, 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();
});
});
+80
View File
@@ -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]);
}
+3
View File
@@ -1740,6 +1740,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben", "To confirm, type {phrase}": "Zur Bestätigung {phrase} eingeben",
"Turn off legacy protocols": "Ältere Mailprotokolle ausschalten", "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.", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1713,6 +1713,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Para confirmar, escriba {phrase}", "To confirm, type {phrase}": "Para confirmar, escriba {phrase}",
"Turn off legacy protocols": "Desactivar los protocolos de correo heredados", "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.", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1718,6 +1718,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}", "To confirm, type {phrase}": "Pour confirmer, saisissez {phrase}",
"Turn off legacy protocols": "Désactiver les protocoles de messagerie historiques", "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.", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1721,6 +1721,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "確認のため {phrase} と入力してください", "To confirm, type {phrase}": "確認のため {phrase} と入力してください",
"Turn off legacy protocols": "従来のメールプロトコルをオフにする", "Turn off legacy protocols": "従来のメールプロトコルをオフにする",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "組織では従来のメールプロトコルがオフになっています。サインインできるのは {app} と JMAP アプリのみです。", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1710,6 +1710,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Typ ter bevestiging {phrase}", "To confirm, type {phrase}": "Typ ter bevestiging {phrase}",
"Turn off legacy protocols": "Verouderde mailprotocollen uitschakelen", "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.", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1716,6 +1716,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Para confirmar, digite {phrase}", "To confirm, type {phrase}": "Para confirmar, digite {phrase}",
"Turn off legacy protocols": "Desativar os protocolos de e-mail legados", "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.", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1715,6 +1715,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Для подтверждения введите {phrase}", "To confirm, type {phrase}": "Для подтверждения введите {phrase}",
"Turn off legacy protocols": "Отключить устаревшие почтовые протоколы", "Turn off legacy protocols": "Отключить устаревшие почтовые протоколы",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Устаревшие почтовые протоколы отключены для вашей организации. Входить могут только {app} и приложения JMAP.", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1709,6 +1709,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "Для підтвердження введіть {phrase}", "To confirm, type {phrase}": "Для підтвердження введіть {phrase}",
"Turn off legacy protocols": "Вимкнути застарілі поштові протоколи", "Turn off legacy protocols": "Вимкнути застарілі поштові протоколи",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "Застарілі поштові протоколи вимкнено для вашої організації. Входити можуть лише {app} і програми JMAP.", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1720,6 +1720,9 @@ export const catalog: Catalog = {
"To confirm, type {phrase}": "请输入 {phrase} 以确认", "To confirm, type {phrase}": "请输入 {phrase} 以确认",
"Turn off legacy protocols": "关闭传统邮件协议", "Turn off legacy protocols": "关闭传统邮件协议",
"Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.": "您的组织已关闭传统邮件协议。只有 {app} 和 JMAP 应用可以登录。", "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: { plurals: {
// ── Administration: legacy mail protocols (INBUXA) ────────────── // ── Administration: legacy mail protocols (INBUXA) ──────────────
+3
View File
@@ -1,4 +1,5 @@
import { SPAM_HEADER_PROPS } from "@/lib/spamScore"; 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:Precedence:asText",
"header:Authentication-Results:asText", "header:Authentication-Results:asText",
...SPAM_HEADER_PROPS, ...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"]; export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
+7
View File
@@ -2387,6 +2387,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.spam-weight.bad { color: var(--danger); } .spam-weight.bad { color: var(--danger); }
.spam-weight.good { color: var(--success); } .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. */ /* 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; }
@@ -15,7 +15,7 @@ const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
function signIn(permissions: string[], username = "[email protected]") { function signIn(permissions: string[], username = "[email protected]") {
useSession.setState({ useSession.setState({
session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:stalwart:jmap": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession, session: { capabilities: {}, accounts: {}, primaryAccounts: { "urn:inbuxa:jmap:registry": "self" }, username, ihasmail: { permissions } } as unknown as JmapSession,
}); });
} }
+67
View File
@@ -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 (
<>
<strong>{opinion.category}</strong>
{opinion.confidence && <span className="hint">{` · ${opinion.confidence}`}</span>}
</>
);
}
/** In the message details, beside the spam filter's own working. */
export function LlmOpinionDetail({ opinion }: { opinion: LlmOpinion }) {
return (
<div className="llm-opinion">
<div>
<Verdict opinion={opinion} />
</div>
{opinion.explanation && <div className="llm-explanation">{opinion.explanation}</div>}
<div className="hint">{translate("One of several signals the spam filter weighed")}</div>
</div>
);
}
/** Above a message that's in Junk. */
export function LlmOpinionBanner({ opinion }: { opinion: LlmOpinion }) {
return (
<div className="remote-banner llm-banner" role="note" style={{ margin: "0 16px 8px" }}>
<Bot size={16} />
<span className="grow llm-opinion">
<span className="llm-heading">
<span>{translate("Language model's opinion")}</span>
<span>
<Verdict opinion={opinion} />
</span>
</span>
{opinion.explanation && <span className="llm-explanation">{opinion.explanation}</span>}
<span className="hint">{translate("One of several signals the spam filter weighed")}</span>
</span>
</div>
);
}
/** The banner shows only for a message in Junk that carries an opinion. */
export function llmBannerOpinion(
opinion: LlmOpinion | null,
mailboxIds: Record<string, boolean>,
junkId: string | null | undefined,
): LlmOpinion | null {
return opinion && junkId && mailboxIds[junkId] ? opinion : null;
}
+8
View File
@@ -15,6 +15,8 @@ import { emlFilename } from "@/lib/text/emlName";
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef"; import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings"; import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
import { spamReport, type SpamReport } from "@/lib/spamScore"; 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 { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, domainOf, formatAddress } from "@/lib/address"; import { displayName, domainOf, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html"; 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 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 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); const identities = useMail((st) => st.identities);
/* /*
* Only computed when the warning is on, because the domains it compares * 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"] && <><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></>} {spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>}
{llm && <><dt>{translate("Language model's opinion")}</dt><dd><LlmOpinionDetail opinion={llm} /></dd></>}
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}</dd></>} {receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}</dd></>}
</dl> </dl>
)} )}
@@ -425,6 +432,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
</div> </div>
)} )}
<SignatureBanner state={signature} /> <SignatureBanner state={signature} />
{llmBanner && <LlmOpinionBanner opinion={llmBanner} />}
{externalSender && ( {externalSender && (
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}> <div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
<ShieldAlert size={16} /> <ShieldAlert size={16} />
@@ -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(<LlmOpinionDetail opinion={opinion("LLM_UNSOLICITED_HIGH (Sells something unasked)")} />);
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(<LlmOpinionDetail opinion={opinion('LLM_HARMFUL_HIGH (<img src=x onerror="alert(1)"> <b>bold</b>)')} />);
expect(host.querySelector("img")).toBeNull();
expect(host.querySelector("b")).toBeNull();
expect(host.textContent).toContain('<img src=x onerror="alert(1)">');
});
it("leaves out what the header didn't carry", async () => {
await render(<LlmOpinionDetail opinion={opinion("LLM_LEGITIMATE")} />);
expect(host.querySelector(".llm-explanation")).toBeNull();
expect(host.textContent).not.toContain("·");
});
it("banners a message in Junk with the same framing", async () => {
await render(<LlmOpinionBanner opinion={opinion("LLM_UNSOLICITED_MEDIUM (Bulk newsletter)")} />);
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();
});
});