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
+161
View File
@@ -0,0 +1,161 @@
"""Antispam HTTP surface + RLS isolation (SPEC §C.6, acceptance 1 and 4).
Exercises the real vertical: entry-point discovery -> router at
/api/plugins/antispam -> SDK auth -> RLS-scoped session -> the plg_antispam_*
tables. Requires the plugin installed and `install antispam` run.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
BASE = "/api/plugins/antispam"
async def _mk_user(sm: async_sessionmaker) -> uuid.UUID: # type: ignore[type-arg]
async with sm() as s:
user = User(
handle=f"as{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
)
s.add(user)
await s.commit()
return user.id
class _Ctx:
user_a: uuid.UUID
user_b: uuid.UUID
client_a: httpx.AsyncClient
client_b: httpx.AsyncClient
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[_Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
c = _Ctx()
c.user_a = await _mk_user(sm)
c.user_b = await _mk_user(sm)
transport = ASGITransport(app=create_app())
async def client(uid: uuid.UUID) -> httpx.AsyncClient:
token = await create_session(uid, "pytest")
return httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
cookies={"session": token, "csrf": "csrf"},
headers={"X-CSRF": "csrf"},
)
c.client_a = await client(c.user_a)
c.client_b = await client(c.user_b)
try:
yield c
finally:
await c.client_a.aclose()
await c.client_b.aclose()
async with sm() as s:
for table in ("plg_antispam_rules", "plg_antispam_settings", "plg_antispam_verdicts"):
await s.execute(
text(f"DELETE FROM {table} WHERE user_id IN (:a, :b)"),
{"a": str(c.user_a), "b": str(c.user_b)},
)
await s.execute(delete(User).where(User.id.in_([c.user_a, c.user_b])))
await s.commit()
await engine.dispose()
async def test_settings_defaults_then_merge(ctx: _Ctx) -> None:
"""A user who has never opened the page still gets the §C.3 defaults."""
s = (await ctx.client_a.get(f"{BASE}/settings")).json()
assert (s["move_threshold"], s["banner_threshold"]) == (12.0, 6.0)
assert s["network_checks"] is False # privacy default (§C.2)
assert s["enabled_accounts"] == [] # empty = every account
# PATCH merges: the field we leave out keeps its value.
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 15.0})).json()
assert s["move_threshold"] == 15.0
assert s["banner_threshold"] == 6.0
async def test_banner_threshold_cannot_exceed_move(ctx: _Ctx) -> None:
"""A banner above the move score would never be seen — it is capped, not refused."""
s = (
await ctx.client_a.patch(
f"{BASE}/settings", json={"move_threshold": 10.0, "banner_threshold": 20.0}
)
).json()
assert s["banner_threshold"] == 10.0
async def test_rules_crud_and_validation(ctx: _Ctx) -> None:
created = await ctx.client_a.post(
f"{BASE}/rules", json={"kind": "deny", "pattern": "@Bad.TEST"}
)
assert created.status_code == 200
assert created.json()["pattern"] == "@bad.test" # normalised
listed = (await ctx.client_a.get(f"{BASE}/rules")).json()
assert [r["pattern"] for r in listed] == ["@bad.test"]
# Re-adding the same pattern is idempotent, not a duplicate.
await ctx.client_a.post(f"{BASE}/rules", json={"kind": "deny", "pattern": "@bad.test"})
assert len((await ctx.client_a.get(f"{BASE}/rules")).json()) == 1
assert (
await ctx.client_a.post(f"{BASE}/rules", json={"kind": "deny", "pattern": "nonsense"})
).status_code == 400
assert (
await ctx.client_a.post(f"{BASE}/rules", json={"kind": "maybe", "pattern": "a@b.test"})
).status_code == 422
await ctx.client_a.delete(f"{BASE}/rules/{listed[0]['id']}")
assert (await ctx.client_a.get(f"{BASE}/rules")).json() == []
async def test_rls_isolation_between_users(ctx: _Ctx) -> None:
"""Acceptance 4's per-user isolation, at the storage layer."""
await ctx.client_a.post(f"{BASE}/rules", json={"kind": "allow", "pattern": "a@only.test"})
await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 9.0})
assert (await ctx.client_b.get(f"{BASE}/rules")).json() == []
assert (await ctx.client_b.get(f"{BASE}/settings")).json()["move_threshold"] == 12.0
# B cannot delete A's rule even knowing its id.
a_rule = (await ctx.client_a.get(f"{BASE}/rules")).json()[0]
await ctx.client_b.delete(f"{BASE}/rules/{a_rule['id']}")
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_stats_work_without_an_engine(ctx: _Ctx) -> None:
"""The stats page must render on a box where Rspamd is not running."""
body = (await ctx.client_a.get(f"{BASE}/stats")).json()
assert body["counts"] == {}
assert body["top_symbols"] == []
assert body["bayes"] is None # unreachable engine, not an error
async def test_ui_bundles_are_served_and_gated(ctx: _Ctx) -> None:
for name in ("settings.js", "banner.js", "actions.js"):
r = await ctx.client_a.get(f"{BASE}/ui/{name}")
assert r.status_code == 200
assert "javascript" in r.headers["content-type"]
assert (await ctx.client_a.get(f"{BASE}/ui/../hook.py")).status_code == 404
@@ -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 = "";
};
}