Antispam C2: learn from what the user does

Explicit teaching shipped with C1 (the Spam / Not spam buttons). This adds the
two halves that need no buttons at all.

Passive learning (§C.5). Moving mail into Spam says "this is spam" more honestly
than any button, because the user is doing it for their own reasons rather than
to train a filter; moving it back out says the opposite. Only those two
transitions teach — Spam to Trash is someone tidying up, Inbox to Archive says
nothing about spam. Teaching the same message the same thing twice is skipped,
since users move things back and forth, but reversing a lesson is allowed. If
the engine is down nothing is recorded, so a later move can teach it again.

This needed the transition, not just the destination: `message.changed` carried
only where a message landed, so `move_job` now also publishes
previous_mailbox_id and both special_use values.

Provider bootstrap (§C.5). An account going live enqueues a job that teaches
Bayes from mail the user already owns — up to 500 messages from the provider's
Junk folder as spam, and up to 500 Inbox messages older than 30 days as ham.
This is the constructive use of the history that classification deliberately
refuses to act on: the same backfill, read instead of judged. It sleeps between
messages so live classification never queues behind training, stops rather than
grinding if the engine disappears, and marks the account done in KV so it runs
once.

That needed `PluginMessages.sample`: the SDK could fetch a message by id but not
enumerate a folder. Ids only — bodies are pulled one at a time, so a 500-message
sample never materialises 500 bodies.

