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:
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user