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
+11 -1
View File
@@ -26,7 +26,17 @@ JobFunc = Callable[..., Awaitable[Any]]
# The frozen slot catalog (SPEC §11.3). Bundles are injected into these UI points.
SLOT_NAMES: frozenset[str] = frozenset(
{"thread.toolbar", "message.actions", "message.banner", "sidebar.section", "settings.page"}
{
"thread.toolbar",
"message.actions",
# Inside the per-message Actions dropdown, for plugin verbs that belong
# with Reply/Print/Delete rather than sitting on the header as a button.
# The host closes the menu after a click, so a bundle here just acts.
"message.actions.menu",
"message.banner",
"sidebar.section",
"settings.page",
}
)
_NAME_RE = re.compile(r"[a-z][a-z0-9_]*")
+1 -1
View File
@@ -181,7 +181,7 @@ async def test_stats_include_engine_state_when_it_is_up(ctx: _Ctx) -> None:
async def test_ui_bundles_are_served_and_gated(ctx: _Ctx) -> None:
for name in ("settings.js", "banner.js", "actions.js"):
for name in ("settings.js", "banner.js", "menu.js"):
r = await ctx.client_a.get(f"{BASE}/ui/{name}")
assert r.status_code == 200
assert "javascript" in r.headers["content-type"]
@@ -145,6 +145,7 @@ const LOCATION_LABEL: Record<string, string> = {
};
function ActionsMenu({
messageId,
onReply,
onPrint,
onDelete,
@@ -154,6 +155,7 @@ function ActionsMenu({
onToggleLabel,
onCreateLabel,
}: {
messageId: string;
onReply: (kind: string) => void;
onPrint: () => void;
onDelete: () => void;
@@ -277,6 +279,12 @@ function ActionsMenu({
)}
</div>
)}
{/* Plugin verbs that belong in the menu rather than on the header
(SPEC §11.3 message.actions.menu). Clicking anything inside closes
the menu, so a bundle only has to perform its action. */}
<div onClick={() => setOpen(false)}>
<PluginSlot name="message.actions.menu" props={{ messageId }} />
</div>
<div className="my-1 border-t border-app-border" />
{item(t("mail.delete"), onDelete)}
</div>
@@ -441,6 +449,7 @@ function MessageItem({
</button>
)}
<ActionsMenu
messageId={message.id}
onReply={onReply}
onPrint={onPrint}
onDelete={onDelete}
+13
View File
@@ -39,6 +39,19 @@
margin-top: 12px;
}
/* An item inside the core Actions dropdown (message.actions.menu). @applies the
EXACT utilities ThreadView's own menu items use, so a plugin verb is
indistinguishable from Reply or Delete rather than approximately similar. */
.wm-menu-item {
@apply block w-full px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover;
border: 0;
background: none;
cursor: pointer;
}
.wm-menu-item:disabled {
@apply cursor-default text-app-faint;
}
.wm-row {
display: flex;
flex-wrap: wrap;
@@ -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 = "";
};
}