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:
2026-07-27 08:30:24 +02:00
co-authored by Claude Opus 4.8
parent 4e077f1a37
commit d46ad13b7b
42 changed files with 2284 additions and 3 deletions
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "webmail-plugin-filters"
version = "0.1.0"
description = "Rules on message.new (match -> flag) as a plugin — hook + KV + slot, zero core diffs."
requires-python = ">=3.11"
dependencies = []
[project.entry-points."webmail.plugins"]
filters = "webmail_plugin_filters:plugin"
[tool.setuptools]
packages = ["webmail_plugin_filters"]
[tool.setuptools.package-data]
webmail_plugin_filters = ["ui/*.js"]
@@ -0,0 +1,23 @@
"""``filters`` — rules on incoming mail, built on a hook + KV + slot (SPEC §11, §12 M3).
On ``message.new`` the hook matches the message against the user's rules (stored
in KV) and applies a flag (mark read / pin) via the SDK — no core diffs. Rules
are managed from a ``settings.page`` slot. (Flag actions for now; ``move`` would
follow the same pattern once the SDK exposes it.)
"""
from __future__ import annotations
from app.plugins.sdk import Plugin, Slot, UiManifest
from webmail_plugin_filters.hook import on_message_new
from webmail_plugin_filters.router import router
plugin = Plugin(
name="filters",
version="0.1.0",
hooks={"message.new": on_message_new},
router=router,
ui_manifest=UiManifest(
slots=[Slot(name="settings.page", bundle="/api/plugins/filters/ui/settings.js")],
),
)
@@ -0,0 +1,62 @@
"""message.new hook: match the user's rules and flag the message (SPEC §11).
Runs wherever message.new is published (the sync worker). Reads rules from KV,
reads the message envelope through the SDK, and applies flags user-scoped.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from app.plugins.sdk import sdk
logger = logging.getLogger("plg.filters")
# rule.action "read"/"pin" -> IMAP flag on a match ("pin" is the app's name for
# \Flagged; setting it re-derives Thread.pinned). "trash" -> move to Trash.
_ACTION_FLAG = {"read": "\\Seen", "pin": "\\Flagged"}
def _haystack(env: dict[str, Any], field: str) -> str:
"""The text a rule matches against, per its field."""
if field == "from":
return f"{env['from_email']} {env['from_name']}"
if field == "to":
return " ".join(f"{a['email']} {a['name']}" for a in env["to"])
return env["subject"]
async def on_message_new(payload: dict[str, Any]) -> None:
uid_raw, mid_raw = payload.get("user_id"), payload.get("message_id")
if not isinstance(uid_raw, str) or not isinstance(mid_raw, str):
return
user_id, message_id = uuid.UUID(uid_raw), uuid.UUID(mid_raw)
s = sdk("filters")
rules = await s.kv(user_id).get("_rules", [])
if not rules:
return
env = await s.messages.envelope(user_id, message_id)
if env is None:
return
add: set[str] = set()
trash = False
for rule in rules:
needle = str(rule.get("contains", "")).strip().lower()
if not needle or needle not in _haystack(env, rule.get("field", "")).lower():
continue
action = rule.get("action", "")
if action == "trash":
trash = True
elif action in _ACTION_FLAG:
add.add(_ACTION_FLAG[action])
# Trash wins: no point flagging a message we're removing.
if trash:
if await s.actions.trash(user_id, message_id):
logger.info("filters: trashed message %s", message_id)
elif add and await s.actions.set_flags(user_id, message_id, add=add):
logger.info("filters: applied %s to message %s", sorted(add), message_id)
@@ -0,0 +1,51 @@
"""HTTP surface for the filters plugin, mounted at /api/plugins/filters (SPEC §11).
Rules live in the KV store under ``_rules`` (per user). The message.new hook reads
them; this router lets the settings.page slot read/write them.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
_SDK = sdk("filters")
_KEY = "_rules"
class Rule(BaseModel):
field: str # "from" | "to" | "subject"
contains: str
action: str # "read" | "pin" | "trash"
class RulesIn(BaseModel):
rules: list[Rule]
@router.get("/rules", response_model=list[Rule])
async def get_rules(user_id: CurrentUserId) -> list[dict[str, Any]]:
return await _SDK.kv(user_id).get(_KEY, [])
@router.put("/rules", dependencies=[Depends(require_csrf)])
async def set_rules(body: RulesIn, user_id: CurrentUserId) -> dict[str, object]:
rules = [r.model_dump() for r in body.rules]
await _SDK.kv(user_id).set(_KEY, rules)
return {"status": "ok", "count": len(rules)}
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename != "settings.js":
raise HTTPException(status_code=404)
return FileResponse(_UI / filename, media_type="text/javascript")
@@ -0,0 +1,84 @@
// filters · settings.page slot (SPEC §11.3). Manage match->action rules; persists
// to the plugin's KV-backed API. New mail is matched by the message.new hook.
// Styling uses the plugin UI kit (.wm-* classes, see docs/plugins.md).
export default function mount(el, ctx) {
el.innerHTML = `
<section class="wm-root">
<h2 class="wm-h2">Filters</h2>
<p class="wm-muted" style="margin:0 0 12px">When new mail matches, apply an action. Rules run on arrival.</p>
<table class="wm-table"><tbody data-rows></tbody></table>
<form data-add class="wm-row wm-card" style="margin-top:12px">
<select data-field class="wm-select">
<option value="subject">Subject</option>
<option value="from">From</option>
<option value="to">To</option>
</select>
<span>contains</span>
<input data-contains class="wm-input" placeholder="text" aria-label="contains"/>
<span>→</span>
<select data-action class="wm-select">
<option value="read">Mark read</option>
<option value="pin">Pin</option>
<option value="trash">Delete</option>
</select>
<button class="wm-btn wm-btn-primary">Add rule</button>
</form>
</section>`;
let rules = [];
const rows = el.querySelector("[data-rows]");
const ACTION_LABEL = { read: "Mark read", pin: "Pin", trash: "Delete" };
const FIELD_LABEL = { from: "From", to: "To", subject: "Subject" };
async function save() {
await ctx.api.pluginFetch("/rules", { method: "PUT", body: JSON.stringify({ rules }) });
}
function render() {
rows.innerHTML = rules
.map(
(r, i) =>
`<tr>
<td>${FIELD_LABEL[r.field] || r.field} contains <b>${esc(r.contains)}</b></td>
<td class="wm-muted">${ACTION_LABEL[r.action] || r.action}</td>
<td style="text-align:right"><button class="wm-btn-danger" data-rm="${i}">Remove</button></td>
</tr>`,
)
.join("");
rows.querySelectorAll("[data-rm]").forEach((b) =>
b.addEventListener("click", async () => {
rules.splice(Number(b.getAttribute("data-rm")), 1);
await save();
render();
}),
);
}
el.querySelector("[data-add]").addEventListener("submit", async (e) => {
e.preventDefault();
const contains = el.querySelector("[data-contains]").value.trim();
if (!contains) return;
rules.push({
field: el.querySelector("[data-field]").value,
contains,
action: el.querySelector("[data-action]").value,
});
el.querySelector("[data-contains]").value = "";
await save();
render();
});
(async () => {
rules = await ctx.api.pluginFetch("/rules");
render();
})();
return () => {
el.innerHTML = "";
};
function esc(s) {
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
}
@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "webmail-plugin-hello"
version = "0.1.0"
description = "Skeleton page-plugin for the webmail plugin platform (SPEC §11 / §12 M3 acceptance)."
requires-python = ">=3.11"
dependencies = []
# Discovery: the loader enumerates this entry-point group (SPEC §11).
[project.entry-points."webmail.plugins"]
hello = "webmail_plugin_hello:plugin"
[tool.setuptools]
packages = ["webmail_plugin_hello"]
[tool.setuptools.package-data]
webmail_plugin_hello = ["ui/*.js", "migrations/versions/*.py"]
@@ -0,0 +1,34 @@
"""``hello`` — the skeleton page-plugin (SPEC §11, §12 M3 acceptance).
Exercises the whole platform in the smallest form: an ``app.page`` (route + nav +
ESM bundle), its own RLS-isolated table ``plg_hello_note`` (a migration branch),
and one cron ``JobSpec`` — all via ``app.plugins.sdk`` only, with zero core diffs.
Not builtin, so it round-trips install → run → uninstall --drop-data cleanly.
"""
from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, UiManifest
from webmail_plugin_hello.jobs import heartbeat
from webmail_plugin_hello.router import router
_HERE = Path(__file__).resolve().parent
plugin = Plugin(
name="hello",
version="0.1.0",
router=router,
ui_manifest=UiManifest(
pages=[
Page(
route="/hello",
nav=PageNav(icon="puzzle", label="Hello", order=50),
bundle="/api/plugins/hello/ui/page.js",
)
],
),
migrations=str(_HERE / "migrations" / "versions"),
jobs=[JobSpec(func=heartbeat, schedule="*/5 * * * *")],
)
@@ -0,0 +1,13 @@
"""Background job for the hello plugin (SPEC §11.2). Trivial heartbeat proving
cron registration on the shared worker; logs under the ``plg`` namespace."""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger("plg.hello")
async def heartbeat(ctx: dict[str, Any]) -> None:
logger.info("hello plugin heartbeat")
@@ -0,0 +1,33 @@
"""hello plugin: user-owned note table with RLS (SPEC §11.1)
Revision ID: hello0001
Revises:
Create Date: 2026-07-26
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.plugins.rls import enable_rls
revision: str = "hello0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"CREATE TABLE plg_hello_note ("
"id uuid PRIMARY KEY DEFAULT gen_random_uuid(), "
"user_id uuid NOT NULL, "
"body text NOT NULL DEFAULT '')"
)
enable_rls("plg_hello_note")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS plg_hello_note")
@@ -0,0 +1,55 @@
"""HTTP surface for the hello plugin, mounted at /api/plugins/hello (SPEC §11).
Notes live in ``plg_hello_note`` and are read/written only through the RLS-scoped
``sdk.session(user_id)``, so a user only ever sees their own — the DB enforces it.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import text
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
_SDK = sdk("hello")
class NoteIn(BaseModel):
body: str
class NoteOut(BaseModel):
id: str
body: str
@router.get("/notes", response_model=list[NoteOut])
async def list_notes(user_id: CurrentUserId) -> list[NoteOut]:
async with _SDK.session(user_id) as session:
rows = (
await session.execute(text("SELECT id, body FROM plg_hello_note ORDER BY body"))
).mappings().all()
return [NoteOut(id=str(r["id"]), body=r["body"]) for r in rows]
@router.post("/notes", dependencies=[Depends(require_csrf)])
async def add_note(body: NoteIn, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as session:
await session.execute(
text("INSERT INTO plg_hello_note (user_id, body) VALUES (:u, :b)"),
{"u": str(user_id), "b": body.body},
)
return {"status": "ok"}
@router.get("/ui/page.js")
async def page_bundle() -> FileResponse:
"""Serve the page's ESM bundle for the frontend loader (SPEC §11.3)."""
return FileResponse(_UI / "page.js", media_type="text/javascript")
@@ -0,0 +1,49 @@
// hello plugin page bundle (SPEC §11.3). Framework-agnostic: the host calls this
// default export with a container element and the plugin context. No build step —
// hand-written ESM served by the plugin router, imported at runtime. Styling uses
// the plugin UI kit (.wm-* classes, see docs/plugins.md).
export default function mount(el, ctx) {
el.innerHTML = `
<div class="wm-root" style="max-width:640px;margin:24px auto;padding:0 16px">
<h1 class="wm-h2" style="font-size:20px">Hello plugin</h1>
<p class="wm-muted" style="margin:0 0 16px">
A skeleton page-plugin: its own RLS-isolated table, a cron job, and this page —
all through the SDK, zero core diffs.
</p>
<form data-role="add" class="wm-row" style="margin:0 0 16px">
<input data-role="input" class="wm-input" placeholder="Add a note" aria-label="note"/>
<button class="wm-btn wm-btn-primary">Add</button>
</form>
<ul data-role="list" class="wm-list"></ul>
</div>`;
const list = el.querySelector('[data-role="list"]');
const input = el.querySelector('[data-role="input"]');
async function refresh() {
const notes = await ctx.api.pluginFetch("/notes");
list.innerHTML = notes.map((n) => `<li>${escapeHtml(n.body)}</li>`).join("");
}
el.querySelector('[data-role="add"]').addEventListener("submit", async (e) => {
e.preventDefault();
const body = input.value.trim();
if (!body) return;
await ctx.api.pluginFetch("/notes", { method: "POST", body: JSON.stringify({ body }) });
input.value = "";
await refresh();
});
refresh();
return () => {
el.innerHTML = "";
};
function escapeHtml(s) {
return String(s).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
}
}
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "webmail-plugin-labels"
version = "0.1.0"
description = "Per-message labels as a plugin — slots + KV only, zero core diffs (SPEC §11 / §12 M3)."
requires-python = ">=3.11"
dependencies = []
[project.entry-points."webmail.plugins"]
labels = "webmail_plugin_labels:plugin"
[tool.setuptools]
packages = ["webmail_plugin_labels"]
[tool.setuptools.package-data]
webmail_plugin_labels = ["ui/*.js"]
@@ -0,0 +1,23 @@
"""``labels`` — per-message labels, built entirely on slots + KV (SPEC §11, §12 M3).
No plugin tables and no core diffs: labels live in the namespaced KV store (keyed
per user, so isolation is by construction), surfaced through a ``message.actions``
slot (add/remove on a message) and a ``sidebar.section`` slot (list all labels).
"""
from __future__ import annotations
from app.plugins.sdk import Plugin, Slot, UiManifest
from webmail_plugin_labels.router import router
plugin = Plugin(
name="labels",
version="0.1.0",
router=router,
ui_manifest=UiManifest(
slots=[
Slot(name="message.actions", bundle="/api/plugins/labels/ui/actions.js"),
Slot(name="sidebar.section", bundle="/api/plugins/labels/ui/sidebar.js"),
],
),
)
@@ -0,0 +1,55 @@
"""HTTP surface for the labels plugin, mounted at /api/plugins/labels (SPEC §11).
State is the namespaced KV store: key ``<message_id>`` -> that message's labels,
and key ``_labels`` -> the user's known label set. The KV store is scoped to
(plugin, user_id) by construction, so users never see each other's labels.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
_SDK = sdk("labels")
_ALL_KEY = "_labels"
_BUNDLES = {"actions.js", "sidebar.js"}
class LabelsIn(BaseModel):
labels: list[str]
@router.get("/message/{message_id}/labels", response_model=list[str])
async def get_labels(message_id: str, user_id: CurrentUserId) -> list[str]:
return await _SDK.kv(user_id).get(message_id, [])
@router.put("/message/{message_id}/labels", dependencies=[Depends(require_csrf)])
async def set_labels(message_id: str, body: LabelsIn, user_id: CurrentUserId) -> dict[str, object]:
labels = sorted({name.strip() for name in body.labels if name.strip()})
kv = _SDK.kv(user_id)
await kv.set(message_id, labels)
known = set(await kv.get(_ALL_KEY, []))
known.update(labels)
await kv.set(_ALL_KEY, sorted(known))
return {"status": "ok", "labels": labels}
@router.get("/labels", response_model=list[str])
async def all_labels(user_id: CurrentUserId) -> list[str]:
return await _SDK.kv(user_id).get(_ALL_KEY, [])
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename not in _BUNDLES:
raise HTTPException(status_code=404)
return FileResponse(_UI / filename, media_type="text/javascript")
@@ -0,0 +1,62 @@
// labels · message.actions slot (SPEC §11.3). Removable label chips + an add
// control on a message, persisted via the plugin's KV-backed API. Styling uses
// the plugin UI kit (.wm-* classes, see docs/plugins.md).
export default function mount(el, ctx, props) {
const messageId = props && props.messageId;
if (!messageId) return;
el.style.display = "inline-flex";
el.style.gap = "4px";
el.style.alignItems = "center";
let labels = [];
async function save() {
await ctx.api.pluginFetch(`/message/${messageId}/labels`, {
method: "PUT",
body: JSON.stringify({ labels }),
});
// Let the sidebar list refresh (both slots live in the same document).
window.dispatchEvent(new CustomEvent("webmail:labels-changed"));
}
function render() {
const chips = labels
.map(
(name, i) =>
`<span class="wm-chip">${esc(name)}<button data-rm="${i}" title="remove">×</button></span>`,
)
.join("");
el.innerHTML = `${chips}<button class="wm-btn-ghost" data-add title="add label">+label</button>`;
el.querySelectorAll("[data-rm]").forEach((b) =>
b.addEventListener("click", async (e) => {
e.stopPropagation();
labels.splice(Number(b.getAttribute("data-rm")), 1);
await save();
render();
}),
);
el.querySelector("[data-add]").addEventListener("click", async (e) => {
e.stopPropagation();
const name = window.prompt("Label name:");
if (name && name.trim() && !labels.includes(name.trim())) {
labels.push(name.trim());
await save();
render();
}
});
}
(async () => {
labels = await ctx.api.pluginFetch(`/message/${messageId}/labels`);
render();
})();
return () => {
el.innerHTML = "";
};
function esc(s) {
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
}
@@ -0,0 +1,30 @@
// labels · sidebar.section slot (SPEC §11.3). Lists the user's known labels;
// refreshes when the message.actions slot signals a change (same-document event).
// Styling uses the plugin UI kit (.wm-* classes, see docs/plugins.md).
export default function mount(el, ctx) {
el.innerHTML = `
<div style="padding:8px 12px 0">
<div class="wm-faint" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;padding-bottom:4px">Labels</div>
<ul data-list class="wm-list"></ul>
</div>`;
const list = el.querySelector("[data-list]");
async function refresh() {
const labels = await ctx.api.pluginFetch("/labels");
list.innerHTML = labels.length
? labels.map((name) => `<li>🏷 ${esc(name)}</li>`).join("")
: `<li class="wm-faint">No labels yet</li>`;
}
refresh();
window.addEventListener("webmail:labels-changed", refresh);
return () => {
window.removeEventListener("webmail:labels-changed", refresh);
el.innerHTML = "";
};
function esc(s) {
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
}