Antispam: one button, and only where it means something

Spam / Not spam appeared on every message in every thread. "Not spam" on an
inbox message is nonsense — it was never filed as spam — and a pair of buttons
everywhere is noise that trains nobody.

The slot now shows a single action chosen by where the message actually is:
"Spam" in the inbox or archive, "Not spam" in the spam folder, nothing at all in
Sent, Drafts or Trash, where no move is a verdict about spam. A message already
taught says so instead of offering the same lesson again.

That needs the message's folder, which the slot was never told. Rather than a
core change to the slot props, /verdict/{id} becomes /context/{id} and returns
the folder alongside the verdict — one round-trip, answered for any message
including ones never classified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-17 19:01:46 +02:00
co-authored by Claude Opus 5
parent 149c11581e
commit e420126145
5 changed files with 94 additions and 31 deletions
+6 -2
View File
@@ -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 assert len((await ctx.client_a.get(f"{BASE}/rules")).json()) == 1
async def test_verdict_absent_for_unclassified_message(ctx: _Ctx) -> None: async def test_context_for_an_unknown_message(ctx: _Ctx) -> None:
assert (await ctx.client_a.get(f"{BASE}/verdict/{uuid.uuid4()}")).json() is 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: async def test_stats_work_without_an_engine(ctx: _Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -19,6 +19,7 @@ from app.plugins.sdk import CurrentUserId, require_csrf, sdk
from webmail_plugin_antispam import policy, rspamd from webmail_plugin_antispam import policy, rspamd
from webmail_plugin_antispam.schemas import ( from webmail_plugin_antispam.schemas import (
LearnIn, LearnIn,
MessageContextOut,
RuleIn, RuleIn,
RuleOut, RuleOut,
SettingsIn, SettingsIn,
@@ -171,11 +172,25 @@ async def delete_rule(rule_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, s
# --- verdicts, learning, stats ---------------------------------------------- # --- verdicts, learning, stats ----------------------------------------------
@router.get("/verdict/{message_id}", response_model=VerdictOut | None) @router.get("/context/{message_id}", response_model=MessageContextOut)
async def get_verdict( async def get_context(
message_id: uuid.UUID, user_id: CurrentUserId message_id: uuid.UUID, user_id: CurrentUserId
) -> VerdictOut | None: ) -> MessageContextOut:
"""What the banner slot asks before deciding whether to render anything.""" """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: async with _SDK.session(user_id) as s:
row = ( row = (
await s.execute( await s.execute(
@@ -54,6 +54,19 @@ class VerdictOut(BaseModel):
learned: str | None 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): class StatsOut(BaseModel):
counts: dict[str, int] counts: dict[str, int]
top_symbols: list[dict[str, object]] top_symbols: list[dict[str, object]]
@@ -1,50 +1,80 @@
// antispam · message.actions slot (SPEC §C.6). Explicit teaching: the two // antispam · message.actions slot (SPEC §C.6). Explicit teaching: the strongest
// buttons are the strongest training signal the engine gets, because the user // training signal the engine gets, because the user is telling us about *this*
// is telling us about *this* message rather than about a threshold. // 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) { export default function mount(el, ctx, props) {
const messageId = props && props.messageId; const messageId = props && props.messageId;
if (!messageId) return; if (!messageId) return;
el.style.display = "inline-flex"; let disposed = false;
el.style.gap = "4px";
el.style.alignItems = "center";
let busy = false; let busy = false;
async function teach(verdict) { async function teach(verdict) {
if (busy) return; if (busy) return;
busy = true; busy = true;
render(); render(current, true);
try { try {
await ctx.api.pluginFetch("/learn", { await ctx.api.pluginFetch("/learn", {
method: "POST", method: "POST",
body: JSON.stringify({ message_id: messageId, verdict }), 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")); 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>`; el.innerHTML = `<span class="wm-faint">${verdict === "spam" ? "Marked as spam" : "Marked as not spam"}</span>`;
return; }
} catch { } catch {
busy = false; busy = false;
render(); render(current, false);
} }
} }
function render() { let current = null;
const d = busy ? " disabled" : "";
el.innerHTML = function render(action, pending) {
`<button class="wm-btn-ghost" data-v="spam" title="Move to Spam and teach the filter"${d}>Spam</button>` + if (!action) {
`<button class="wm-btn-ghost" data-v="ham" title="Move back to Inbox and teach the filter"${d}>Not spam</button>`; el.innerHTML = "";
el.querySelectorAll("[data-v]").forEach((b) => return;
b.addEventListener("click", (e) => { }
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(); e.stopPropagation();
teach(b.getAttribute("data-v")); 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 = `<span class="wm-faint">${learned === "spam" ? "Marked as spam" : "Marked as not spam"}</span>`;
return;
}
render(current, false);
})();
return () => { return () => {
disposed = true;
el.innerHTML = ""; el.innerHTML = "";
}; };
} }
@@ -24,7 +24,8 @@ export default function mount(el, ctx, props) {
(async () => { (async () => {
let v = null; let v = null;
try { try {
v = await ctx.api.pluginFetch(`/verdict/${messageId}`); const ctxData = await ctx.api.pluginFetch(`/context/${messageId}`);
v = ctxData && ctxData.verdict;
} catch { } catch {
return; // no verdict service, no banner — never block the message return; // no verdict service, no banner — never block the message
} }