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