Plugin SDK: raw message bytes, per-message move, mailbox in the envelope

Three additive surfaces the antispam plugin (M7, Appendix C) needs and the
frozen §11 API did not have. None of them changes a core mail path.

`actions.move_to_special` moves ONE message into an account's folder by
special-use, wrapping the same optimistic-update + move_job path the core
endpoint uses. The existing `trash` moves a whole thread, which is right for
Delete and wrong for a spam verdict: one spam reply must not drag a legitimate
conversation into Junk. It returns False rather than raising when the account
has no such folder — an account whose server lacks a Junk folder must still
receive its mail.

`messages.raw` returns the stored RFC822 bytes. Spam scoring needs headers and
body; the SDK could only hand out one header at a time.

`envelope` now also carries account_id and mailbox_special_use. Appendix C must
classify only inbox arrivals, and neither the message.new payload nor the
envelope previously said where a message landed.

docs/open-questions.md gains OQ-6 (why these are additive rather than a spec
violation of "zero core diffs") and OQ-7 (why the engine is reached with plain
httpx instead of the SSRF-guarded sdk.http).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-16 22:14:24 +02:00
co-authored by Claude Opus 5
parent 348daf8ad7
commit 428ec58c71
3 changed files with 135 additions and 9 deletions
+54
View File
@@ -3,6 +3,60 @@
Per the setup rules, deviations and unresolved decisions are recorded here rather
than guessed silently.
## 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
"zero core diffs".
**Problem.** C1 cannot be written against the M3 SDK as frozen:
1. **§C.4 step 4** requires moving a message to Junk "through the existing move
machinery, so the verdict propagates to the IMAP server". `PluginActions`
exposed only `set_flags` and `trash`. `trash` moves the *whole thread*, which
is right for Delete and wrong for a spam verdict — one spam reply would drag a
legitimate conversation into Junk. (The `filters` plugin had already noted the
same gap: "a `move` action would follow the same pattern".)
2. **§C.4 step 3** posts the *raw blob* to Rspamd. `PluginMessages` exposed
`envelope()` and `header()` — no way to get the whole RFC822.
3. **§C.4 step 1** processes "only messages landing in `special_use='inbox'`
mailboxes". Neither the `message.new` payload nor `envelope()` said where a
message landed.
**Resolution.** Three additive changes to `app/plugins/`, no change to any core
mail path:
- `PluginActions.move_to_special(user_id, message_id, special_use)` — per-message
move wrapping the same optimistic-update + `move_job` path as the core
endpoint. Returns False (never raises) when the account has no such folder.
- `PluginMessages.raw(user_id, message_id) -> bytes | None`.
- `envelope()` now also returns `account_id` and `mailbox_special_use`.
**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
is for, and all three additions are useful beyond antispam. If the intent was
literally "no file under `app/` changes", C1 is not implementable and the spec
needs amending instead.
**Also note:** §C.2 itself mandates compose additions (`rspamd`,
`redis-rspamd`), which are core-repo diffs by any reading. They are placed behind
a compose profile so a deployment that does not want the engine is unaffected.
## OQ-7 — Rspamd cannot be reached through the SDK's guarded HTTP
**Status:** resolved, deliberate.
`sdk.http` applies the §10.6 SSRF guard, which rejects private addresses. That is
correct for user-supplied CalDAV/CardDAV URLs, but Rspamd lives at
`http://rspamd:11333` on the compose network — a private address by definition.
The guard would forbid the only address that works.
The antispam plugin therefore uses `httpx` directly for engine calls. This is
safe for the reason the guard exists: the URL comes from `RSPAMD_URL` in the
operator's environment, never from user input, so there is no request-forgery
surface to protect. User-supplied URLs in this plugin (there are none today)
would still have to go through `sdk.http`.
## OQ-1 — Meilisearch container healthcheck reports `unhealthy` (IPv6 `localhost`)
**Status:** resolved — approved deviation applied. The healthcheck URL in