Compare commits
6
Commits
acd9cff4ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119548090e | ||
|
|
e4926cfa7d | ||
|
|
c0c892dd33 | ||
|
|
f5dd4e5537 | ||
|
|
e7ee09d228 | ||
|
|
1752276229 |
@@ -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
|
||||
* 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
|
||||
@@ -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");
|
||||
assert.equal(res.status, 200);
|
||||
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.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
assert.equal(res.body.capabilities["urn:inbuxa:jmap:registry"], undefined, "not where a client would first look");
|
||||
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 () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.
|
||||
* 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";
|
||||
/** Stalwart's id for a singleton object; the number it encodes spells this. */
|
||||
const SINGLETON = "singleton";
|
||||
|
||||
@@ -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 () => {
|
||||
// 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.
|
||||
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);
|
||||
@@ -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
|
||||
* 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
|
||||
* 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": {} };
|
||||
|
||||
test("a 0.16 server is recognized from primaryAccounts, where it advertises itself", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
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.APP_SECRET = "test-secret-for-login-guard";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../we
|
||||
|
||||
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
|
||||
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||
* support for it.
|
||||
|
||||
@@ -60,8 +60,8 @@ const session = () => ({
|
||||
* the only way this stays honest about what can be inferred from a
|
||||
* 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": {} }) } } },
|
||||
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 }) },
|
||||
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:inbuxa:jmap:registry": ACCOUNT }) },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
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.
|
||||
// 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:
|
||||
// `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.
|
||||
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
|
||||
const unknown = (body.using ?? []).find((u) => !known.has(u));
|
||||
|
||||
@@ -178,14 +178,14 @@ export function forgetUpstreamSession(sessionId: string): void {
|
||||
/* Account locale */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const STALWART_CAP = "urn:inbuxa:jmap:registry";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
|
||||
/**
|
||||
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
|
||||
* 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
|
||||
* 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
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
<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="manifest" href="/manifest.webmanifest" />
|
||||
<title>INBUXA</title>
|
||||
<title>inbuxa</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const CAP = {
|
||||
} as const;
|
||||
|
||||
/** 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. */
|
||||
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
|
||||
* 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
|
||||
* real 0.16 server as though it were older. Look in all three places.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -15,7 +15,7 @@ const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
||||
|
||||
function signIn(permissions: string[], username = "[email protected]") {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</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></>}
|
||||
</dl>
|
||||
)}
|
||||
@@ -425,6 +432,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
</div>
|
||||
)}
|
||||
<SignatureBanner state={signature} />
|
||||
{llmBanner && <LlmOpinionBanner opinion={llmBanner} />}
|
||||
{externalSender && (
|
||||
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user