Antispam: "Mark as Spam" belongs in the Actions menu

A button pinned to every message header is a persistent control; marking spam is
a verb you reach for occasionally, like Reply or Delete. It now lives in the
Actions menu, between Labels and Delete.

That needed a slot inside the dropdown, which the catalog had no name for:
`message.actions.menu`. The host wraps it so any click closes the menu, leaving
a bundle to do nothing but act.

The UI kit gains `.wm-menu-item`, @applying the exact utilities ThreadView's own
menu items use — the same trick the sidebar shell already relies on, so a plugin
verb is indistinguishable from a core one rather than approximately similar.

Labels follow the folder as before: "Mark as Spam" in the inbox or archive,
"Mark as not spam" in the spam folder, nothing in Sent/Drafts/Trash. A message
already taught shows that, disabled, instead of offering the lesson again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-17 19:36:29 +02:00
co-authored by Claude Opus 5
parent e420126145
commit c76b186b10
8 changed files with 96 additions and 84 deletions
@@ -56,7 +56,9 @@ plugin = Plugin(
# Mounts on every message but renders only for a `banner` verdict —
# a spam warning on ordinary mail is worse than no warning at all.
Slot(name="message.banner", bundle=f"{_UI}/banner.js"),
Slot(name="message.actions", bundle=f"{_UI}/actions.js"),
# In the Actions menu, not as a header button: it is a verb like
# Reply or Delete, not a persistent control.
Slot(name="message.actions.menu", bundle=f"{_UI}/menu.js"),
],
),
)
@@ -31,7 +31,7 @@ from webmail_plugin_antispam.schemas import (
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
_BUNDLES = {"settings.js", "banner.js", "actions.js"}
_BUNDLES = {"settings.js", "banner.js", "menu.js"}
_SDK = sdk("antispam")
@@ -1,80 +0,0 @@
// antispam · message.actions slot (SPEC §C.6). Explicit teaching: the strongest
// training signal the engine gets, because the user is telling us about *this*
// message rather than adjusting a threshold.
//
// Only ever one button, and only where it means something. "Not spam" on an
// inbox message is nonsense — it was never filed as spam — and a pair of
// buttons on every message in every thread is noise that trains nobody.
const MEANINGFUL = {
junk: { verdict: "ham", label: "Not spam", title: "Move back to Inbox and teach the filter" },
inbox: { verdict: "spam", label: "Spam", title: "Move to Spam and teach the filter" },
archive: { verdict: "spam", label: "Spam", title: "Move to Spam and teach the filter" },
// Sent, Drafts and Trash deliberately absent: nothing here is a spam verdict.
};
export default function mount(el, ctx, props) {
const messageId = props && props.messageId;
if (!messageId) return;
let disposed = false;
let busy = false;
async function teach(verdict) {
if (busy) return;
busy = true;
render(current, true);
try {
await ctx.api.pluginFetch("/learn", {
method: "POST",
body: JSON.stringify({ message_id: messageId, verdict }),
});
window.dispatchEvent(new CustomEvent("webmail:antispam-learned"));
if (!disposed) {
el.innerHTML = `<span class="wm-faint">${verdict === "spam" ? "Marked as spam" : "Marked as not spam"}</span>`;
}
} catch {
busy = false;
render(current, false);
}
}
let current = null;
function render(action, pending) {
if (!action) {
el.innerHTML = "";
return;
}
el.style.display = "inline-flex";
el.style.gap = "4px";
el.style.alignItems = "center";
el.innerHTML = `<button class="wm-btn-ghost" title="${action.title}"${pending ? " disabled" : ""}>${action.label}</button>`;
el.querySelector("button").addEventListener("click", (e) => {
e.stopPropagation();
teach(action.verdict);
});
}
(async () => {
let ctxData = null;
try {
ctxData = await ctx.api.pluginFetch(`/context/${messageId}`);
} catch {
return; // no antispam service, no buttons
}
if (disposed) return;
current = MEANINGFUL[ctxData && ctxData.special_use] || null;
// Already taught? Say so instead of offering the same lesson again.
const learned = ctxData && ctxData.verdict && ctxData.verdict.learned;
if (learned) {
el.innerHTML = `<span class="wm-faint">${learned === "spam" ? "Marked as spam" : "Marked as not spam"}</span>`;
return;
}
render(current, false);
})();
return () => {
disposed = true;
el.innerHTML = "";
};
}
@@ -0,0 +1,58 @@
// antispam · message.actions.menu slot (SPEC §C.6). The teaching verb, in the
// Actions menu beside Reply and Delete, where a per-message action belongs.
//
// One item, and only where it means something: "Mark as Spam" in the inbox,
// "Mark as not spam" in the spam folder, nothing in Sent/Drafts/Trash where no
// move is a verdict about spam. The host closes the menu after a click.
const MEANINGFUL = {
junk: { verdict: "ham", label: "Mark as not spam" },
inbox: { verdict: "spam", label: "Mark as Spam" },
archive: { verdict: "spam", label: "Mark as Spam" },
};
export default function mount(el, ctx, props) {
const messageId = props && props.messageId;
if (!messageId) return;
let disposed = false;
(async () => {
let data = null;
try {
data = await ctx.api.pluginFetch(`/context/${messageId}`);
} catch {
return; // no antispam service, no menu item
}
if (disposed || !data) return;
// Already taught: show it, greyed, rather than offering the same lesson.
const learned = data.verdict && data.verdict.learned;
if (learned) {
el.innerHTML = `<button class="wm-menu-item" disabled>${
learned === "spam" ? "Marked as spam" : "Marked as not spam"
}</button>`;
return;
}
const action = MEANINGFUL[data.special_use];
if (!action) return;
el.innerHTML = `<button class="wm-menu-item">${action.label}</button>`;
el.querySelector("button").addEventListener("click", async () => {
try {
await ctx.api.pluginFetch("/learn", {
method: "POST",
body: JSON.stringify({ message_id: messageId, verdict: action.verdict }),
});
window.dispatchEvent(new CustomEvent("webmail:antispam-learned"));
} catch {
/* the menu is closing regardless; nothing useful to show here */
}
});
})();
return () => {
disposed = true;
el.innerHTML = "";
};
}