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