M3 plugin platform: frontend host, reference plugins, UI kit + docs
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
This commit is contained in:
+384
@@ -0,0 +1,384 @@
|
||||
# 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_<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`:
|
||||
|
||||
```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("<name>")` 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(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:
|
||||
|
||||
```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`.
|
||||
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/<name>`. 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).
|
||||
|
||||
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 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 = `<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:
|
||||
|
||||
```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.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`).
|
||||
|
||||
```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`.
|
||||
|
||||
---
|
||||
|
||||
## 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: <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`:
|
||||
|
||||
```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` — 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).
|
||||
Reference in New Issue
Block a user