Antispam C1: the API and the three UI surfaces

Completes phase C1 — until now the plugin classified mail that nobody could see
or steer.

Router (§C.6): settings with merge semantics, allow/deny rules, a per-message
verdict lookup, learn, and stats. Two behaviours worth naming. A banner
threshold above the move threshold is silently capped rather than refused,
because a banner past the move score can never be seen — everything that far up
is already in Junk. And /learn moves the message even when the engine is
unreachable: teaching is best-effort, but filing is what the user actually
asked for.

UI: settings.page (thresholds in plain language, per-account toggles, the two
lists, the privacy switch, activity); message.banner for the uncertain middle,
offering Move to Junk / Not spam / Always trust sender; message.actions with
Spam / Not spam. The banner mounts on every message but renders only for a
`banner` verdict — a spam warning on ordinary mail is worse than no warning.

Bundle serving is allow-listed by filename, so /ui/../hook.py is a 404 rather
than a file read.

Tests cover the defaults, merge semantics, the threshold cap, rule
normalisation and idempotency, stats degrading to empty when Rspamd is absent,
and RLS isolation: one user cannot see another's rules or settings, nor delete a
rule whose id they know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-16 22:21:26 +02:00
co-authored by Claude Opus 5
parent 95ca1b49f6
commit c3ae613bf4
9 changed files with 789 additions and 7 deletions
@@ -18,14 +18,26 @@ from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import Plugin
from app.plugins.sdk import Plugin, Slot, UiManifest
from webmail_plugin_antispam.hook import on_message_new
from webmail_plugin_antispam.router import router
_HERE = Path(__file__).parent
_UI = "/api/plugins/antispam/ui"
plugin = Plugin(
name="antispam",
version="0.1.0",
hooks={"message.new": on_message_new},
router=router,
migrations=str(_HERE / "migrations" / "versions"),
ui_manifest=UiManifest(
slots=[
Slot(name="settings.page", bundle=f"{_UI}/settings.js"),
# Mounts on every message but renders only for a `banner` verdict —
# a spam warning on ordinary mail is worse than no warning at all.
Slot(name="message.banner", bundle=f"{_UI}/banner.js"),
Slot(name="message.actions", bundle=f"{_UI}/actions.js"),
],
),
)
@@ -72,9 +72,7 @@ def upgrade() -> None:
"CREATE INDEX ix_plg_antispam_verdicts_user_action "
"ON plg_antispam_verdicts (user_id, action)"
)
op.execute(
"CREATE INDEX ix_plg_antispam_rules_user_kind ON plg_antispam_rules (user_id, kind)"
)
op.execute("CREATE INDEX ix_plg_antispam_rules_user_kind ON plg_antispam_rules (user_id, kind)")
enable_rls("plg_antispam_settings")
enable_rls("plg_antispam_rules")
@@ -0,0 +1,266 @@
"""HTTP surface for the antispam plugin, mounted at /api/plugins/antispam (§C.6).
Everything here is user-scoped through the SDK session, which applies RLS on the
plugin's own tables — a user cannot read or change another user's thresholds,
lists or verdicts.
"""
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy import text
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
from webmail_plugin_antispam import policy, rspamd
from webmail_plugin_antispam.schemas import (
LearnIn,
RuleIn,
RuleOut,
SettingsIn,
SettingsOut,
StatsOut,
VerdictOut,
)
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
_BUNDLES = {"settings.js", "banner.js", "actions.js"}
_SDK = sdk("antispam")
# --- settings ----------------------------------------------------------------
@router.get("/settings", response_model=SettingsOut)
async def get_settings(user_id: CurrentUserId) -> SettingsOut:
s = await policy.load_settings(user_id)
return SettingsOut(
enabled_accounts=s.enabled_accounts,
move_threshold=s.move_threshold,
banner_threshold=s.banner_threshold,
network_checks=s.network_checks,
learn_from_provider_junk=s.learn_from_provider_junk,
)
@router.patch("/settings", response_model=SettingsOut, dependencies=[Depends(require_csrf)])
async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOut:
current = await policy.load_settings(user_id)
merged = {
"enabled_accounts": (
current.enabled_accounts if body.enabled_accounts is None else body.enabled_accounts
),
"move_threshold": body.move_threshold or current.move_threshold,
"banner_threshold": body.banner_threshold or current.banner_threshold,
"network_checks": (
current.network_checks if body.network_checks is None else body.network_checks
),
"learn_from_provider_junk": (
current.learn_from_provider_junk
if body.learn_from_provider_junk is None
else body.learn_from_provider_junk
),
}
# A banner that fires later than the move would never be seen: anything past
# it is already gone to Junk. Keep the pair coherent rather than rejecting.
if merged["banner_threshold"] > merged["move_threshold"]:
merged["banner_threshold"] = merged["move_threshold"]
# asyncpg binds arrays as Python lists — a "{a,b}" literal is rejected. Drop
# anything that is not a uuid rather than 500 on a malformed account id.
account_ids: list[uuid.UUID] = []
for raw in merged["enabled_accounts"]:
try:
account_ids.append(uuid.UUID(str(raw)))
except (ValueError, AttributeError, TypeError):
continue
merged["enabled_accounts"] = [str(a) for a in account_ids]
async with _SDK.session(user_id) as s:
await s.execute(
text(
"INSERT INTO plg_antispam_settings (user_id, enabled_accounts, move_threshold, "
" banner_threshold, network_checks, learn_from_provider_junk) "
"VALUES (:u, CAST(:ea AS uuid[]), :mt, :bt, :nc, :lp) "
"ON CONFLICT (user_id) DO UPDATE SET "
" enabled_accounts = EXCLUDED.enabled_accounts, "
" move_threshold = EXCLUDED.move_threshold, "
" banner_threshold = EXCLUDED.banner_threshold, "
" network_checks = EXCLUDED.network_checks, "
" learn_from_provider_junk = EXCLUDED.learn_from_provider_junk"
),
{
"u": str(user_id),
"ea": account_ids,
"mt": merged["move_threshold"],
"bt": merged["banner_threshold"],
"nc": merged["network_checks"],
"lp": merged["learn_from_provider_junk"],
},
)
await s.commit()
return SettingsOut(**merged)
# --- allow / deny lists ------------------------------------------------------
@router.get("/rules", response_model=list[RuleOut])
async def list_rules(user_id: CurrentUserId) -> list[RuleOut]:
async with _SDK.session(user_id) as s:
rows = (
await s.execute(
text(
"SELECT id, kind, pattern FROM plg_antispam_rules "
"WHERE user_id = :u ORDER BY kind, pattern"
),
{"u": str(user_id)},
)
).all()
return [RuleOut(id=r[0], kind=r[1], pattern=str(r[2])) for r in rows]
@router.post("/rules", response_model=RuleOut, dependencies=[Depends(require_csrf)])
async def add_rule(body: RuleIn, user_id: CurrentUserId) -> RuleOut:
pattern = body.pattern.strip().lower()
if "@" not in pattern:
raise HTTPException(
status_code=400,
detail={"code": "bad_pattern", "message": "use an address or @domain"},
)
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(
"INSERT INTO plg_antispam_rules (user_id, kind, pattern) "
"VALUES (:u, :k, :p) "
"ON CONFLICT (user_id, kind, pattern) DO UPDATE SET pattern = EXCLUDED.pattern "
"RETURNING id, kind, pattern"
),
{"u": str(user_id), "k": body.kind, "p": pattern},
)
).first()
await s.commit()
assert row is not None
return RuleOut(id=row[0], kind=row[1], pattern=str(row[2]))
@router.delete("/rules/{rule_id}", dependencies=[Depends(require_csrf)])
async def delete_rule(rule_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await s.execute(
text("DELETE FROM plg_antispam_rules WHERE id = :i AND user_id = :u"),
{"i": str(rule_id), "u": str(user_id)},
)
await s.commit()
return {"status": "ok"}
# --- verdicts, learning, stats ----------------------------------------------
@router.get("/verdict/{message_id}", response_model=VerdictOut | None)
async def get_verdict(message_id: uuid.UUID, user_id: CurrentUserId) -> VerdictOut | None:
"""What the banner slot asks before deciding whether to render anything."""
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(
"SELECT message_id, score, action, symbols, learned "
"FROM plg_antispam_verdicts WHERE message_id = :m AND user_id = :u"
),
{"m": str(message_id), "u": str(user_id)},
)
).first()
if row is None:
return None
return VerdictOut(
message_id=row[0],
score=float(row[1]),
action=row[2],
symbols=list(row[3] or []),
learned=row[4],
)
@router.post("/learn", dependencies=[Depends(require_csrf)])
async def learn(body: LearnIn, user_id: CurrentUserId) -> dict[str, object]:
"""Teach this user's Bayes, then file the message accordingly (§C.5).
The move is what the user actually asked for, so it happens even when the
engine is unreachable — teaching is best-effort, filing is not.
"""
spam = body.verdict == "spam"
raw = await _SDK.messages.raw(user_id, body.message_id)
taught = False
if raw is not None:
taught = await rspamd.learn(raw, user_key=str(user_id), spam=spam)
moved = await _SDK.actions.move_to_special(
user_id, body.message_id, "junk" if spam else "inbox"
)
async with _SDK.session(user_id) as s:
await s.execute(
text(
"UPDATE plg_antispam_verdicts SET learned = :l, action = :a "
"WHERE message_id = :m AND user_id = :u"
),
{
"l": body.verdict,
"a": "moved" if spam else "none",
"m": str(body.message_id),
"u": str(user_id),
},
)
await s.commit()
return {"status": "ok", "taught": taught, "moved": moved}
@router.get("/stats", response_model=StatsOut)
async def stats(user_id: CurrentUserId) -> StatsOut:
async with _SDK.session(user_id) as s:
counts = {
r[0]: int(r[1])
for r in (
await s.execute(
text(
"SELECT action, count(*) FROM plg_antispam_verdicts "
"WHERE user_id = :u GROUP BY action"
),
{"u": str(user_id)},
)
).all()
}
top = (
await s.execute(
text(
"SELECT sym, count(*) AS n FROM plg_antispam_verdicts, "
" jsonb_array_elements_text(symbols) AS sym "
"WHERE user_id = :u GROUP BY sym ORDER BY n DESC LIMIT 10"
),
{"u": str(user_id)},
)
).all()
bayes: dict[str, Any] | None = await rspamd.stats(user_key=str(user_id))
return StatsOut(
counts=counts,
top_symbols=[{"symbol": r[0], "count": int(r[1])} for r in top],
bayes=bayes,
)
# --- UI bundles --------------------------------------------------------------
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename not in _BUNDLES:
raise HTTPException(status_code=404, detail={"code": "not_found", "message": "no bundle"})
return FileResponse(_UI / filename, media_type="text/javascript")
@@ -85,9 +85,7 @@ def _headers(user_key: str, network_checks: bool) -> dict[str, str]:
}
async def check(
raw: bytes, *, user_key: str, network_checks: bool = False
) -> Verdict | None:
async def check(raw: bytes, *, user_key: str, network_checks: bool = False) -> Verdict | None:
"""Score one raw RFC822 message. ``None`` means "could not classify"."""
try:
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
@@ -0,0 +1,60 @@
"""Request/response models for the antispam API (SPEC §C.6)."""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
class SettingsOut(BaseModel):
enabled_accounts: list[str]
move_threshold: float
banner_threshold: float
network_checks: bool
learn_from_provider_junk: bool
class SettingsIn(BaseModel):
"""Merge semantics: omitted fields keep their current value."""
enabled_accounts: list[str] | None = None
# Bounds are guard rails, not opinions: 0 would file everything as spam and
# the engine's own scale tops out well below 100.
move_threshold: float | None = Field(default=None, gt=0, le=100)
banner_threshold: float | None = Field(default=None, gt=0, le=100)
network_checks: bool | None = None
learn_from_provider_junk: bool | None = None
class RuleOut(BaseModel):
id: uuid.UUID
kind: str
pattern: str
class RuleIn(BaseModel):
kind: str = Field(pattern="^(allow|deny)$")
# An address (a@b.test) or a whole domain (@b.test).
pattern: str = Field(min_length=3, max_length=320)
class LearnIn(BaseModel):
message_id: uuid.UUID
verdict: str = Field(pattern="^(spam|ham)$")
class VerdictOut(BaseModel):
"""What the banner slot needs to explain itself."""
message_id: uuid.UUID
score: float
action: str
symbols: list[str]
learned: str | None
class StatsOut(BaseModel):
counts: dict[str, int]
top_symbols: list[dict[str, object]]
bayes: dict[str, object] | None
@@ -0,0 +1,50 @@
// 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.
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 busy = false;
async function teach(verdict) {
if (busy) return;
busy = true;
render();
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;
} catch {
busy = false;
render();
}
}
function render() {
const d = busy ? " disabled" : "";
el.innerHTML =
`<button class="wm-btn-ghost" data-v="spam" title="Move to Junk 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"));
}),
);
}
render();
return () => {
el.innerHTML = "";
};
}
@@ -0,0 +1,74 @@
// antispam · message.banner slot (SPEC §C.6). The "uncertain middle": scored
// above the banner threshold but below the move threshold, so it was delivered
// normally and the user gets a warning plus one click to resolve it either way.
//
// Renders NOTHING unless this message actually has a `banner` verdict — the slot
// mounts on every message, and a spam warning on ordinary mail is worse than no
// warning at all.
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
}
export default function mount(el, ctx, props) {
const messageId = props && props.messageId;
if (!messageId) return;
let disposed = false;
async function act(path, body) {
await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(body) });
window.dispatchEvent(new CustomEvent("webmail:antispam-learned"));
if (!disposed) el.innerHTML = "";
}
(async () => {
let v = null;
try {
v = await ctx.api.pluginFetch(`/verdict/${messageId}`);
} catch {
return; // no verdict service, no banner — never block the message
}
if (disposed || !v || v.action !== "banner") return;
const why = (v.symbols || []).slice(0, 4).join(", ");
el.innerHTML = `
<div class="wm-card" role="status">
<strong>Looks like spam</strong>
<span class="wm-muted">score ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}</span>
<div class="wm-row">
<button class="wm-btn" data-a="junk">Move to Junk</button>
<button class="wm-btn-ghost" data-a="ham">Not spam</button>
<button class="wm-btn-ghost" data-a="trust">Always trust sender</button>
</div>
</div>`;
el.querySelector('[data-a="junk"]').addEventListener("click", () =>
act("/learn", { message_id: messageId, verdict: "spam" }),
);
el.querySelector('[data-a="ham"]').addEventListener("click", () =>
act("/learn", { message_id: messageId, verdict: "ham" }),
);
el.querySelector('[data-a="trust"]').addEventListener("click", async () => {
// Allow-listing needs the sender, which the verdict does not carry.
let from = "";
try {
const msg = await ctx.api.fetch(`/messages/${messageId}`);
from = (msg && msg.from_addr && msg.from_addr[0] && msg.from_addr[0].email) || "";
} catch {
from = "";
}
if (from) {
await ctx.api.pluginFetch("/rules", {
method: "POST",
body: JSON.stringify({ kind: "allow", pattern: from }),
});
}
await act("/learn", { message_id: messageId, verdict: "ham" });
});
})();
return () => {
disposed = true;
el.innerHTML = "";
};
}
@@ -0,0 +1,163 @@
// antispam · settings.page slot (SPEC §C.6). Thresholds in plain language,
// per-account toggles, the allow/deny lists, the privacy switch, and stats.
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
}
export default function mount(el, ctx) {
let settings = null;
let rules = [];
let accounts = [];
let stats = null;
let disposed = false;
async function load() {
const [s, r, st] = await Promise.all([
ctx.api.pluginFetch("/settings").catch(() => null),
ctx.api.pluginFetch("/rules").catch(() => []),
ctx.api.pluginFetch("/stats").catch(() => null),
]);
accounts = await ctx.api.fetch("/accounts").catch(() => []);
settings = s;
rules = r || [];
stats = st;
if (!disposed) render();
}
async function patch(body) {
settings = await ctx.api.pluginFetch("/settings", {
method: "PATCH",
body: JSON.stringify(body),
});
render();
}
function accountRows() {
if (!accounts.length) return "";
const on = new Set(settings.enabled_accounts || []);
// Empty list means every account, so an untouched install shows all ticked.
const all = on.size === 0;
return accounts
.map(
(a) =>
`<label class="wm-row"><input type="checkbox" data-acc="${esc(a.id)}"${all || on.has(a.id) ? " checked" : ""}> ${esc(a.email)}</label>`,
)
.join("");
}
function ruleRows(kind) {
const list = rules.filter((r) => r.kind === kind);
if (!list.length) return `<li class="wm-faint">none yet</li>`;
return list
.map(
(r) =>
`<li class="wm-row"><span>${esc(r.pattern)}</span><button class="wm-btn-ghost" data-del="${esc(r.id)}">remove</button></li>`,
)
.join("");
}
function statsBlock() {
if (!stats) return `<p class="wm-faint">Stats unavailable — is the engine running?</p>`;
const c = stats.counts || {};
const rowsOf = Object.entries(c)
.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v}</td></tr>`)
.join("");
const syms = (stats.top_symbols || [])
.map((s) => `<tr><td>${esc(s.symbol)}</td><td>${s.count}</td></tr>`)
.join("");
return `
<table class="wm-table"><tbody>${rowsOf || '<tr><td class="wm-faint">nothing classified yet</td><td></td></tr>'}</tbody></table>
${syms ? `<p class="wm-muted">Most common reasons</p><table class="wm-table"><tbody>${syms}</tbody></table>` : ""}`;
}
function render() {
if (!settings) {
el.innerHTML = `<p class="wm-faint">Loading…</p>`;
return;
}
el.className = "wm-root";
el.innerHTML = `
<h2 class="wm-h2">Spam filtering</h2>
<p class="wm-muted">A second pass over mail your provider already delivered. Nothing is deleted — suspect mail is filed into Junk, and the uncertain middle only gets a warning.</p>
<div class="wm-card">
<label class="wm-row">Move to Junk at score
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}">
</label>
<label class="wm-row">Warn with a banner at score
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="banner_threshold" value="${settings.banner_threshold}">
</label>
<p class="wm-faint">Higher is more permissive. A banner above the move score would never be seen, so it is capped there.</p>
</div>
<div class="wm-card">
<strong>Accounts</strong>
<p class="wm-faint">Tick none to filter every account.</p>
${accountRows()}
</div>
<div class="wm-card">
<strong>Always allow</strong>
<p class="wm-faint">An address, or @domain for everyone there. Beats any score.</p>
<ul class="wm-list">${ruleRows("allow")}</ul>
<div class="wm-row"><input class="wm-input" data-new="allow" placeholder="sender@example.com or @example.com"><button class="wm-btn" data-add="allow">Add</button></div>
</div>
<div class="wm-card">
<strong>Always refuse</strong>
<p class="wm-faint">Filed into Junk without asking the engine.</p>
<ul class="wm-list">${ruleRows("deny")}</ul>
<div class="wm-row"><input class="wm-input" data-new="deny" placeholder="sender@example.com or @example.com"><button class="wm-btn" data-add="deny">Add</button></div>
</div>
<div class="wm-card">
<label class="wm-row"><input type="checkbox" data-f="network_checks"${settings.network_checks ? " checked" : ""}> Allow online reputation checks</label>
<p class="wm-faint">Off by default. These send hashes of your mail and the links it contains to third-party servers. They also need the modules enabled on the engine itself.</p>
<label class="wm-row"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as junk</label>
</div>
<div class="wm-card"><strong>Activity</strong>${statsBlock()}</div>`;
el.querySelectorAll('input[type="number"][data-f]').forEach((i) =>
i.addEventListener("change", () => patch({ [i.getAttribute("data-f")]: Number(i.value) })),
);
el.querySelectorAll('input[type="checkbox"][data-f]').forEach((i) =>
i.addEventListener("change", () => patch({ [i.getAttribute("data-f")]: i.checked })),
);
el.querySelectorAll("[data-acc]").forEach((i) =>
i.addEventListener("change", () => {
const ticked = [...el.querySelectorAll("[data-acc]")].filter((x) => x.checked);
// All ticked is the same as "every account": store it as empty.
const ids = ticked.length === accounts.length ? [] : ticked.map((x) => x.getAttribute("data-acc"));
patch({ enabled_accounts: ids });
}),
);
el.querySelectorAll("[data-add]").forEach((b) =>
b.addEventListener("click", async () => {
const kind = b.getAttribute("data-add");
const input = el.querySelector(`[data-new="${kind}"]`);
const pattern = (input.value || "").trim();
if (!pattern) return;
try {
await ctx.api.pluginFetch("/rules", { method: "POST", body: JSON.stringify({ kind, pattern }) });
} catch {
return;
}
await load();
}),
);
el.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", async () => {
await ctx.api.pluginFetch(`/rules/${b.getAttribute("data-del")}`, { method: "DELETE" });
await load();
}),
);
}
load();
return () => {
disposed = true;
el.innerHTML = "";
};
}