# Writing a plugin The webmail app has a plugin platform (SPEC §11): self-contained Python packages that add hooks, HTTP routes, background jobs, database tables, and UI — with **zero core diffs**. The built-in Calendar and Contacts features are plugins; so are Filters and Labels. This guide is everything you need to write one. > A plugin may import from **`app.plugins.sdk` only** — never other `app.*` > internals. Everything below is reachable through the SDK. --- ## 1. Anatomy A plugin is a Python package `webmail_plugin_` that exposes a module-level `plugin = Plugin(...)` and registers it under the `webmail.plugins` entry-point group. ``` plugins/webmail_plugin_myplugin/ pyproject.toml webmail_plugin_myplugin/ __init__.py # plugin = Plugin(...) router.py # optional APIRouter hook.py # optional event handlers jobs.py # optional background jobs migrations/versions/ # optional plg_ tables (heavy plugins) ui/*.js # optional frontend bundles (slots / pages) ``` `pyproject.toml`: ```toml [build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] name = "webmail-plugin-myplugin" version = "0.1.0" requires-python = ">=3.11" [project.entry-points."webmail.plugins"] myplugin = "webmail_plugin_myplugin:plugin" [tool.setuptools] packages = ["webmail_plugin_myplugin"] [tool.setuptools.package-data] webmail_plugin_myplugin = ["ui/*.js", "migrations/versions/*.py"] ``` `__init__.py`: ```python from app.plugins.sdk import Plugin, Slot, UiManifest from webmail_plugin_myplugin.router import router plugin = Plugin( name="myplugin", # [a-z][a-z0-9_]*, unique version="0.1.0", hooks={}, # {event: async handler} (§4) router=router, # mounted at /api/plugins/myplugin (§5) ui_manifest=UiManifest(...), # slots / pages / capabilities (§8) migrations=None, # path to a versions/ dir (§7) jobs=[], # [JobSpec(...)] (§6) builtin=False, # True = core, non-removable ) ``` Install & lifecycle: ``` pip install -e plugins/webmail_plugin_myplugin # register the entry point python -m app.plugins.cli list # show discovered plugins python -m app.plugins.cli install myplugin # run its migrations (if any) python -m app.plugins.cli uninstall myplugin --drop-data # downgrade + drop tables ``` `builtin=True` plugins refuse `uninstall`. Restart the **worker** after installing (it doesn't auto-reload; the API does). --- ## 2. The SDK `sdk("")` returns a facade bound to your plugin. It's cheap — call it on demand. ```python from app.plugins.sdk import sdk s = sdk("myplugin") ``` | Member | What it gives you | |---|---| | `s.session(user_id)` | RLS-scoped async DB session for your `plg_*` tables (§7) | | `s.kv(user_id)` | namespaced key/value store: `get/set/delete/items` (§3) | | `await s.publish(event, payload)` | publish `plugin.myplugin.{event}` (payload needs `user_id`) | | `await s.enqueue(job_func, **kwargs)` | enqueue one of your jobs (§6) | | `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`, `header`, `raw`, `sample` | | `s.actions` | act on a message: `set_flags`, `trash`, `move_to_special` | | `s.accounts` | `live()` → `(user_id, account_id)` for every account past its initial import — cross-user, for scheduled jobs | Plus, for routers: `CurrentUserId` (auth dependency → the user's uuid) and `require_csrf` (guard mutating routes). ### 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. --- ## 3. KV store Light per-user state, no schema needed: ```python kv = sdk("myplugin").kv(user_id) await kv.set("rules", [{"match": "boss"}]) await kv.get("rules", default=[]) await kv.delete("rules") await kv.items() # {key: value, ...} ``` Keyed by `(plugin, user_id, key)` — users can't see each other's values. --- ## 4. Hooks 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 # hook.py from app.plugins.sdk import sdk import uuid async def on_message_new(payload): user_id = uuid.UUID(payload["user_id"]) message_id = uuid.UUID(payload["message_id"]) env = await sdk("myplugin").messages.envelope(user_id, message_id) if env and "urgent" in env["subject"].lower(): await sdk("myplugin").actions.set_flags(user_id, message_id, add=["\\Flagged"]) ``` Register in the `Plugin(hooks={"message.new": on_message_new})`. --- ## 5. Router An `APIRouter` mounted at `/api/plugins/`. Use `CurrentUserId` for auth and `require_csrf` on mutating routes; do DB work through the SDK. ```python # router.py from fastapi import APIRouter, Depends from app.plugins.sdk import CurrentUserId, require_csrf, sdk router = APIRouter() @router.get("/rules") async def get_rules(user_id: CurrentUserId) -> list: return await sdk("myplugin").kv(user_id).get("rules", []) @router.put("/rules", dependencies=[Depends(require_csrf)]) async def set_rules(rules: list[dict], user_id: CurrentUserId) -> dict: await sdk("myplugin").kv(user_id).set("rules", rules) return {"status": "ok"} ``` To serve a UI bundle, add a `FileResponse` route (see §8). --- ## 6. Background jobs ```python # jobs.py async def sync_now(ctx, *, user_id): ... # ctx is the arq context ``` ```python from app.plugins.sdk import JobSpec jobs = [ JobSpec(func=sync_now, schedule="*/15 * * * *", singleton=True), ] ``` - `schedule`: standard 5-field cron (`min hour dom mon dow`), or `None` for enqueue-on-demand via `await sdk("myplugin").enqueue(sync_now, user_id=...)`. - `singleton=True`: at most one running instance per `(plugin, user_id)` (Redis lock). A hook on a state change is a **one-shot** event: it cannot help anything that already happened before your plugin was installed, and a worker restart can miss it. If a job must eventually run for every account, pair the hook with a scheduled sweep over `s.accounts.live()` and make the per-account work cheap to repeat — the antispam bootstrap does exactly this. Jobs share the worker pool + retry semantics and log under `plg.myplugin.*`. --- ## 7. Plugin tables (heavy plugins) — migrations + RLS Only if you need real tables. Ship an Alembic `versions/` dir and point `Plugin(migrations=".../versions")` at it. Rules: - Every table is prefixed **`plg__`**. - Every user-data table has a **`user_id uuid`** column. - Enable **Row-Level Security** right after creating the table — the DB then enforces per-user isolation so a forgotten `WHERE user_id` can't leak across users. ```python # migrations/versions/0001_init.py from alembic import op from app.plugins.rls import enable_rls revision = "myplugin0001" down_revision = None def upgrade(): op.execute( "CREATE TABLE plg_myplugin_note (" "id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " "user_id uuid NOT NULL, body text NOT NULL DEFAULT '')" ) enable_rls("plg_myplugin_note") def downgrade(): op.execute("DROP TABLE IF EXISTS plg_myplugin_note") ``` Query only through `sdk(...).session(user_id)` — it scopes the transaction to that user, so RLS applies: ```python async with sdk("myplugin").session(user_id) as db: rows = (await db.execute(text("SELECT body FROM plg_myplugin_note"))).all() ``` `install` runs `upgrade`; `uninstall --drop-data` runs `downgrade` and drops the version table, leaving zero residue. --- ## 8. Frontend surfaces Declare them in `UiManifest`. Bundles are **runtime-loaded ESM** served by your router; they use a framework-agnostic mount contract (no shared React): ```js // ui/actions.js — default export mounts into `el`, returns an optional cleanup. export default function mount(el, ctx, props) { el.innerHTML = ``; return () => { el.innerHTML = ""; }; } ``` `ctx` is `{ pluginName, api, events, t }`: - `ctx.api.pluginFetch(path, init?)` → fetch under `/api/plugins/` (auth+CSRF handled). - `ctx.api.fetch(path, init?)` → fetch under `/api`. - `ctx.events.subscribe(event, handler)` → WS events (`plugin..*` + core). - `ctx.t(key)` → i18n. Serve bundles from the router: ```python from pathlib import Path from fastapi.responses import FileResponse _UI = Path(__file__).resolve().parent / "ui" @router.get("/ui/{filename}") async def bundle(filename: str) -> FileResponse: assert filename in {"actions.js"} return FileResponse(_UI / filename, media_type="text/javascript") ``` **Slots** (inject into the mail UI): `thread.toolbar`, `message.actions`, `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/` with a nav entry. **Capabilities**: expose an endpoint under a named capability the core UI merges (v1: `recipients.suggest`). ```python from app.plugins.sdk import Page, PageNav, Slot, UiManifest ui_manifest = UiManifest( slots=[Slot(name="message.actions", bundle="/api/plugins/myplugin/ui/actions.js")], pages=[Page(route="/myplugin", nav=PageNav(icon="puzzle", label="My plugin", order=50), bundle="/api/plugins/myplugin/ui/page.js")], capabilities={}, ) ``` ### The plugin UI kit 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-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. --- ## 9. Worked example — an Unsubscribe button A `message.actions` plugin that shows an **Unsubscribe** button on mailing-list mail (messages with a `List-Unsubscribe` header). No table, no job — a router + one slot. `__init__.py`: ```python from app.plugins.sdk import Plugin, Slot, UiManifest from webmail_plugin_unsubscribe.router import router plugin = Plugin( name="unsubscribe", version="0.1.0", router=router, ui_manifest=UiManifest( slots=[Slot(name="message.actions", bundle="/api/plugins/unsubscribe/ui/button.js")], ), ) ``` `router.py`: ```python import re from pathlib import Path from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse from app.plugins.sdk import CurrentUserId, sdk router = APIRouter() _UI = Path(__file__).resolve().parent / "ui" _URL = re.compile(r"<(https?://[^>]+)>") # List-Unsubscribe: , @router.get("/message/{message_id}/unsub") async def unsub(message_id: str, user_id: CurrentUserId) -> dict: import uuid raw = await sdk("unsubscribe").messages.header(user_id, uuid.UUID(message_id), "List-Unsubscribe") m = _URL.search(raw or "") return {"url": m.group(1) if m else None} @router.get("/ui/{filename}") async def bundle(filename: str) -> FileResponse: if filename != "button.js": raise HTTPException(404) return FileResponse(_UI / filename, media_type="text/javascript") ``` `ui/button.js`: ```js export default function mount(el, ctx, props) { if (!props || !props.messageId) return; (async () => { const { url } = await ctx.api.pluginFetch(`/message/${props.messageId}/unsub`); if (!url) return; // not a list message → show nothing el.innerHTML = ``; el.querySelector("button").addEventListener("click", (e) => { e.stopPropagation(); window.open(url, "_blank", "noopener,noreferrer"); }); })(); return () => { el.innerHTML = ""; }; } ``` That's a complete, installable plugin using only this guide: entry point → router with SDK header access + a bundle → a `message.actions` slot styled with the UI kit. --- ## 10. Security checklist - Import only from `app.plugins.sdk`. - Scope every query to `user_id` — via `sdk.session(user_id)` (RLS) or the KV store. - Enable RLS on every `plg_*` user-data table (§7). - Route external HTTP through `sdk.http_get` (SSRF guard). - Guard mutating routes with `require_csrf`. - Store secrets encrypted (ask if you need the vault exposed — not in the v1 SDK).