Antispam: plugin docs, the learning acceptance test, and a bootstrap that refuses bad corpora

docs/plugins.md described an SDK that no longer exists. It now covers
messages.raw/sample, actions.move_to_special, the envelope's account_id /
mailbox_special_use / account_state, the message.changed transition payload, the
message.actions.menu slot, Slot.title and .wm-menu-item — with the reasons, not
just the signatures. M3's acceptance is that a plugin can be written from this
file alone, and it had drifted.

Acceptance 4 (§C.8): a test that teaches 20 spam + 20 ham to a real engine and
checks the classifier generalises to held-out mail, keeps ham under the banner
threshold, and does not leak between users. It skips when no engine is reachable.
It does NOT assert the literal move_threshold — a synthetic fixture cannot reach
12.0 once the SMTP-time symbols are zeroed, which is the whole point of the
tuning. Recorded as OQ-8 rather than quietly asserting something weaker.

Bayes ignores training until min_learns, stock 200. For a personal mailbox that
means every correction appears to be ignored for months, so it is now 20.

The bootstrap would have made this filter worse. Accounts do not sync their
provider's spam folder by default, so the real corpus here is 3 junk beside 1,294
aged inbox — teaching both as found trains "everything is ham". It now refuses
below 10 spam samples (leaving the account unmarked so a later run can do it
properly) and otherwise caps ham at 3x the spam count.

Run against the live account, it correctly declined:
  "only 3 spam samples (need 10); enable spam-folder sync to build a corpus"

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 20:34:50 +02:00
co-authored by Claude Opus 5
parent 76beb9d527
commit 5aa133e18c
6 changed files with 396 additions and 12 deletions
@@ -36,6 +36,18 @@ HAM_MIN_AGE_DAYS = 30
# live mail must never queue behind a training backlog.
PAUSE_SECONDS = 0.05
# A Bayes classifier learns the ratio it is shown. Accounts do not sync their
# provider's Junk folder by default (accounts.sync_spam is off), so the spam side
# of this corpus is routinely tiny while the inbox side is thousands — teaching
# both as found would train "everything is ham" and make the filter worse than
# leaving it untrained. Two guards:
#
# below MIN_SPAM_CORPUS teach nothing at all, and leave the account unmarked
# so a later run can do it properly
# above it teach at most MAX_HAM_RATIO times as much ham as spam
MIN_SPAM_CORPUS = 10
MAX_HAM_RATIO = 3
_DONE_KEY = "_bootstrapped"
@@ -78,9 +90,21 @@ async def bootstrap_account(
limit=HAM_SAMPLE,
older_than_days=HAM_MIN_AGE_DAYS,
)
if not spam_ids and not ham_ids:
if len(spam_ids) < MIN_SPAM_CORPUS:
# Nothing to be learned from an empty Junk folder, and plenty of harm in
# learning only the ham beside it. Usually means the account is not
# syncing its provider's spam folder.
logger.info(
"antispam bootstrap for %s skipped: only %d spam samples (need %d); "
"enable spam-folder sync on the account to build a corpus",
account_id,
len(spam_ids),
MIN_SPAM_CORPUS,
)
return
ham_ids = ham_ids[: len(spam_ids) * MAX_HAM_RATIO]
spam_taught = await _teach(uid, spam_ids, spam=True)
if spam_taught < 0:
logger.warning(