Files
webmail-client/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js
T
lukasandClaude Opus 5.5 d8e6cca66b Spam banner: inset card, red title, plain-language reasons, translated
The banner sits inside the message body's 16px gutter with room below,
"Looks like spam!" is red, and the rspamd symbols are replaced by up to
three human-readable reasons (neutral symbols are hidden). All strings go
through ctx.t with English and Polish catalog entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:39 +01:00

111 lines
4.7 KiB
JavaScript

// antispam · message.banner slot (SPEC §C.6). The "uncertain middle": scored
// above the banner threshold but below the move threshold, so it was delivered
// normally and the user gets a warning plus one click to resolve it either way.
//
// Renders NOTHING unless this message actually has a `banner` verdict — the slot
// mounts on every message, and a spam warning on ordinary mail is worse than no
// warning at all.
// Engine symbols mean nothing to a reader, so the banner explains only the ones
// that point AT spam, in plain words (app catalog keys antispam.why.*). Neutral
// or reassuring symbols (ARC_NA, DKIM_TRACE, R_SPF_ALLOW…) are not reasons and
// stay hidden. Ordered by how telling the reason is; first match wins per symbol.
const REASONS = [
[/^BAYES_SPAM$/, "bayes"],
[/^FUZZY_(DENIED|PROB|BLACK)/, "fuzzy"],
[/^PHISH(ING|ED)/, "phishing"],
[/^(PH_SURBL|DBL_PHISH|URIBL_|SURBL_|DBL_|SEM_URIBL|RSPAMD_URIBL|MSBL_)/, "badLinks"],
[/^(RBL_|RECEIVED_SPAMHAUS|RCVD_IN_(?!DNSWL)|RSPAMD_EMAILBL|MAILSPIKE_BAD)/, "blocklist"],
[/^(BAD_REP_|RWL_.*_BAD|SENDER_REP_SPAM|IP_REPUTATION_SPAM)/, "reputation"],
[/^(R_SPF_(FAIL|SOFTFAIL)|R_DKIM_REJECT|DMARC_POLICY_(REJECT|QUARANTINE|SOFTFAIL)|ARC_REJECT)$/, "authFail"],
[/^(FORGED_|SPOOF_)/, "forged"],
[/^(FREEMAIL_REPLYTO_NEQ_FROM|REPLYTO_DOM_NEQ_FROM_DOM|REPLYTO_ADDR_EQ_TO_ADDR)/, "replyTo"],
[/^(ZERO_FONT|MANY_INVISIBLE_PARTS|R_WHITE_ON_WHITE|HTML_SHORT_LINK_IMG)/, "hiddenText"],
[/^HFILTER_/, "suspiciousServer"],
[/^MISSING_(DATE|MID|FROM|TO)$/, "headers"],
[/^(DATE_IN_FUTURE|DATE_IN_PAST)$/, "date"],
[/^SUBJ_ALL_CAPS$/, "shouting"],
];
/** Distinct plain-language reasons for a verdict's symbols, most telling first. */
function reasons(symbols) {
const found = new Set();
for (const s of symbols || []) {
const hit = REASONS.find(([re]) => re.test(s));
if (hit) found.add(hit[1]);
}
return REASONS.map(([, key]) => key).filter((key) => found.has(key));
}
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
}
export default function mount(el, ctx, props) {
const messageId = props && props.messageId;
if (!messageId) return;
let disposed = false;
const t = ctx.t;
async function act(path, body) {
await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(body) });
window.dispatchEvent(new CustomEvent("webmail:antispam-learned"));
if (!disposed) el.innerHTML = "";
}
(async () => {
let v = null;
try {
const ctxData = await ctx.api.pluginFetch(`/context/${messageId}`);
v = ctxData && ctxData.verdict;
} catch {
return; // no verdict service, no banner — never block the message
}
if (disposed || !v || v.action !== "banner") return;
const why = reasons(v.symbols).slice(0, 3).map((key) => t(`antispam.why.${key}`));
const meta = [t("antispam.score", { score: Number(v.score).toFixed(1) }), ...why].join(" · ");
// Inset like the rest of the message body (px-4) with air below, so the card
// does not run edge to edge into the remote-images notice under it.
el.innerHTML = `
<div class="wm-card" role="status" style="margin: 0 16px 10px">
<strong style="color: rgb(239 68 68)">${esc(t("antispam.looksLikeSpam"))}</strong>
<span class="wm-muted">${esc(meta)}</span>
<div class="wm-row" style="margin-top: 6px">
<button class="wm-btn" data-a="junk">${esc(t("antispam.moveToSpam"))}</button>
<button class="wm-btn-ghost" data-a="ham">${esc(t("antispam.notSpam"))}</button>
<button class="wm-btn-ghost" data-a="trust">${esc(t("antispam.trustSender"))}</button>
</div>
</div>`;
el.querySelector('[data-a="junk"]').addEventListener("click", () =>
act("/learn", { message_id: messageId, verdict: "spam" }),
);
el.querySelector('[data-a="ham"]').addEventListener("click", () =>
act("/learn", { message_id: messageId, verdict: "ham" }),
);
el.querySelector('[data-a="trust"]').addEventListener("click", async () => {
// Allow-listing needs the sender, which the verdict does not carry.
let from = "";
try {
const msg = await ctx.api.fetch(`/messages/${messageId}`);
from = (msg && msg.from_addr && msg.from_addr[0] && msg.from_addr[0].email) || "";
} catch {
from = "";
}
if (from) {
await ctx.api.pluginFetch("/rules", {
method: "POST",
body: JSON.stringify({ kind: "allow", pattern: from }),
});
}
await act("/learn", { message_id: messageId, verdict: "ham" });
});
})();
return () => {
disposed = true;
el.innerHTML = "";
};
}