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:
@@ -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"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
+1
-3
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 = "";
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user