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,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],
);
}
}