diff --git a/backend/tests/plugins/test_antispam_api.py b/backend/tests/plugins/test_antispam_api.py index 26ef9e6..97fdfd6 100644 --- a/backend/tests/plugins/test_antispam_api.py +++ b/backend/tests/plugins/test_antispam_api.py @@ -147,8 +147,12 @@ async def test_rls_isolation_between_users(ctx: _Ctx) -> None: assert len((await ctx.client_a.get(f"{BASE}/rules")).json()) == 1 -async def test_verdict_absent_for_unclassified_message(ctx: _Ctx) -> None: - assert (await ctx.client_a.get(f"{BASE}/verdict/{uuid.uuid4()}")).json() is None +async def test_context_for_an_unknown_message(ctx: _Ctx) -> None: + """The slots ask about every message they mount on, including ones we have + never classified — that must answer, not 404.""" + body = (await ctx.client_a.get(f"{BASE}/context/{uuid.uuid4()}")).json() + assert body["verdict"] is None + assert body["special_use"] is None # not this user's message: no folder to report async def test_stats_work_without_an_engine(ctx: _Ctx, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/router.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/router.py index 8a816f6..943bdee 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/router.py +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/router.py @@ -19,6 +19,7 @@ from app.plugins.sdk import CurrentUserId, require_csrf, sdk from webmail_plugin_antispam import policy, rspamd from webmail_plugin_antispam.schemas import ( LearnIn, + MessageContextOut, RuleIn, RuleOut, SettingsIn, @@ -171,11 +172,25 @@ async def delete_rule(rule_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, s # --- verdicts, learning, stats ---------------------------------------------- -@router.get("/verdict/{message_id}", response_model=VerdictOut | None) -async def get_verdict( +@router.get("/context/{message_id}", response_model=MessageContextOut) +async def get_context( message_id: uuid.UUID, user_id: CurrentUserId -) -> VerdictOut | None: - """What the banner slot asks before deciding whether to render anything.""" +) -> MessageContextOut: + """Verdict + current folder for the per-message slots, in one round-trip. + + The folder is what decides which action is worth showing: "Spam" means + something in the inbox, "Not spam" means something in the spam folder, and + neither belongs on a message in Sent or Drafts. + """ + env = await _SDK.messages.envelope(user_id, message_id) + return MessageContextOut( + message_id=message_id, + special_use=(env or {}).get("mailbox_special_use"), + verdict=await _load_verdict(user_id, message_id), + ) + + +async def _load_verdict(user_id: uuid.UUID, message_id: uuid.UUID) -> VerdictOut | None: async with _SDK.session(user_id) as s: row = ( await s.execute( diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/schemas.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/schemas.py index 7c9fafc..4ba4528 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/schemas.py +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/schemas.py @@ -54,6 +54,19 @@ class VerdictOut(BaseModel): learned: str | None +class MessageContextOut(BaseModel): + """What the per-message UI slots need in one round-trip. + + ``special_use`` is where the message currently sits, which decides which + action is meaningful: "Spam" in the inbox, "Not spam" in the spam folder, + neither in Sent or Drafts. + """ + + message_id: uuid.UUID + special_use: str | None + verdict: VerdictOut | None + + class StatsOut(BaseModel): counts: dict[str, int] top_symbols: list[dict[str, object]] diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js index 9c0d2b9..d1e3ede 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js @@ -1,50 +1,80 @@ -// antispam · message.actions slot (SPEC §C.6). Explicit teaching: the two -// buttons are the strongest training signal the engine gets, because the user -// is telling us about *this* message rather than about a threshold. +// 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; - el.style.display = "inline-flex"; - el.style.gap = "4px"; - el.style.alignItems = "center"; - + let disposed = false; let busy = false; async function teach(verdict) { if (busy) return; busy = true; - render(); + render(current, true); try { await ctx.api.pluginFetch("/learn", { method: "POST", body: JSON.stringify({ message_id: messageId, verdict }), }); - // The message has moved folders; let the mail UI catch up. window.dispatchEvent(new CustomEvent("webmail:antispam-learned")); - el.innerHTML = `${verdict === "spam" ? "Marked as spam" : "Marked as not spam"}`; - return; + if (!disposed) { + el.innerHTML = `${verdict === "spam" ? "Marked as spam" : "Marked as not spam"}`; + } } catch { busy = false; - render(); + render(current, false); } } - function render() { - const d = busy ? " disabled" : ""; - el.innerHTML = - `` + - ``; - el.querySelectorAll("[data-v]").forEach((b) => - b.addEventListener("click", (e) => { - e.stopPropagation(); - teach(b.getAttribute("data-v")); - }), - ); + 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); + }); } - render(); + (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/banner.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js index 5f65696..4dc321e 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js @@ -24,7 +24,8 @@ export default function mount(el, ctx, props) { (async () => { let v = null; try { - v = await ctx.api.pluginFetch(`/verdict/${messageId}`); + const ctxData = await ctx.api.pluginFetch(`/context/${messageId}`); + v = ctxData && ctxData.verdict; } catch { return; // no verdict service, no banner — never block the message }