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
}