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