Files
webmail-client/plugins/webmail_plugin_antispam/webmail_plugin_antispam/policy.py
T
lukasandClaude Opus 5.5 b88855931e M6 hosted mailboxes (off by default); dark message mode; fixes
Hosted mailboxes (SPEC App. D), behind HOSTED_MAILBOXES (off: unchanged):
- Stalwart mail server, provisioned by `python -m app.hosted.provision`;
  directory read from a Postgres view, app passwords only for mail apps
- native account per user, the worker signs in as master for the user
- raw messages of hosted mailboxes only cached, fetched back on demand
- migrations 0025 (accounts.kind, directory view), 0026 (IMAP ids bigint)

Mail:
- dark message mode switch in the reading pane (Thunderbird's algorithm)
- moves and folder creation quote the folder name ("Deleted Items"):
  swipe-to-delete / Trash no longer bounce back
- thread unread counters: lock before recount, sorted lock order,
  UIDVALIDITY re-import recounts, nightly repair_threads
- sidebar accounts sorted alphabetically, more space above headings

Settings: Security headings renamed; antispam accounts are explicit
(none ticked = none filtered), input widths, Activity hidden.
Plugin bundles served with Cache-Control: no-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:25:10 +02:00

166 lines
5.7 KiB
Python

"""Settings and allow/deny rules — the policy half of classification (SPEC §C.3).
Kept apart from the hook so the decision logic is testable without a database or
an engine: :func:`decide` is a pure function of (rules, score, thresholds).
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from sqlalchemy import text
from app.plugins.sdk import sdk
_SDK = sdk("antispam")
# §C.3 defaults, used when a user has never opened the settings page.
DEFAULTS = {
"enabled_accounts": None, # every account
"move_threshold": 12.0,
"banner_threshold": 6.0,
"network_checks": False,
"learn_from_provider_junk": True,
}
@dataclass(frozen=True)
class Settings:
# None: every account (also ones added later); a list: exactly those —
# empty, none.
enabled_accounts: list[str] | None
move_threshold: float
banner_threshold: float
network_checks: bool
learn_from_provider_junk: bool
def covers(self, account_id: str) -> bool:
return self.enabled_accounts is None or account_id in self.enabled_accounts
async def load_settings(user_id: uuid.UUID) -> Settings:
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(
"SELECT enabled_accounts, move_threshold, banner_threshold, "
"network_checks, learn_from_provider_junk "
"FROM plg_antispam_settings WHERE user_id = :u"
),
{"u": str(user_id)},
)
).first()
if row is None:
d: Any = DEFAULTS
return Settings(
enabled_accounts=None,
move_threshold=d["move_threshold"],
banner_threshold=d["banner_threshold"],
network_checks=d["network_checks"],
learn_from_provider_junk=d["learn_from_provider_junk"],
)
return Settings(
enabled_accounts=None if row[0] is None else [str(a) for a in row[0]],
move_threshold=float(row[1]),
banner_threshold=float(row[2]),
network_checks=bool(row[3]),
learn_from_provider_junk=bool(row[4]),
)
async def load_rules(user_id: uuid.UUID) -> dict[str, set[str]]:
"""``{"allow": {...}, "deny": {...}}`` of lower-cased patterns."""
async with _SDK.session(user_id) as s:
rows = (
await s.execute(
text("SELECT kind, pattern FROM plg_antispam_rules WHERE user_id = :u"),
{"u": str(user_id)},
)
).all()
out: dict[str, set[str]] = {"allow": set(), "deny": set()}
for kind, pattern in rows:
if kind in out:
out[kind].add(str(pattern).strip().lower())
return out
async def sender_in_contacts(user_id: uuid.UUID, email: str) -> bool:
"""Is this sender in the user's address book?
Someone you have deliberately saved is not someone whose mail should vanish
into Junk — so a contact caps the verdict at a banner (see :func:`decide`).
Reads the contacts plugin's table directly. Cross-plugin, and deliberately
forgiving: if that plugin is not installed the table does not exist, which is
not an error here, just "no contacts to check". The addresses are stored as
the user typed them, so both sides are lower-cased rather than relying on the
jsonb containment index.
"""
addr = (email or "").strip().lower()
if not addr:
return False
try:
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(
"SELECT 1 FROM plg_contacts_contacts "
"WHERE user_id = :u AND ("
" lower(default_email) = :e "
" OR EXISTS (SELECT 1 FROM jsonb_array_elements(emails) AS x "
" WHERE lower(x->>'value') = :e)) "
"LIMIT 1"
),
{"u": str(user_id), "e": addr},
)
).first()
return row is not None
except Exception: # noqa: BLE001 - contacts plugin absent, or its schema moved
return False
def matches(patterns: set[str], email: str) -> bool:
"""A pattern is either a whole address or ``@domain`` for everyone there."""
addr = (email or "").strip().lower()
if not addr:
return False
if addr in patterns:
return True
_, _, domain = addr.partition("@")
return bool(domain) and f"@{domain}" in patterns
def decide(
*,
sender: str,
rules: dict[str, set[str]],
score: float | None,
settings: Settings,
contact: bool = False,
) -> str:
"""The §C.4 decision, in order. Returns the verdict action.
Deny beats allow beats engine: an explicit user rule is a statement about a
sender that no score should override. ``score is None`` means the engine
could not be reached — fail open, deliver as ``none``.
``contact`` (the sender is in the user's address book) caps the outcome at a
banner: their mail is never moved to Junk. Not silenced entirely, because the
way this goes wrong in practice is a contact's account being compromised and
spamming its own address book — the user still deserves the warning. An
explicit deny rule still wins: that is the user overruling us on purpose.
"""
if matches(rules["deny"], sender):
return "deny_listed"
if matches(rules["allow"], sender):
return "allow_listed"
if score is None:
return "none"
if score >= settings.move_threshold:
return "banner" if contact else "moved"
if score >= settings.banner_threshold:
return "banner"
return "none"