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:
@@ -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 = "";
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user