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
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:
@@ -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(
@@ -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]]
@@ -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 = `<span class="wm-faint">${verdict === "spam" ? "Marked as spam" : "Marked as not spam"}</span>`;
return;
if (!disposed) {
el.innerHTML = `<span class="wm-faint">${verdict === "spam" ? "Marked as spam" : "Marked as not spam"}</span>`;
}
} catch {
busy = false;
render();
render(current, false);
}
}
function render() {
const d = busy ? " disabled" : "";
el.innerHTML =
`<button class="wm-btn-ghost" data-v="spam" title="Move to Spam and teach the filter"${d}>Spam</button>` +
`<button class="wm-btn-ghost" data-v="ham" title="Move back to Inbox and teach the filter"${d}>Not spam</button>`;
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 = `<button class="wm-btn-ghost" title="${action.title}"${pending ? " disabled" : ""}>${action.label}</button>`;
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 = `<span class="wm-faint">${learned === "spam" ? "Marked as spam" : "Marked as not spam"}</span>`;
return;
}
render(current, false);
})();
return () => {
disposed = true;
el.innerHTML = "";
};
}
@@ -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
}