Completes M3 (SPEC §11 + §12 acceptance) on top of the backend platform (4e077f1).
Frontend host (§11.3):
- src/plugins/: manifest loader, runtime ESM bundle loader (framework-agnostic
mount contract), PluginSlot, PluginPage (app.page at /p/*), plugin context
(scoped api/events/i18n), capabilities hook. Wired slots into ThreadView
(message.actions/message.banner), MailLayout sidebar (sidebar.section + page nav)
and Settings→Accounts (settings.page); ws.ts fans out core + plugin.* events.
- plugin-ui.css: theme-aware .wm-* classes so bundles don't re-derive inline styles.
SDK additions:
- CurrentUserId + require_csrf (plugin-router auth), messages.envelope/header,
actions.set_flags/trash, sdk.enqueue.
Reference plugins (plugins/, installed via webmail.plugins entry points):
- hello: skeleton page-plugin (app.page + plg_hello_note w/ RLS + cron job) —
install→run→uninstall --drop-data leaves zero residue.
- labels: message.actions + sidebar.section slots, KV only, zero core diffs.
- filters: message.new hook -> match (subject/from/to) -> Mark read / Pin / Delete
(delete = move thread to Trash via the core path).
docs/plugins.md: full authoring guide + a complete "Unsubscribe button" worked
example (the §12 "writable from docs alone" bar).
Also: conftest resets the arq pool between tests (loop-bound global, like engine/redis).
Gates: backend ruff + mypy --strict (107) + full suite 337; frontend tsc + eslint
+ vitest (11) + vite build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
12 KiB
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.sdkonly — never otherapp.*internals. Everything below is reachable through the SDK.
1. Anatomy
A plugin is a Python package webmail_plugin_<name> 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:
[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:
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("<name>") returns a facade bound to your plugin. It's cheap — call it on demand.
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(user_id, id), header(user_id, id, name) |
s.actions |
act on a message: set_flags(...), trash(user_id, id) |
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).
3. KV store
Light per-user state, no schema needed:
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.
They fire wherever the event is published — message.new originates in the worker.
# 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/<name>. Use CurrentUserId for auth and
require_csrf on mutating routes; do DB work through the SDK.
# 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
# jobs.py
async def sync_now(ctx, *, user_id): ... # ctx is the arq context
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), orNonefor enqueue-on-demand viaawait sdk("myplugin").enqueue(sync_now, user_id=...).singleton=True: at most one running instance per(plugin, user_id)(Redis lock).
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_<name>_. - Every user-data table has a
user_id uuidcolumn. - Enable Row-Level Security right after creating the table — the DB then enforces
per-user isolation so a forgotten
WHERE user_idcan't leak across users.
# 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:
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):
// ui/actions.js — default export mounts into `el`, returns an optional cleanup.
export default function mount(el, ctx, props) {
el.innerHTML = `<button class="wm-btn">Hi</button>`;
return () => { el.innerHTML = ""; };
}
ctx is { pluginName, api, events, t }:
ctx.api.pluginFetch(path, init?)→ fetch under/api/plugins/<name>(auth+CSRF handled).ctx.api.fetch(path, init?)→ fetch under/api.ctx.events.subscribe(event, handler)→ WS events (plugin.<name>.*+ core).ctx.t(key)→ i18n.
Serve bundles from the router:
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.banner, sidebar.section, settings.page. message.* slots receive
props = { messageId }.
Pages (app.page): a full content-area view at /p/<route> with a nav entry.
Capabilities: expose an endpoint under a named capability the core UI merges
(v1: recipients.suggest).
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.
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:
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:
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: <https://...>, <mailto:...>
@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:
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 = `<button class="wm-btn-ghost">Unsubscribe</button>`;
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— viasdk.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).