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