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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user