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>
111 lines
4.7 KiB
JavaScript
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 = "";
|
|
};
|
|
}
|