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