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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user