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
+77 -7
View File
@@ -6,8 +6,9 @@ own message (the same app-level ``user_id`` scoping core uses — core tables ha
no RLS). Flagging mirrors the core action path: update the DB, enqueue the IMAP
push, and publish ``flags.changed`` so the UI updates.
(Only flag actions are exposed for now; a ``move`` action would follow the same
wrap-the-core-path pattern.)
Actions exposed: flags, trash (whole thread), and ``move_to_special`` for a
single message — the last added for the antispam plugin, which must file one
spam message into Junk without dragging its thread along.
"""
from __future__ import annotations
@@ -22,7 +23,7 @@ from sqlalchemy import select
from app.db import session_scope
from app.events import get_event_bus
from app.indexer import blobstore
from app.models import Message, Thread
from app.models import Mailbox, Message, Thread
class PluginMessages:
@@ -44,6 +45,26 @@ class PluginMessages:
return None
return message_from_bytes(raw).get(name)
async def raw(self, user_id: uuid.UUID, message_id: uuid.UUID) -> bytes | None:
"""Return the stored RFC822 bytes of the user's message, or None.
For engines that need the whole message rather than one header — spam
scoring wants headers *and* body. None when the message isn't the user's,
has no blob, or the blob is unreadable; callers must treat that as "cannot
classify", never as an error.
"""
async with session_scope() as session:
msg = await session.scalar(
select(Message).where(Message.id == message_id, Message.user_id == user_id)
)
if msg is None or not msg.blob_sha256:
return None
sha = msg.blob_sha256
try:
return blobstore.load(user_id, sha)
except FileNotFoundError:
return None
async def threads_of(
self, user_id: uuid.UUID, message_ids: Iterable[str]
) -> dict[str, str | None]:
@@ -69,14 +90,24 @@ class PluginMessages:
async def envelope(self, user_id: uuid.UUID, message_id: uuid.UUID) -> dict[str, Any] | None:
async with session_scope() as session:
msg = await session.scalar(
select(Message).where(Message.id == message_id, Message.user_id == user_id)
)
if msg is None:
row = (
await session.execute(
select(Message, Mailbox.special_use)
.join(Mailbox, Mailbox.id == Message.mailbox_id, isouter=True)
.where(Message.id == message_id, Message.user_id == user_id)
)
).first()
if row is None:
return None
msg, special_use = row
sender = msg.from_addr[0] if msg.from_addr else {}
return {
"message_id": str(msg.id),
"account_id": str(msg.account_id),
# Where it landed: "inbox", "junk", "sent", … or None for a folder
# with no SPECIAL-USE. A plugin that should only act on newly
# arrived mail checks this — `message.new` itself does not say.
"mailbox_special_use": special_use,
"subject": msg.subject or "",
"from_email": (sender.get("email") or "").lower(),
"from_name": sender.get("name") or "",
@@ -148,6 +179,45 @@ class PluginActions:
)
return True
async def move_to_special(
self, user_id: uuid.UUID, message_id: uuid.UUID, special_use: str
) -> bool:
"""Move ONE message into the account's folder with this ``special_use``
(``junk``, ``inbox``, …), on the same path as the UI's move: optimistic DB
update, then ``move_job`` performs the IMAP MOVE and reconciles the uid.
Per-message on purpose. ``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.
Returns False when the message isn't the user's, the account has no such
folder, or it is already there. That is "could not act", not an error:
an account whose server has no Junk folder must still receive its mail.
"""
from app.queue import enqueue_job
async with session_scope() as session:
msg = await session.scalar(
select(Message).where(Message.id == message_id, Message.user_id == user_id)
)
if msg is None:
return False
dst = await session.scalar(
select(Mailbox).where(
Mailbox.user_id == user_id,
Mailbox.account_id == msg.account_id, # moves stay within an account
Mailbox.special_use == special_use,
)
)
if dst is None or dst.id == msg.mailbox_id:
return False
dst_id, src_id = dst.id, msg.mailbox_id
msg.mailbox_id = dst_id # optimistic, as the core endpoint does
await session.commit()
await enqueue_job("move_job", str(message_id), str(dst_id), str(src_id))
return True
async def trash(self, user_id: uuid.UUID, message_id: uuid.UUID) -> bool:
"""Move the message's thread to Trash — the same path as the UI Delete
(move_thread_to_folder_job handles the IMAP move, restore state, folder
+4 -2
View File
@@ -143,12 +143,14 @@ class PluginSdk:
@property
def messages(self) -> PluginMessages:
"""Read a user's message envelope (subject/sender/flags) for matching."""
"""Read a user's message: envelope (subject/sender/flags), one header, or
the whole RFC822 blob for engines that need it."""
return PluginMessages()
@property
def actions(self) -> PluginActions:
"""User-scoped message actions (``set_flags``) — e.g. for filter rules."""
"""User-scoped message actions: ``set_flags``, ``trash`` (thread), and
``move_to_special`` (one message into e.g. Junk)."""
return PluginActions()
@property
+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