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>
544 lines
18 KiB
Python
544 lines
18 KiB
Python
"""C2 learning loop: what teaches, what doesn't, and what only teaches once.
|
|
|
|
Passive learning (SPEC §C.5) reads the user's own filing as a verdict. The value
|
|
is in the restraint — most folder moves say nothing about spam, and teaching the
|
|
same message twice skews the corpus.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from webmail_plugin_antispam import hook, jobs, policy
|
|
|
|
SETTINGS = policy.Settings(
|
|
enabled_accounts=None,
|
|
move_threshold=12.0,
|
|
banner_threshold=6.0,
|
|
network_checks=False,
|
|
learn_from_provider_junk=True,
|
|
)
|
|
|
|
|
|
class _FakeSdk:
|
|
def __init__(self, learned: str | None = None) -> None:
|
|
self.learned_before = learned
|
|
self.published: list[tuple[str, dict[str, Any]]] = []
|
|
self.recorded: list[dict[str, Any]] = []
|
|
self.enqueued: list[dict[str, Any]] = []
|
|
|
|
outer = self
|
|
|
|
class _M:
|
|
async def envelope(self, *_a: object, **_k: object) -> dict[str, Any]:
|
|
return {"account_id": "acc-1"}
|
|
|
|
async def raw(self, *_a: object, **_k: object) -> bytes:
|
|
return b"From: x@y.test\r\n\r\nbody\r\n"
|
|
|
|
self.messages = _M()
|
|
|
|
async def _enqueue(_f: object, **kw: Any) -> None:
|
|
outer.enqueued.append(kw)
|
|
|
|
self.enqueue = _enqueue
|
|
|
|
async def publish(self, event: str, payload: dict[str, Any]) -> None:
|
|
self.published.append((event, payload))
|
|
|
|
|
|
@pytest.fixture
|
|
def wired(monkeypatch: pytest.MonkeyPatch): # type: ignore[no-untyped-def]
|
|
def _install(*, learned: str | None = None, engine_up: bool = True) -> _FakeSdk:
|
|
fake = _FakeSdk(learned)
|
|
taught: list[bool] = []
|
|
|
|
async def _learn(_raw: bytes, *, user_key: str, spam: bool) -> bool:
|
|
taught.append(spam)
|
|
return engine_up
|
|
|
|
async def _already(_u: object, _m: object) -> str | None:
|
|
return fake.learned_before
|
|
|
|
async def _remember(_u: object, _m: object, **kw: Any) -> None:
|
|
fake.recorded.append(kw)
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return SETTINGS
|
|
|
|
monkeypatch.setattr(hook, "_SDK", fake)
|
|
monkeypatch.setattr(hook.rspamd, "learn", _learn)
|
|
monkeypatch.setattr(hook, "_already_learned", _already)
|
|
monkeypatch.setattr(hook, "_remember_learned", _remember)
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
fake.taught = taught # type: ignore[attr-defined]
|
|
return fake
|
|
|
|
return _install
|
|
|
|
|
|
async def _move(before: str | None, now: str | None) -> None:
|
|
await hook.on_message_changed(
|
|
{
|
|
"user_id": str(uuid.uuid4()),
|
|
"message_id": str(uuid.uuid4()),
|
|
"previous_special_use": before,
|
|
"special_use": now,
|
|
}
|
|
)
|
|
|
|
|
|
async def test_filing_into_spam_teaches_spam(wired) -> None: # type: ignore[no-untyped-def]
|
|
fake = wired()
|
|
await _move("inbox", "junk")
|
|
assert fake.taught == [True]
|
|
assert fake.recorded[0]["verdict"] == "spam"
|
|
|
|
|
|
async def test_rescuing_from_spam_teaches_ham(wired) -> None: # type: ignore[no-untyped-def]
|
|
fake = wired()
|
|
await _move("junk", "inbox")
|
|
assert fake.taught == [False]
|
|
assert fake.recorded[0]["verdict"] == "ham"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("before", "now"),
|
|
[
|
|
("inbox", "archive"), # tidying up
|
|
("junk", "trash"), # deleting spam is not a verdict about ham
|
|
("archive", "inbox"), # unarchiving says nothing about spam
|
|
("sent", "archive"),
|
|
("inbox", "inbox"), # not a move at all
|
|
],
|
|
)
|
|
async def test_other_moves_teach_nothing(wired, before, now) -> None: # type: ignore[no-untyped-def]
|
|
fake = wired()
|
|
await _move(before, now)
|
|
assert fake.taught == []
|
|
assert fake.recorded == []
|
|
|
|
|
|
async def test_the_same_lesson_is_not_taught_twice(wired) -> None: # type: ignore[no-untyped-def]
|
|
"""Users move things back and forth; the corpus should not count that twice."""
|
|
fake = wired(learned="spam")
|
|
await _move("inbox", "junk")
|
|
assert fake.taught == []
|
|
|
|
|
|
async def test_reversing_a_lesson_does_teach(wired) -> None: # type: ignore[no-untyped-def]
|
|
"""Having taught spam, moving it back out must be able to teach ham."""
|
|
fake = wired(learned="spam")
|
|
await _move("junk", "inbox")
|
|
assert fake.taught == [False]
|
|
|
|
|
|
async def test_nothing_is_recorded_when_the_engine_is_down(wired) -> None: # type: ignore[no-untyped-def]
|
|
"""`learned` stays unset so a later move can teach it again."""
|
|
fake = wired(engine_up=False)
|
|
await _move("inbox", "junk")
|
|
assert fake.taught == [True]
|
|
assert fake.recorded == []
|
|
|
|
|
|
# --- bootstrap ---------------------------------------------------------------
|
|
|
|
|
|
async def test_going_live_enqueues_the_bootstrap(wired) -> None: # type: ignore[no-untyped-def]
|
|
fake = wired()
|
|
await hook.on_account_state_changed(
|
|
{"user_id": str(uuid.uuid4()), "account_id": "acc-1", "state": "live"}
|
|
)
|
|
assert fake.enqueued == [{"user_id": fake.enqueued[0]["user_id"], "account_id": "acc-1"}]
|
|
|
|
|
|
async def test_other_states_do_not_enqueue(wired) -> None: # type: ignore[no-untyped-def]
|
|
fake = wired()
|
|
for state in ("backfilling", "error", "paused"):
|
|
await hook.on_account_state_changed(
|
|
{"user_id": str(uuid.uuid4()), "account_id": "acc-1", "state": state}
|
|
)
|
|
assert fake.enqueued == []
|
|
|
|
|
|
async def test_bootstrap_respects_the_setting(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Opting out of provider-Junk learning means no training pass at all."""
|
|
sampled: list[str] = []
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return policy.Settings(
|
|
enabled_accounts=None,
|
|
move_threshold=12.0,
|
|
banner_threshold=6.0,
|
|
network_checks=False,
|
|
learn_from_provider_junk=False,
|
|
)
|
|
|
|
class _M:
|
|
async def sample(self, *_a: object, **kw: object) -> list[uuid.UUID]:
|
|
sampled.append(str(kw.get("special_use")))
|
|
return []
|
|
|
|
class _Fake:
|
|
messages = _M()
|
|
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
|
|
assert sampled == []
|
|
|
|
|
|
async def test_bootstrap_refuses_a_lopsided_corpus(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Three spam beside a thousand ham would teach "everything is ham".
|
|
|
|
Accounts do not sync the provider's spam folder by default, so this is the
|
|
common case, not an edge case.
|
|
"""
|
|
taught: list[bool] = []
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return SETTINGS
|
|
|
|
class _Kv:
|
|
async def get(self, _k: str, _d: object = None) -> list[str]:
|
|
return []
|
|
|
|
async def set(self, _k: str, _v: object) -> None:
|
|
raise AssertionError("must not mark the account done")
|
|
|
|
class _M:
|
|
async def sample(self, *_a: object, **kw: object) -> list[uuid.UUID]:
|
|
n = 3 if kw.get("special_use") == "junk" else 1000
|
|
return [uuid.uuid4() for _ in range(n)]
|
|
|
|
async def raw(self, *_a: object, **_k: object) -> bytes:
|
|
return b"x"
|
|
|
|
class _Fake:
|
|
messages = _M()
|
|
|
|
def kv(self, _u: object) -> _Kv:
|
|
return _Kv()
|
|
|
|
async def _learn(_raw: bytes, **kw: object) -> bool:
|
|
taught.append(bool(kw.get("spam")))
|
|
return True
|
|
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
|
monkeypatch.setattr(jobs.rspamd, "learn", _learn)
|
|
|
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
|
|
assert taught == [], "taught from a corpus too lopsided to learn from"
|
|
|
|
|
|
async def test_bootstrap_caps_ham_at_a_multiple_of_spam(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
marked: list[object] = []
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return SETTINGS
|
|
|
|
class _Kv:
|
|
async def get(self, _k: str, _d: object = None) -> list[str]:
|
|
return []
|
|
|
|
async def set(self, _k: str, v: object) -> None:
|
|
marked.append(v)
|
|
|
|
class _M:
|
|
async def sample(self, *_a: object, **kw: object) -> list[uuid.UUID]:
|
|
n = 20 if kw.get("special_use") == "junk" else 1000
|
|
return [uuid.uuid4() for _ in range(n)]
|
|
|
|
async def raw(self, *_a: object, **_k: object) -> bytes:
|
|
return b"x"
|
|
|
|
class _Fake:
|
|
messages = _M()
|
|
published: list[object] = []
|
|
|
|
def kv(self, _u: object) -> _Kv:
|
|
return _Kv()
|
|
|
|
async def publish(self, _e: str, _p: object) -> None:
|
|
return None
|
|
|
|
taught: list[bool] = []
|
|
|
|
async def _learn(_raw: bytes, **kw: object) -> bool:
|
|
taught.append(bool(kw.get("spam")))
|
|
return True
|
|
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
|
monkeypatch.setattr(jobs.rspamd, "learn", _learn)
|
|
monkeypatch.setattr(jobs, "PAUSE_SECONDS", 0)
|
|
|
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
|
|
assert taught.count(True) == 20
|
|
assert taught.count(False) == 60, "ham should be capped at 3x the spam count"
|
|
assert marked, "a completed bootstrap must mark the account done"
|
|
|
|
|
|
# --- the sweep: bootstrapping must not depend on catching an event -----------
|
|
|
|
|
|
async def test_sweep_visits_every_live_account(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""An account that went live before the plugin existed still gets a warm start."""
|
|
visited: list[tuple[str, str]] = []
|
|
accounts = [(uuid.uuid4(), uuid.uuid4()) for _ in range(3)]
|
|
|
|
class _Accounts:
|
|
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
|
|
return accounts
|
|
|
|
class _Fake:
|
|
accounts = _Accounts()
|
|
|
|
async def _one(_ctx: object, *, user_id: str, account_id: str) -> None:
|
|
visited.append((user_id, account_id))
|
|
|
|
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
|
monkeypatch.setattr(jobs, "bootstrap_account", _one)
|
|
await jobs.bootstrap_sweep({})
|
|
assert visited == [(str(u), str(a)) for u, a in accounts]
|
|
|
|
|
|
async def test_one_failing_account_does_not_stop_the_sweep(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
done: list[str] = []
|
|
accounts = [(uuid.uuid4(), uuid.uuid4()) for _ in range(3)]
|
|
|
|
class _Accounts:
|
|
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
|
|
return accounts
|
|
|
|
class _Fake:
|
|
accounts = _Accounts()
|
|
|
|
async def _one(_ctx: object, *, user_id: str, account_id: str) -> None:
|
|
if account_id == str(accounts[1][1]):
|
|
raise RuntimeError("that mailbox is on fire")
|
|
done.append(account_id)
|
|
|
|
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
|
monkeypatch.setattr(jobs, "bootstrap_account", _one)
|
|
await jobs.bootstrap_sweep({})
|
|
assert done == [str(accounts[0][1]), str(accounts[2][1])]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_dead_engine_never_marks_the_account_bootstrapped(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An engine that refuses the FIRST message must not look like a clean run.
|
|
|
|
This shipped: failure was signalled by negating the count taught so far, so
|
|
a refusal on message one returned `-0` — which is `0`, not negative. The
|
|
caller read that as "learned nothing, fine", marked the account done, and
|
|
the hourly sweep then skipped it forever. The account most in need of a warm
|
|
start was the one guaranteed never to get one.
|
|
"""
|
|
marked: list[object] = []
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return SETTINGS
|
|
|
|
class _Kv:
|
|
async def get(self, _k: str, _d: object = None) -> list[str]:
|
|
return []
|
|
|
|
async def set(self, _k: str, v: object) -> None:
|
|
marked.append(v)
|
|
|
|
class _M:
|
|
async def sample(self, *_a: object, **kw: object) -> list[uuid.UUID]:
|
|
n = 50 if kw.get("special_use") == "junk" else 50
|
|
return [uuid.uuid4() for _ in range(n)]
|
|
|
|
async def raw(self, *_a: object, **_k: object) -> bytes:
|
|
return b"x"
|
|
|
|
class _Fake:
|
|
messages = _M()
|
|
|
|
def kv(self, _u: object) -> _Kv:
|
|
return _Kv()
|
|
|
|
async def publish(self, *_a: object, **_k: object) -> None:
|
|
"""Present so this test fails on its assertion, not on a bare fake."""
|
|
|
|
async def _dead(_raw: bytes, **_kw: object) -> bool:
|
|
return False # every learn refused, from the very first one
|
|
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
|
monkeypatch.setattr(jobs.rspamd, "learn", _dead)
|
|
|
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
|
|
assert marked == [], "marked the account bootstrapped after learning nothing"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_engine_that_dies_midway_also_leaves_the_account_unmarked(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The case the original sentinel did handle — keep it handled."""
|
|
marked: list[object] = []
|
|
calls = {"n": 0}
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return SETTINGS
|
|
|
|
class _Kv:
|
|
async def get(self, _k: str, _d: object = None) -> list[str]:
|
|
return []
|
|
|
|
async def set(self, _k: str, v: object) -> None:
|
|
marked.append(v)
|
|
|
|
class _M:
|
|
async def sample(self, *_a: object, **_kw: object) -> list[uuid.UUID]:
|
|
return [uuid.uuid4() for _ in range(50)]
|
|
|
|
async def raw(self, *_a: object, **_k: object) -> bytes:
|
|
return b"x"
|
|
|
|
class _Fake:
|
|
messages = _M()
|
|
|
|
def kv(self, _u: object) -> _Kv:
|
|
return _Kv()
|
|
|
|
async def publish(self, *_a: object, **_k: object) -> None:
|
|
"""As above."""
|
|
|
|
async def _dies(_raw: bytes, **_kw: object) -> bool:
|
|
calls["n"] += 1
|
|
return calls["n"] <= 5
|
|
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
monkeypatch.setattr(jobs, "_SDK", _Fake())
|
|
monkeypatch.setattr(jobs.rspamd, "learn", _dies)
|
|
|
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=str(uuid.uuid4()))
|
|
assert marked == [], "marked the account bootstrapped after a partial run"
|
|
|
|
|
|
def test_compose_gives_the_app_the_controller_password() -> None:
|
|
"""The learning loop needs the controller password; classification does not.
|
|
|
|
Rspamd splits over two ports: the scan worker takes /checkv2 anonymously,
|
|
the controller demands a password for /stat and /learnspam. Ship the password
|
|
to rspamd but not to the api/worker containers and the result is a filter
|
|
that looks healthy — mail is still classified — while every learn is refused
|
|
401 and swallowed by the plugin's fail-open path. The only visible trace is
|
|
`"bayes": null` on the stats endpoint.
|
|
|
|
That shipped, so it is worth a test that reads the compose files rather than
|
|
trusting review to catch the missing line again.
|
|
"""
|
|
import pathlib
|
|
|
|
root = pathlib.Path(__file__).resolve().parents[3]
|
|
for rel, anchor in (
|
|
("docker-compose.yml", "x-backend-env:"),
|
|
("docker/docker-compose.yml", "environment: &worker-env"),
|
|
):
|
|
text = (root / rel).read_text()
|
|
assert anchor in text, f"{rel}: shared env block moved; update this test"
|
|
start = text.index(anchor)
|
|
# The shared block runs to the next top-level key.
|
|
tail = text[start:]
|
|
end = start + (tail.index("\nservices:") if "\nservices:" in tail else len(tail))
|
|
block = text[start:end]
|
|
assert "RSPAMD_CONTROLLER_PASSWORD" in block, (
|
|
f"{rel}: the app containers do not receive RSPAMD_CONTROLLER_PASSWORD — "
|
|
"antispam will classify but never learn"
|
|
)
|
|
|
|
|
|
class _BootKv:
|
|
"""A kv store holding a given value per key; records what gets written."""
|
|
|
|
def __init__(self, values: dict[str, object]) -> None:
|
|
self.values = values
|
|
self.written: list[tuple[str, object]] = []
|
|
|
|
async def get(self, key: str, default: object = None) -> object:
|
|
return self.values.get(key, default)
|
|
|
|
async def set(self, key: str, value: object) -> None:
|
|
self.written.append((key, value))
|
|
|
|
|
|
def _boot_fake(kv: _BootKv, taught: list[bool]) -> object:
|
|
class _M:
|
|
async def sample(self, *_a: object, **_kw: object) -> list[uuid.UUID]:
|
|
return [uuid.uuid4() for _ in range(12)]
|
|
|
|
async def raw(self, *_a: object, **_k: object) -> bytes:
|
|
return b"x"
|
|
|
|
class _Fake:
|
|
messages = _M()
|
|
|
|
def kv(self, _u: object) -> _BootKv:
|
|
return kv
|
|
|
|
async def publish(self, *_a: object, **_k: object) -> None:
|
|
taught.append(True)
|
|
|
|
return _Fake()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_old_style_marker_does_not_block_the_bootstrap(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Markers under the old key may come from the `-0` bug: done, nothing taught."""
|
|
account = str(uuid.uuid4())
|
|
kv = _BootKv({"_bootstrapped": [account]})
|
|
ran: list[bool] = []
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return SETTINGS
|
|
|
|
async def _ok(_raw: bytes, **_kw: object) -> bool:
|
|
return True
|
|
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
monkeypatch.setattr(jobs, "_SDK", _boot_fake(kv, ran))
|
|
monkeypatch.setattr(jobs.rspamd, "learn", _ok)
|
|
monkeypatch.setattr(jobs, "PAUSE_SECONDS", 0)
|
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=account)
|
|
assert ran == [True]
|
|
assert kv.written == [("bootstrap_done", [account])]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_done_accounts_are_skipped_unless_forced(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
account = str(uuid.uuid4())
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return SETTINGS
|
|
|
|
async def _ok(_raw: bytes, **_kw: object) -> bool:
|
|
return True
|
|
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
monkeypatch.setattr(jobs.rspamd, "learn", _ok)
|
|
monkeypatch.setattr(jobs, "PAUSE_SECONDS", 0)
|
|
|
|
kv, ran = _BootKv({"bootstrap_done": [account]}), []
|
|
monkeypatch.setattr(jobs, "_SDK", _boot_fake(kv, ran))
|
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=account)
|
|
assert ran == [] # the hourly sweep leaves it alone
|
|
|
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=account, force=True)
|
|
assert ran == [True] # the button re-runs it
|
|
assert kv.written == [] # already listed: not appended twice
|