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
+75
View File
@@ -0,0 +1,75 @@
// Full-content-area host for plugin pages (app.page, SPEC §11.3).
//
// Mounted at "/p/*": matches the sub-path against declared page routes and mounts
// the page bundle into the whole content area (no mail list/thread). A minimal
// header keeps a way back to Mail. (Sidebar persistence is a later refinement.)
import { useEffect, useMemo, useRef } from "react";
import { Link, Navigate, useLocation } from "react-router-dom";
import { useT } from "../i18n";
import { loadBundle } from "./bundleLoader";
import { usePluginContext } from "./context";
import { usePluginManifest, usePluginPages } from "./manifest";
import type { PluginUnmount } from "./types";
function PageMount({
pluginName,
bundle,
subpath,
}: {
pluginName: string;
bundle: string;
subpath: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const ctx = usePluginContext(pluginName);
const props = useMemo(() => ({ subpath }), [subpath]);
useEffect(() => {
let cleanup: PluginUnmount | void;
let alive = true;
loadBundle(bundle)
.then((mount) => {
if (alive && ref.current) cleanup = mount(ref.current, ctx, props);
})
.catch((err) => console.error(`plugin page ${pluginName} failed`, err));
return () => {
alive = false;
if (typeof cleanup === "function") cleanup();
};
}, [bundle, ctx, props, pluginName]);
return <div ref={ref} data-plugin-page={pluginName} className="min-h-0 flex-1 overflow-auto" />;
}
export function PluginPage() {
const t = useT();
const location = useLocation();
const { data, isLoading } = usePluginManifest();
const pages = usePluginPages();
// Sub-path under "/p" (e.g. "/calendar/2026-07"); match the longest page route.
const subpath = location.pathname.replace(/^\/p/, "") || "/";
const match = pages.find((p) => subpath === p.route || subpath.startsWith(`${p.route}/`));
if (isLoading || !data) {
return <div className="p-8 text-app-muted">{t("common.loading")}</div>;
}
if (!match) return <Navigate to="/mail" replace />;
return (
<div className="flex h-screen flex-col bg-app-bg">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link
to="/mail"
className="rounded px-2 py-1 text-sm text-app-muted hover:bg-app-hover hover:text-app-fg"
>
← {t("nav.mail")}
</Link>
<span className="text-sm font-semibold text-app-fg">{match.nav.label}</span>
</header>
<PageMount pluginName={match.plugin} bundle={match.bundle} subpath={subpath} />
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
// Renders every plugin bundle registered for a named UI slot (SPEC §11.3).
//
// The host owns a container <div> and delegates rendering to each bundle's mount
// function (framework-agnostic contract), tearing it down on unmount. Slot-specific
// data (e.g. the current messageId) is passed as `props` at mount time.
import { useEffect, useRef } from "react";
import { loadBundle } from "./bundleLoader";
import { usePluginContext } from "./context";
import { usePluginSlots } from "./manifest";
import type { PluginUnmount } from "./types";
function SlotMount({
pluginName,
bundle,
slotName,
props,
}: {
pluginName: string;
bundle: string;
slotName: string;
props?: Record<string, unknown>;
}) {
const ref = useRef<HTMLDivElement>(null);
const ctx = usePluginContext(pluginName);
useEffect(() => {
let cleanup: PluginUnmount | void;
let alive = true;
loadBundle(bundle)
.then((mount) => {
if (alive && ref.current) cleanup = mount(ref.current, ctx, props);
})
.catch((err) => console.error(`plugin slot ${slotName} (${pluginName}) failed`, err));
return () => {
alive = false;
if (typeof cleanup === "function") cleanup();
};
// props is read once at mount; plugins react to later changes via ctx.events.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bundle, ctx]);
return <div ref={ref} data-plugin-slot={slotName} data-plugin={pluginName} />;
}
export function PluginSlot({ name, props }: { name: string; props?: Record<string, unknown> }) {
const bindings = usePluginSlots(name);
if (bindings.length === 0) return null;
return (
<>
{bindings.map((b) => (
<SlotMount
key={`${b.plugin}:${b.bundle}`}
pluginName={b.plugin}
bundle={b.bundle}
slotName={name}
props={props}
/>
))}
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
// Runtime loader for plugin ESM bundles (SPEC §11.3).
//
// Bundles are hosted by the backend (the manifest gives absolute URLs), so they
// are imported dynamically at runtime and cached per URL. The importer is
// injectable so the caching logic is unit-testable without a real network module.
import type { PluginBundle, PluginMount } from "./types";
type Importer = (url: string) => Promise<unknown>;
const _cache = new Map<string, Promise<PluginMount>>();
// @vite-ignore keeps Vite from trying to resolve/transform the runtime URL.
const defaultImporter: Importer = (url) => import(/* @vite-ignore */ url);
export function loadBundle(url: string, importer: Importer = defaultImporter): Promise<PluginMount> {
let pending = _cache.get(url);
if (!pending) {
pending = importer(url).then((mod) => {
const mount = (mod as PluginBundle).default;
if (typeof mount !== "function") {
throw new Error(`plugin bundle ${url} has no default mount export`);
}
return mount;
});
// Don't cache a rejected import — allow a later retry.
pending.catch(() => _cache.delete(url));
_cache.set(url, pending);
}
return pending;
}
export function clearBundleCache(): void {
_cache.clear();
}
+34
View File
@@ -0,0 +1,34 @@
// Capability providers (SPEC §11.3). Core UI features query every plugin that
// provides a named capability and merge the results client-side, keeping core
// code plugin-unaware. v1 capability: "recipients.suggest".
import { apiFetch } from "../lib/api";
import { usePluginManifest } from "./manifest";
export interface CapabilityProvider {
plugin: string;
endpoint: string; // path under /api, e.g. /plugins/contacts/suggest
}
/** Providers of `name`, across installed plugins. */
export function useCapabilityProviders(name: string): CapabilityProvider[] {
const { data } = usePluginManifest();
return (data?.plugins ?? []).flatMap((p) =>
name in p.capabilities ? [{ plugin: p.name, endpoint: p.capabilities[name] }] : [],
);
}
/**
* Query every provider of a capability and flatten the results. A failing
* provider is skipped (never breaks the merge). Callers dedupe/rank as needed.
*/
export async function queryCapability<T>(
providers: CapabilityProvider[],
query: Record<string, string>,
): Promise<T[]> {
const qs = new URLSearchParams(query).toString();
const results = await Promise.allSettled(
providers.map((p) => apiFetch<T[]>(`${p.endpoint}?${qs}`)),
);
return results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
}
+33
View File
@@ -0,0 +1,33 @@
// Builds the PluginContext handed to plugin bundles at mount (SPEC §11.3).
import { useMemo } from "react";
import type { MessageKey } from "../i18n/en";
import { useT } from "../i18n";
import { apiFetch } from "../lib/api";
import { subscribeWs } from "../lib/ws";
import type { PluginContext } from "./types";
/** A memoised context bound to one plugin, usable from slots and pages. */
export function usePluginContext(pluginName: string): PluginContext {
const t = useT();
return useMemo<PluginContext>(
() => ({
pluginName,
api: {
fetch: (path, init) => apiFetch(path, init),
pluginFetch: (path, init) => apiFetch(`/plugins/${pluginName}${path}`, init),
},
events: {
subscribe: (event, handler) =>
subscribeWs((name, payload) => {
if (name === event) handler(payload);
}),
},
// Plugins mostly ship their own strings; app keys resolve, unknown keys
// fall through as-is (the app translator returns the key when missing).
t: (key, vars) => t(key as MessageKey, vars),
}),
[pluginName, t],
);
}
+39
View File
@@ -0,0 +1,39 @@
// Plugin UI manifest loading + derived selectors (SPEC §11.3).
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import type { PageDecl, SlotDecl } from "./types";
export function usePluginManifest() {
// The manifest is small and changes only on install/uninstall; cache it hard.
return useQuery({
queryKey: ["plugin-ui-manifest"],
queryFn: api.uiManifest,
staleTime: Infinity,
});
}
export interface SlotBinding extends SlotDecl {
plugin: string;
}
export interface PageBinding extends PageDecl {
plugin: string;
}
/** All bundles registered for a given slot, across plugins. */
export function usePluginSlots(name: string): SlotBinding[] {
const { data } = usePluginManifest();
return (data?.plugins ?? []).flatMap((p) =>
p.slots.filter((s) => s.name === name).map((s) => ({ ...s, plugin: p.name })),
);
}
/** All plugin pages, sorted by nav order (for the sidebar + routing). */
export function usePluginPages(): PageBinding[] {
const { data } = usePluginManifest();
return (data?.plugins ?? [])
.flatMap((p) => p.pages.map((pg) => ({ ...pg, plugin: p.name })))
.sort((a, b) => a.nav.order - b.nav.order);
}
+128
View File
@@ -0,0 +1,128 @@
/* Plugin UI kit (SPEC §11.3).
*
* Stable, theme-aware CSS classes for plugin bundles, so plugins style their UI
* with `class="wm-…"` instead of re-deriving inline styles from the theme vars
* (which is error-prone — the app's --app-* vars are RGB channel triplets used as
* `rgb(var(--app-fg))`, and dark mode flips them). These classes are part of the
* plugin contract and are documented in docs/plugins.md. Colors come from the app
* theme, so light/dark "just work".
*/
.wm-root {
font: 15px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: rgb(var(--app-fg));
}
.wm-h2 {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
}
.wm-muted {
color: rgb(var(--app-muted));
}
.wm-faint {
color: rgb(var(--app-faint));
}
/* Bordered, rounded panel — matches the app's list items. */
.wm-card {
border: 1px solid rgb(var(--app-border));
border-radius: 8px;
padding: 12px;
}
.wm-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
/* Form controls: solid surface background + explicit fg so the native <option>
* popup and text stay readable in both themes. */
.wm-input,
.wm-select {
padding: 6px 8px;
border: 1px solid rgb(var(--app-border));
border-radius: 6px;
background: rgb(var(--app-surface));
color: rgb(var(--app-fg));
font: inherit;
}
.wm-input {
flex: 1;
min-width: 120px;
}
.wm-btn {
padding: 6px 12px;
border: 0;
border-radius: 6px;
cursor: pointer;
font: inherit;
background: rgb(var(--app-hover));
color: rgb(var(--app-fg));
}
.wm-btn-primary {
background: rgb(var(--app-accent));
color: rgb(var(--app-accent-fg));
}
.wm-btn-danger {
background: none;
border: 0;
padding: 0;
cursor: pointer;
color: rgb(220 38 38);
font: inherit;
}
.wm-btn-ghost {
background: none;
border: 1px dashed rgb(var(--app-border));
border-radius: 10px;
padding: 1px 8px;
cursor: pointer;
font-size: 12px;
color: rgb(var(--app-muted));
}
/* Removable pill (e.g. a label). Put an <button> inside for the × control. */
.wm-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 10px;
background: rgb(var(--app-hover));
color: rgb(var(--app-fg));
font-size: 12px;
}
.wm-chip button {
border: 0;
background: none;
cursor: pointer;
color: inherit;
font-size: 12px;
line-height: 1;
}
.wm-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.wm-table td {
padding: 6px 4px;
border-bottom: 1px solid rgb(var(--app-border));
}
.wm-list {
list-style: none;
margin: 0;
padding: 0;
}
.wm-list li {
padding: 4px 12px;
font-size: 14px;
color: rgb(var(--app-muted));
}
+69
View File
@@ -0,0 +1,69 @@
// Frontend plugin surface types (SPEC §11.3), mirroring the backend ui-manifest.
export interface SlotDecl {
name: string; // one of the SPEC §11.3 slot names
bundle: string; // URL of the ESM bundle to mount (served by the plugin router)
}
export interface PageNavDecl {
icon: string;
label: string;
order: number;
}
export interface PageDecl {
route: string; // e.g. "/calendar"; mounted at "/p/calendar"
nav: PageNavDecl;
bundle: string;
}
export interface PluginManifestEntry {
name: string;
version: string;
builtin: boolean; // core plugins the UI must not offer to remove
slots: SlotDecl[];
pages: PageDecl[];
capabilities: Record<string, string>; // capability name -> endpoint path
}
export interface UiManifest {
plugins: PluginManifestEntry[];
}
// --- runtime bundle contract -------------------------------------------------
// Plugin bundles are built separately and loaded at runtime, so they use a
// framework-agnostic mount contract rather than sharing the host's React:
// the host renders a container element and calls the bundle's default export.
export interface PluginApi {
/** Fetch relative to /api (same auth/CSRF as the app client). */
fetch<T = unknown>(path: string, init?: RequestInit): Promise<T>;
/** Fetch relative to this plugin's router, /api/plugins/{name}. */
pluginFetch<T = unknown>(path: string, init?: RequestInit): Promise<T>;
}
export type PluginEventHandler = (payload: Record<string, unknown>) => void;
export interface PluginEvents {
/** Subscribe to a single WS event (plugin.{name}.* or a core event). Returns an unsubscribe. */
subscribe(event: string, handler: PluginEventHandler): () => void;
}
export interface PluginContext {
pluginName: string;
api: PluginApi;
events: PluginEvents;
t: (key: string, vars?: Record<string, string | number>) => string;
}
export type PluginUnmount = () => void;
/** A bundle's default export: render into `el`, optionally return a cleanup. */
export type PluginMount = (
el: HTMLElement,
ctx: PluginContext,
props?: Record<string, unknown>,
) => void | PluginUnmount;
export interface PluginBundle {
default: PluginMount;
}