Files
webmail-client/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js
T
lukasandClaude Opus 5 6414504f6e Settings: a section per plugin, and call the folder Spam
Plugin settings pages were stacked at the bottom of Settings -> Accounts, which
put antispam's thresholds and lists under a heading about mail accounts. Each
`settings.page` now gets its own entry in the Settings nav: "Antispam filtering"
and "Mail filters".

Slot gains an optional `title` for this — the host needs a label when it gives a
slot an area of its own, and falls back to the plugin name. PluginSlot gains an
optional `plugin` filter so a host can render one plugin's bundle rather than
every binding for a slot name.

Wording: the app calls that folder Spam (i18n `nav.spam`), so the plugin's copy
now does too — "filed into Spam", "Move to Spam", "marked as spam". The IMAP
special-use stays `junk`, which is what the protocol calls it; only what the
user reads changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 08:19:11 +02:00

75 lines
2.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.
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;
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 {
v = await ctx.api.pluginFetch(`/verdict/${messageId}`);
} catch {
return; // no verdict service, no banner — never block the message
}
if (disposed || !v || v.action !== "banner") return;
const why = (v.symbols || []).slice(0, 4).join(", ");
el.innerHTML = `
<div class="wm-card" role="status">
<strong>Looks like spam</strong>
<span class="wm-muted">score ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}</span>
<div class="wm-row">
<button class="wm-btn" data-a="junk">Move to Spam</button>
<button class="wm-btn-ghost" data-a="ham">Not spam</button>
<button class="wm-btn-ghost" data-a="trust">Always trust sender</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 = "";
};
}