Verified against the running engine: teaching moved its `learned` counter 0 -> 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-17 08:36:51 +02:00
co-authored by Claude Opus 5
parent 8fc97e2f27
commit 149c11581e
7 changed files with 479 additions and 3 deletions
@@ -19,7 +19,13 @@ from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import Plugin, Slot, UiManifest
from webmail_plugin_antispam.hook import on_message_new
from app.plugins.sdk import JobSpec
from webmail_plugin_antispam.hook import (
on_account_state_changed,
on_message_changed,
on_message_new,
)
from webmail_plugin_antispam.jobs import bootstrap_account
from webmail_plugin_antispam.router import router
_HERE = Path(__file__).parent
@@ -28,7 +34,16 @@ _UI = "/api/plugins/antispam/ui"
plugin = Plugin(
name="antispam",
version="0.1.0",
hooks={"message.new": on_message_new},
hooks={
"message.new": on_message_new,
# Passive learning: where the user files things teaches the filter (§C.5).
"message.changed": on_message_changed,
# An account going live is the cue to warm Bayes from its history (§C.5).
"account.state_changed": on_account_state_changed,
},
# Enqueue-on-demand (no schedule); singleton so a flapping account cannot
# start two training passes over the same mailbox.
jobs=[JobSpec(func=bootstrap_account, schedule=None, singleton=True)],
router=router,
migrations=str(_HERE / "migrations" / "versions"),
ui_manifest=UiManifest(
@@ -139,3 +139,102 @@ async def on_message_new(payload: dict[str, Any]) -> None:
"action": action,
},
)
async def _already_learned(user_id: uuid.UUID, message_id: uuid.UUID) -> str | None:
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(
"SELECT learned FROM plg_antispam_verdicts "
"WHERE message_id = :m AND user_id = :u"
),
{"m": str(message_id), "u": str(user_id)},
)
).first()
return None if row is None else row[0]
async def _remember_learned(
user_id: uuid.UUID, message_id: uuid.UUID, *, verdict: str, action: str
) -> None:
"""Record the teaching. Upsert, because the message may never have been
classified by us — provider-filed spam the user drags back has no verdict row."""
async with _SDK.session(user_id) as s:
await s.execute(
text(
"INSERT INTO plg_antispam_verdicts "
" (message_id, user_id, score, symbols, action, learned) "
"VALUES (:m, :u, 0, '[]'::jsonb, :a, :l) "
"ON CONFLICT (message_id) DO UPDATE SET learned = EXCLUDED.learned"
),
{"m": str(message_id), "u": str(user_id), "a": action, "l": verdict},
)
await s.commit()
async def on_message_changed(payload: dict[str, Any]) -> None:
"""Passive learning from where the user files things (SPEC §C.5).
Moving mail into Spam says "this is spam" more honestly than any button,
because the user is doing it for their own reasons rather than to train a
filter. Moving it back out of Spam says the opposite.
Only those two transitions teach. Spam -> Trash is a user tidying up, not a
verdict; Inbox -> Archive says nothing about spam at all.
"""
uid_raw, mid_raw = payload.get("user_id"), payload.get("message_id")
if not isinstance(uid_raw, str) or not isinstance(mid_raw, str):
return
now, before = payload.get("special_use"), payload.get("previous_special_use")
if now == before:
return
if now == "junk":
verdict, action = "spam", "moved"
elif before == "junk" and now == "inbox":
verdict, action = "ham", "none"
else:
return
user_id, message_id = uuid.UUID(uid_raw), uuid.UUID(mid_raw)
# Teaching the same message the same thing twice skews the corpus, and users
# do move things back and forth.
if await _already_learned(user_id, message_id) == verdict:
return
settings = await policy.load_settings(user_id)
env = await _SDK.messages.envelope(user_id, message_id)
if env is None or not settings.covers(str(env.get("account_id") or "")):
return
raw = await _SDK.messages.raw(user_id, message_id)
if raw is None:
return
if not await rspamd.learn(raw, user_key=str(user_id), spam=(verdict == "spam")):
# Engine down: leave `learned` unset so a later move can teach it again.
return
await _remember_learned(user_id, message_id, verdict=verdict, action=action)
await _SDK.publish(
"plugin.antispam.learned",
{"user_id": str(user_id), "message_id": str(message_id), "verdict": verdict},
)
async def on_account_state_changed(payload: dict[str, Any]) -> None:
"""When an account finishes its initial import, warm Bayes from what it holds.
This is the constructive use of the backfill that classification deliberately
skips (§C.4): the same history we refuse to act on is exactly what we want to
learn from.
"""
if payload.get("state") != "live":
return
user_id, account_id = payload.get("user_id"), payload.get("account_id")
if not isinstance(user_id, str) or not isinstance(account_id, str):
return
from webmail_plugin_antispam.jobs import bootstrap_account
await _SDK.enqueue(bootstrap_account, user_id=user_id, account_id=account_id)
@@ -0,0 +1,112 @@
"""Provider-Junk bootstrap (SPEC §C.5).
A per-user Bayes classifier knows nothing on day one, which is exactly when the
filter is asked to judge the most mail. But the user already owns a labelled
corpus: whatever their provider filed as Junk is spam they agreed to lose, and
old Inbox mail they never moved is ham they agreed to keep. Feeding both gives
Bayes a warm start without asking the user to train anything.
Deliberately unhurried. This is a background nicety, not a deadline: it yields
between messages so it cannot monopolise the worker or the engine, and it gives
up quietly on anything that looks wrong rather than retrying into a wall.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Any
from app.plugins.sdk import sdk
from webmail_plugin_antispam import policy, rspamd
logger = logging.getLogger("plg.antispam")
_SDK = sdk("antispam")
# §C.5: up to 500 of each. Enough to shape a classifier, small enough that the
# whole pass is minutes rather than hours.
SPAM_SAMPLE = 500
HAM_SAMPLE = 500
# Ham comes from mail old enough that the user has plainly not triaged it — a
# message from this morning is not yet evidence of anything.
HAM_MIN_AGE_DAYS = 30
# A breath between messages: the engine is shared with live classification, and
# live mail must never queue behind a training backlog.
PAUSE_SECONDS = 0.05
_DONE_KEY = "_bootstrapped"
async def _teach(user_id: uuid.UUID, ids: list[uuid.UUID], *, spam: bool) -> int:
taught = 0
for message_id in ids:
raw = await _SDK.messages.raw(user_id, message_id)
if raw is None:
continue
if not await rspamd.learn(raw, user_key=str(user_id), spam=spam):
# Engine gone. Stop rather than grind through hundreds of failures;
# the account stays unmarked, so a later run picks it up again.
return -taught
taught += 1
await asyncio.sleep(PAUSE_SECONDS)
return taught
async def bootstrap_account(
ctx: dict[str, Any], *, user_id: str, account_id: str
) -> None:
"""Teach Bayes from one account's existing mail. Safe to call more than once."""
uid, acc = uuid.UUID(user_id), uuid.UUID(account_id)
settings = await policy.load_settings(uid)
if not settings.learn_from_provider_junk or not settings.covers(account_id):
return
done = await _SDK.kv(uid).get(_DONE_KEY, [])
if account_id in done:
return
spam_ids = await _SDK.messages.sample(
uid, account_id=acc, special_use="junk", limit=SPAM_SAMPLE
)
ham_ids = await _SDK.messages.sample(
uid,
account_id=acc,
special_use="inbox",
limit=HAM_SAMPLE,
older_than_days=HAM_MIN_AGE_DAYS,
)
if not spam_ids and not ham_ids:
return
spam_taught = await _teach(uid, spam_ids, spam=True)
if spam_taught < 0:
logger.warning(
"antispam bootstrap for %s stopped: engine unavailable", account_id
)
return
ham_taught = await _teach(uid, ham_ids, spam=False)
if ham_taught < 0:
logger.warning(
"antispam bootstrap for %s stopped: engine unavailable", account_id
)
return
await _SDK.kv(uid).set(_DONE_KEY, [*done, account_id])
logger.info(
"antispam bootstrap for %s: learned %d spam, %d ham",
account_id,
spam_taught,
ham_taught,
)
await _SDK.publish(
"plugin.antispam.bootstrapped",
{
"user_id": user_id,
"account_id": account_id,
"spam": spam_taught,
"ham": ham_taught,
},
)