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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <email>" strings.
|
# need to read the outbox row. payload to/cc are "Name <email>" strings.
|
||||||
"to": _addr_dicts(draft.payload.get("to")),
|
"to": _addr_dicts(draft.payload.get("to")),
|
||||||
"cc": _addr_dicts(draft.payload.get("cc")),
|
"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(
|
await bus.publish(
|
||||||
|
|||||||
@@ -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)
|
await jobs.bootstrap_account({}, user_id=str(uuid.uuid4()), account_id=account, force=True)
|
||||||
assert ran == [True] # the button re-runs it
|
assert ran == [True] # the button re-runs it
|
||||||
assert kv.written == [] # already listed: not appended twice
|
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 == []
|
||||||
|
|||||||
@@ -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 {
|
webmail_network {
|
||||||
id = "webmail_network";
|
id = "webmail_network";
|
||||||
priority = high;
|
priority = high;
|
||||||
apply {
|
apply {
|
||||||
groups_enabled = ["rbl", "surbl"];
|
groups_disabled = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from webmail_plugin_antispam.hook import (
|
|||||||
on_account_state_changed,
|
on_account_state_changed,
|
||||||
on_message_changed,
|
on_message_changed,
|
||||||
on_message_new,
|
on_message_new,
|
||||||
|
on_message_sent,
|
||||||
)
|
)
|
||||||
from webmail_plugin_antispam.jobs import bootstrap_account, bootstrap_sweep
|
from webmail_plugin_antispam.jobs import bootstrap_account, bootstrap_sweep
|
||||||
from webmail_plugin_antispam.router import router
|
from webmail_plugin_antispam.router import router
|
||||||
@@ -37,6 +38,8 @@ plugin = Plugin(
|
|||||||
"message.new": on_message_new,
|
"message.new": on_message_new,
|
||||||
# Passive learning: where the user files things teaches the filter (§C.5).
|
# Passive learning: where the user files things teaches the filter (§C.5).
|
||||||
"message.changed": on_message_changed,
|
"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).
|
# An account going live is the cue to warm Bayes from its history (§C.5).
|
||||||
"account.state_changed": on_account_state_changed,
|
"account.state_changed": on_account_state_changed,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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:
|
async def on_account_state_changed(payload: dict[str, Any]) -> None:
|
||||||
"""When an account finishes its initial import, warm Bayes from what it holds.
|
"""When an account finishes its initial import, warm Bayes from what it holds.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user