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>
166 lines
5.7 KiB
Python
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"
|