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:
@@ -3,6 +3,38 @@
|
||||
Per the setup rules, deviations and unresolved decisions are recorded here rather
|
||||
than guessed silently.
|
||||
|
||||
## OQ-8 — Appendix C acceptance 4's threshold is not reachable by a fixture
|
||||
|
||||
**Status:** resolved by testing the property rather than the number; recorded
|
||||
because the test deviates from the spec's literal wording.
|
||||
|
||||
**The criterion (§C.8.4):** after 20 spam + 20 ham teachings, "a held-out
|
||||
obvious-spam fixture scores above `move_threshold` for that user and a ham
|
||||
fixture below `banner_threshold`".
|
||||
|
||||
**What happens:** the ham half holds easily. The spam half does not — a held-out
|
||||
spam fixture scores around 0.9 against a 12.0 threshold, even though `BAYES_SPAM`
|
||||
fires correctly and the engine reports 160+ messages learned.
|
||||
|
||||
**Why, and why it is not a bug:** real spam crosses 12.0 by accumulating many
|
||||
symbols, most of which describe its *delivery* — no DKIM, unknown sending host,
|
||||
dates that do not line up. This deployment deliberately zeroes those
|
||||
(`docker/rspamd/local.d/groups.conf`), because scoring already-delivered mail on
|
||||
SMTP-time facts was firing on 11.3% of ordinary mail. A synthetic fixture has no
|
||||
delivery history at all, so once those symbols contribute nothing, almost the
|
||||
only thing left to score is Bayes itself — and one classifier symbol cannot carry
|
||||
a message to 12.0 on its own, nor should it.
|
||||
|
||||
Asserting the literal number would therefore require either un-tuning the filter
|
||||
or writing fixtures that fake plausible delivery headers — testing the fixture
|
||||
generator rather than the learning loop.
|
||||
|
||||
**What the test asserts instead** (`tests/plugins/test_antispam_corpus.py`):
|
||||
after training, Bayes recognises held-out spam it has never seen; it does not cry
|
||||
spam at held-out ham; held-out ham stays below `banner_threshold`; spam outscores
|
||||
ham; and an untaught user sees neither the symbol nor the score — the per-user
|
||||
isolation the criterion also asks for.
|
||||
|
||||
## OQ-6 — Appendix C needs SDK surfaces the frozen §11 API does not have
|
||||
|
||||
**Status:** resolved by additive SDK extension; recorded here because C.8.1 says
|
||||
|
||||
+73
-11
@@ -98,18 +98,55 @@ s = sdk("myplugin")
|
||||
| `s.blob` | blob store (`store`/`load`/`exists`/`remove`), keyed by user |
|
||||
| `s.outbox` | draft/send helpers (`upsert_draft`, `create_outbox`) |
|
||||
| `await s.http_get(url)` | HTTPS GET through the SSRF guard (blocks private IPs) |
|
||||
| `s.messages` | read a message: `envelope(user_id, id)`, `header(user_id, id, name)` |
|
||||
| `s.actions` | act on a message: `set_flags(...)`, `trash(user_id, id)` |
|
||||
| `s.messages` | read a message: `envelope`, `header`, `raw`, `sample` |
|
||||
| `s.actions` | act on a message: `set_flags`, `trash`, `move_to_special` |
|
||||
|
||||
Plus, for routers: `CurrentUserId` (auth dependency → the user's uuid) and
|
||||
`require_csrf` (guard mutating routes).
|
||||
|
||||
`s.messages.envelope(user_id, message_id)` returns
|
||||
`{message_id, subject, from_email, from_name, to: [{email,name}], flags}` or `None`.
|
||||
`s.messages.header(user_id, message_id, "List-Unsubscribe")` returns a raw header.
|
||||
`s.actions.set_flags(user_id, id, add=["\\Seen"], remove=[])` — user-settable flags
|
||||
only (`\Seen`, `\Flagged` [= "Pin"], `\Answered`). `s.actions.trash(user_id, id)`
|
||||
moves the message's thread to Trash (same as the UI Delete).
|
||||
### Reading a message
|
||||
|
||||
`s.messages.envelope(user_id, message_id)` → `None`, or:
|
||||
|
||||
```python
|
||||
{message_id, subject, from_email, from_name, to: [{email, name}], flags,
|
||||
account_id, # which account it arrived on
|
||||
mailbox_special_use, # "inbox" | "junk" | "sent" | … | None
|
||||
account_state} # "backfilling" | "live" | …
|
||||
```
|
||||
|
||||
The last three matter more than they look. `message.new` fires for imported
|
||||
history as well as new arrivals, and for every folder — so a plugin that should
|
||||
only react to genuinely new inbox mail has to check `account_state == "live"`
|
||||
and `mailbox_special_use == "inbox"` itself. Acting on a backfill means acting on
|
||||
years of someone's archive, inline, in the sync loop.
|
||||
|
||||
`s.messages.header(user_id, id, "List-Unsubscribe")` returns one raw header.
|
||||
|
||||
`s.messages.raw(user_id, id)` returns the whole stored RFC822 as bytes, or `None`
|
||||
when the message isn't the user's or its blob is unreadable. For engines that
|
||||
need headers *and* body — spam scoring, signature checks.
|
||||
|
||||
`s.messages.sample(user_id, account_id=…, special_use="junk", limit=500,
|
||||
older_than_days=30)` returns message **ids** from one folder, newest first. For
|
||||
building a corpus out of mail the user already has. Ids only, so the caller pulls
|
||||
bodies one at a time rather than materialising 500 messages at once.
|
||||
|
||||
### Acting on a message
|
||||
|
||||
`s.actions.set_flags(user_id, id, add=["\\Seen"], remove=[])` — user-settable
|
||||
flags only (`\Seen`, `\Flagged` [= "Pin"], `\Answered`).
|
||||
|
||||
`s.actions.trash(user_id, id)` moves the message's whole **thread** to Trash
|
||||
(same as the UI Delete).
|
||||
|
||||
`s.actions.move_to_special(user_id, id, "junk")` moves **one message** into the
|
||||
account's folder with that special-use, on the same path as the UI's move:
|
||||
optimistic DB update, then the IMAP move, then `message.changed`. Per-message on
|
||||
purpose — filing one spam reply must not drag a legitimate conversation with it.
|
||||
Returns `False` (never raises) when the message isn't the user's, the account has
|
||||
no such folder, or it is already there; treat that as "could not act", because
|
||||
mail must still arrive for an account whose server has no Junk folder.
|
||||
|
||||
---
|
||||
|
||||
@@ -133,6 +170,16 @@ Keyed by `(plugin, user_id, key)` — users can't see each other's values.
|
||||
|
||||
Subscribe to §8.3 events (`message.new`, `flags.changed`, `thread.updated`, …).
|
||||
Handlers are `async def h(payload: dict) -> None`; every payload carries `user_id`.
|
||||
|
||||
`message.changed` describes a **move**, not just a destination:
|
||||
|
||||
```python
|
||||
{user_id, message_id, thread_id, mailbox_id,
|
||||
previous_mailbox_id, special_use, previous_special_use}
|
||||
```
|
||||
|
||||
Read the pair, not `special_use` alone — "now in Junk" and "moved out of Junk"
|
||||
are different facts, and only the transition distinguishes them.
|
||||
They fire wherever the event is published — `message.new` originates in the **worker**.
|
||||
|
||||
```python
|
||||
@@ -276,8 +323,19 @@ async def bundle(filename: str) -> FileResponse:
|
||||
```
|
||||
|
||||
**Slots** (inject into the mail UI): `thread.toolbar`, `message.actions`,
|
||||
`message.banner`, `sidebar.section`, `settings.page`. `message.*` slots receive
|
||||
`props = { messageId }`.
|
||||
`message.actions.menu`, `message.banner`, `sidebar.section`, `settings.page`.
|
||||
`message.*` slots receive `props = { messageId }`.
|
||||
|
||||
- `message.actions` sits on the message header — a persistent control.
|
||||
- `message.actions.menu` sits *inside* the Actions dropdown, beside Reply and
|
||||
Delete — for a verb the user reaches for occasionally. The host closes the menu
|
||||
after any click inside, so the bundle only has to act.
|
||||
- `settings.page` becomes its own section in Settings. Give the slot a `title`
|
||||
and that is its nav label; without one it falls back to the plugin name:
|
||||
|
||||
```python
|
||||
Slot(name="settings.page", bundle=f"{_UI}/settings.js", title="Antispam filtering")
|
||||
```
|
||||
|
||||
**Pages** (`app.page`): a full content-area view at `/p/<route>` with a nav entry.
|
||||
|
||||
@@ -299,7 +357,11 @@ ui_manifest = UiManifest(
|
||||
Style bundles with these theme-aware classes (they adapt to light/dark) instead of
|
||||
inline styles: `wm-root`, `wm-h2`, `wm-muted`, `wm-faint`, `wm-card`, `wm-row`,
|
||||
`wm-input`, `wm-select`, `wm-btn` / `wm-btn-primary` / `wm-btn-danger` / `wm-btn-ghost`,
|
||||
`wm-chip`, `wm-table`, `wm-list`.
|
||||
`wm-chip`, `wm-table`, `wm-list`, `wm-menu-item`.
|
||||
|
||||
`wm-menu-item` is for the `message.actions.menu` slot: it applies the exact
|
||||
utilities the core Actions dropdown uses, so a plugin verb is indistinguishable
|
||||
from Reply or Delete rather than approximately similar.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user