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.
|
# The frozen slot catalog (SPEC §11.3). Bundles are injected into these UI points.
|
||||||
SLOT_NAMES: frozenset[str] = frozenset(
|
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_]*")
|
_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:
|
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}")
|
r = await ctx.client_a.get(f"{BASE}/ui/{name}")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "javascript" in r.headers["content-type"]
|
assert "javascript" in r.headers["content-type"]
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const LOCATION_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ActionsMenu({
|
function ActionsMenu({
|
||||||
|
messageId,
|
||||||
onReply,
|
onReply,
|
||||||
onPrint,
|
onPrint,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -154,6 +155,7 @@ function ActionsMenu({
|
|||||||
onToggleLabel,
|
onToggleLabel,
|
||||||
onCreateLabel,
|
onCreateLabel,
|
||||||
}: {
|
}: {
|
||||||
|
messageId: string;
|
||||||
onReply: (kind: string) => void;
|
onReply: (kind: string) => void;
|
||||||
onPrint: () => void;
|
onPrint: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -277,6 +279,12 @@ function ActionsMenu({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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" />
|
<div className="my-1 border-t border-app-border" />
|
||||||
{item(t("mail.delete"), onDelete)}
|
{item(t("mail.delete"), onDelete)}
|
||||||
</div>
|
</div>
|
||||||
@@ -441,6 +449,7 @@ function MessageItem({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<ActionsMenu
|
<ActionsMenu
|
||||||
|
messageId={message.id}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onPrint={onPrint}
|
onPrint={onPrint}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
|||||||
@@ -39,6 +39,19 @@
|
|||||||
margin-top: 12px;
|
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 {
|
.wm-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ plugin = Plugin(
|
|||||||
# Mounts on every message but renders only for a `banner` verdict —
|
# Mounts on every message but renders only for a `banner` verdict —
|
||||||
# a spam warning on ordinary mail is worse than no warning at all.
|
# 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.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()
|
router = APIRouter()
|
||||||
|
|
||||||
_UI = Path(__file__).resolve().parent / "ui"
|
_UI = Path(__file__).resolve().parent / "ui"
|
||||||
_BUNDLES = {"settings.js", "banner.js", "actions.js"}
|
_BUNDLES = {"settings.js", "banner.js", "menu.js"}
|
||||||
_SDK = sdk("antispam")
|
_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