Rspamd answers on two ports and only one of them asks for a password:
the scan worker takes /checkv2 anonymously, the controller demands
`Password:` for /stat and /learnspam. `webmail.sh init` generates
RSPAMD_CONTROLLER_PASSWORD and docker-compose hands it to the rspamd
container — but the shared x-backend-env block that feeds api and
worker-* never carried it. The plugin reads it from the environment,
found "", and sent no header.
So classification worked and learning never did. Every learn returned
401 and the plugin swallowed it, as it is built to: an engine problem
must not lose mail. Bootstrap logged "engine unavailable", passive
learning from Inbox->Spam moves taught nothing, and the single visible
trace was `"bayes": null` on GET /api/plugins/antispam/stats, on an
install whose verdict counts were ticking up normally.
Verified against a live engine: with no password /stat and /learnspam
both 401 and rspamd.learn() returns False; with it, stats returns and
learn returns True.
- pass RSPAMD_CONTROLLER_PASSWORD to the app containers, in both the
production and dev compose files
- doctor now makes the real call from inside the api container, with
the password that container actually holds, and says plainly that
nothing will be learned if it fails
- a test reads both compose files, because this is a missing line that
review already let through once
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
362 lines
12 KiB
Python
362 lines
12 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=[],
|
|
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=[],
|
|
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])]
|
|
|
|
|
|
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.
|
|
block = text[start : text.index("\nservices:", start) if "\nservices:" in text[start:] else len(text)]
|
|
assert "RSPAMD_CONTROLLER_PASSWORD" in block, (
|
|
f"{rel}: the app containers do not receive RSPAMD_CONTROLLER_PASSWORD — "
|
|
"antispam will classify but never learn"
|
|
)
|