diff --git a/backend/app/plugins/types.py b/backend/app/plugins/types.py index 9ec83ff..6e9f463 100644 --- a/backend/app/plugins/types.py +++ b/backend/app/plugins/types.py @@ -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_]*") diff --git a/backend/tests/plugins/test_antispam_api.py b/backend/tests/plugins/test_antispam_api.py index 97fdfd6..118c62d 100644 --- a/backend/tests/plugins/test_antispam_api.py +++ b/backend/tests/plugins/test_antispam_api.py @@ -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"] diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index bcca9cc..77f9ced 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -145,6 +145,7 @@ const LOCATION_LABEL: Record = { }; 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({ )} )} + {/* 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. */} +
setOpen(false)}> + +
{item(t("mail.delete"), onDelete)}
@@ -441,6 +449,7 @@ function MessageItem({ )} ${verdict === "spam" ? "Marked as spam" : "Marked as not spam"}`; - } - } 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 = ``; - 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 = `${learned === "spam" ? "Marked as spam" : "Marked as not spam"}`; - return; - } - render(current, false); - })(); - - return () => { - disposed = true; - el.innerHTML = ""; - }; -} diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js new file mode 100644 index 0000000..70ec52f --- /dev/null +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/menu.js @@ -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 = ``; + return; + } + + const action = MEANINGFUL[data.special_use]; + if (!action) return; + + el.innerHTML = ``; + 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 = ""; + }; +}