Files
webmail-client/plugins/webmail_plugin_antispam/webmail_plugin_antispam/schemas.py
T
lukasandClaude Opus 5 c3ae613bf4 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
2026-09-16 22:21:26 +02:00

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