Dev compose gains rspamd + redis-rspamd, matching production. The dev API and worker run on the host rather than in the compose network, so the ports are published on loopback and RSPAMD_URL points at 127.0.0.1 — documented in .env.example. Contacts now cap a verdict at "banner": mail from someone in the user's address book is never moved to Junk. Capped rather than exempted, because the way this fails in practice is a contact's account being compromised and spamming its own address book — the warning still has value even when the move would be wrong. An explicit deny rule still wins; that is the user overruling us deliberately. The lookup reads the contacts plugin's table directly and forgives its absence: no contacts plugin means no contacts to check, not an error. Addresses are stored as the user typed them, so both sides are lower-cased rather than relying on the jsonb containment index, and secondary addresses in the emails array count as well as the primary. It runs only when the verdict would otherwise be a move, so the common path pays nothing. Also fixes a test that asserted stats degrade "without an engine" by relying on no engine being present — true until this commit. It now simulates the absence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
277 lines
9.6 KiB
Python
277 lines
9.6 KiB
Python
"""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")
|