From b1222769586b79e5de0ded0f74b87fc5f0d41bcf Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Tue, 20 Oct 2026 10:14:05 +0200 Subject: [PATCH] Antispam: online checks no longer switch off Bayes; replies teach ham - The "online reputation checks" rspamd profile used groups_enabled, which means ONLY those groups: with the switch on, a message got the RBL lookups alone (1 rule instead of 19, score 0.00) and Bayes never ran, so every spam lesson was ignored. It now disables nothing. - A reply or reply-all teaches the answered message as ham (not forwards or new mail, not one's own Sent/Drafts, never over an explicit spam mark; once per message). message.sent now carries the draft's kind and reply_to_message_id. Co-Authored-By: Claude Opus 5.5 --- backend/app/outbox/jobs.py | 6 +++ .../tests/plugins/test_antispam_learning.py | 47 +++++++++++++++++++ docker/rspamd/local.d/settings.conf | 9 +++- .../webmail_plugin_antispam/__init__.py | 3 ++ .../webmail_plugin_antispam/hook.py | 34 ++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/backend/app/outbox/jobs.py b/backend/app/outbox/jobs.py index 57dbc7d..3059fe2 100644 --- a/backend/app/outbox/jobs.py +++ b/backend/app/outbox/jobs.py @@ -338,6 +338,12 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None: # need to read the outbox row. payload to/cc are "Name " strings. "to": _addr_dicts(draft.payload.get("to")), "cc": _addr_dicts(draft.payload.get("cc")), + # What it answers: "reply" / "reply_all" / "forward" / "new", and the + # message (a reply says the user wanted that mail — antispam learns it). + "kind": draft.kind, + "reply_to_message_id": ( + str(draft.reply_to_message_id) if draft.reply_to_message_id else None + ), }, ) await bus.publish( diff --git a/backend/tests/plugins/test_antispam_learning.py b/backend/tests/plugins/test_antispam_learning.py index 857d4c8..fab4279 100644 --- a/backend/tests/plugins/test_antispam_learning.py +++ b/backend/tests/plugins/test_antispam_learning.py @@ -541,3 +541,50 @@ async def test_done_accounts_are_skipped_unless_forced(monkeypatch: pytest.Monke 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 + + +def _sent(kind: str = "reply", replied: str | None = "m") -> dict[str, Any]: + return { + "user_id": str(uuid.uuid4()), + "kind": kind, + "reply_to_message_id": str(uuid.uuid4()) if replied else None, + } + + +async def test_a_reply_teaches_ham(wired) -> None: # type: ignore[no-untyped-def] + fake = wired() + await hook.on_message_sent(_sent("reply_all")) + assert fake.taught == [False] + assert fake.recorded == [{"verdict": "ham", "action": "none"}] + assert fake.published[0][1]["verdict"] == "ham" + + +@pytest.mark.parametrize("kind", ["forward", "new"]) +async def test_forwards_and_new_mail_teach_nothing(wired, kind) -> None: # type: ignore[no-untyped-def] + fake = wired() + await hook.on_message_sent(_sent(kind)) + assert fake.taught == [] + + +@pytest.mark.parametrize("learned", ["ham", "spam"]) +async def test_a_reply_does_not_overrule_or_repeat_a_lesson(wired, learned) -> None: # type: ignore[no-untyped-def] + fake = wired(learned=learned) + await hook.on_message_sent(_sent()) + assert fake.taught == [] + + +async def test_a_reply_to_ones_own_message_teaches_nothing(wired) -> None: # type: ignore[no-untyped-def] + fake = wired() + + async def _own(*_a: object, **_k: object) -> dict[str, Any]: + return {"account_id": "acc-1", "mailbox_special_use": "sent"} + + fake.messages.envelope = _own + await hook.on_message_sent(_sent()) + assert fake.taught == [] + + +async def test_a_reply_while_the_engine_is_down_records_nothing(wired) -> None: # type: ignore[no-untyped-def] + fake = wired(engine_up=False) + await hook.on_message_sent(_sent()) + assert fake.taught == [False] and fake.recorded == [] diff --git a/docker/rspamd/local.d/settings.conf b/docker/rspamd/local.d/settings.conf index 3979950..e9e1fb6 100644 --- a/docker/rspamd/local.d/settings.conf +++ b/docker/rspamd/local.d/settings.conf @@ -23,11 +23,16 @@ webmail_offline { } } -# Opted in: Spamhaus ZEN + DBL and DNSWL, as narrowed in rbl.conf. +# Opted in: Spamhaus ZEN + DBL and DNSWL, as narrowed in rbl.conf — on top of +# everything else. Nothing to switch on (those modules are enabled globally; +# only the offline profile turns them off), so this profile changes nothing. +# NOT `groups_enabled`: that means "ONLY these groups", and it left a user who +# allowed online checks with the reputation lookups alone — no Bayes, no content +# rules, every lesson they taught ignored. webmail_network { id = "webmail_network"; priority = high; apply { - groups_enabled = ["rbl", "surbl"]; + groups_disabled = []; } } diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py index 37a549f..5169728 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py @@ -23,6 +23,7 @@ from webmail_plugin_antispam.hook import ( on_account_state_changed, on_message_changed, on_message_new, + on_message_sent, ) from webmail_plugin_antispam.jobs import bootstrap_account, bootstrap_sweep from webmail_plugin_antispam.router import router @@ -37,6 +38,8 @@ plugin = Plugin( "message.new": on_message_new, # Passive learning: where the user files things teaches the filter (§C.5). "message.changed": on_message_changed, + # ... and so does what the user answers: a reply teaches "not spam". + "message.sent": on_message_sent, # An account going live is the cue to warm Bayes from its history (§C.5). "account.state_changed": on_account_state_changed, }, diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/hook.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/hook.py index f0ea327..4d4ff28 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/hook.py +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/hook.py @@ -223,6 +223,40 @@ async def on_message_changed(payload: dict[str, Any]) -> None: ) +async def on_message_sent(payload: dict[str, Any]) -> None: + """Passive learning from replies: answering a message says it was wanted. + + Only real replies (not forwards — forwarding a scam to warn someone is the + opposite of wanting it — and not new mail), to mail someone sent the user + (not their own, in Sent or Drafts). A message the user marked as spam + stays spam: an explicit verdict outranks this one. Taught once. + """ + if payload.get("kind") not in ("reply", "reply_all"): + return + uid_raw, mid_raw = payload.get("user_id"), payload.get("reply_to_message_id") + if not isinstance(uid_raw, str) or not isinstance(mid_raw, str): + return + user_id, message_id = uuid.UUID(uid_raw), uuid.UUID(mid_raw) + if await _already_learned(user_id, message_id) in ("ham", "spam"): + return + + env = await _SDK.messages.envelope(user_id, message_id) + if env is None or env.get("mailbox_special_use") in ("sent", "drafts"): + return + settings = await policy.load_settings(user_id) + if not settings.covers(str(env.get("account_id") or "")): + return + raw = await _SDK.messages.raw(user_id, message_id) + if raw is None or not await rspamd.learn(raw, user_key=str(user_id), spam=False): + return # engine down: unrecorded, a later lesson may still teach it + + await _remember_learned(user_id, message_id, verdict="ham", action="none") + await _SDK.publish( + "plugin.antispam.learned", + {"user_id": str(user_id), "message_id": str(message_id), "verdict": "ham"}, + ) + + async def on_account_state_changed(payload: dict[str, Any]) -> None: """When an account finishes its initial import, warm Bayes from what it holds.