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
61 lines
1.5 KiB
Python
61 lines
1.5 KiB
Python
"""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
|