docs/plugins.md described an SDK that no longer exists. It now covers messages.raw/sample, actions.move_to_special, the envelope's account_id / mailbox_special_use / account_state, the message.changed transition payload, the message.actions.menu slot, Slot.title and .wm-menu-item — with the reasons, not just the signatures. M3's acceptance is that a plugin can be written from this file alone, and it had drifted. Acceptance 4 (§C.8): a test that teaches 20 spam + 20 ham to a real engine and checks the classifier generalises to held-out mail, keeps ham under the banner threshold, and does not leak between users. It skips when no engine is reachable. It does NOT assert the literal move_threshold — a synthetic fixture cannot reach 12.0 once the SMTP-time symbols are zeroed, which is the whole point of the tuning. Recorded as OQ-8 rather than quietly asserting something weaker. Bayes ignores training until min_learns, stock 200. For a personal mailbox that means every correction appears to be ignored for months, so it is now 20. The bootstrap would have made this filter worse. Accounts do not sync their provider's spam folder by default, so the real corpus here is 3 junk beside 1,294 aged inbox — teaching both as found trains "everything is ham". It now refuses below 10 spam samples (leaving the account unmarked so a later run can do it properly) and otherwise caps ham at 3x the spam count. Run against the live account, it correctly declined: "only 3 spam samples (need 10); enable spam-folder sync to build a corpus" Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
447 lines
15 KiB
Markdown
447 lines
15 KiB
Markdown
# 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`, `header`, `raw`, `sample` |
|
|
| `s.actions` | act on a message: `set_flags`, `trash`, `move_to_special` |
|
|
|
|
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/<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.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/<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`, `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: <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).
|