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