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
+12
View File
@@ -31,6 +31,18 @@ mail path:
- `PluginMessages.raw(user_id, message_id) -> bytes | None`.
- `envelope()` now also returns `account_id` and `mailbox_special_use`.
**C2 needed two more, on the same reasoning:**
4. **§C.5 passive learning** reads the user's own filing as a verdict, which
needs the *transition* — "now in Junk" and "moved out of Junk" are different
facts. `message.changed` carried only the destination, so `move_job` now also
publishes `previous_mailbox_id`, `special_use` and `previous_special_use`.
5. **§C.5 provider bootstrap** samples up to 500 provider-Junk messages and 500
aged Inbox messages. The SDK could fetch a message by id but not enumerate a
folder, so `PluginMessages.sample(account_id, special_use, limit,
older_than_days)` returns ids only — the caller pulls bodies one at a time,
so a 500-message sample never materialises 500 bodies.
**Reading of "zero core diffs":** taken to mean the plugin must not require
changes to core *mail flow or behaviour* — with the plugin absent, mail handling
is byte-identical, which remains true. Extending the plugin SDK is what the SDK