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,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
|
||||
Reference in New Issue
Block a user