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:
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { useMe } from "./lib/auth";
|
||||
import { connectWs } from "./lib/ws";
|
||||
import { PluginPage } from "./plugins/PluginPage";
|
||||
import { ComposeWindow } from "./routes/ComposeWindow";
|
||||
import { Login } from "./routes/Login";
|
||||
import { MailLayout } from "./routes/MailLayout";
|
||||
@@ -51,6 +52,14 @@ export function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/p/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<PluginPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/accounts/new"
|
||||
element={
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { printMessages } from "../../lib/print";
|
||||
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||
import { Icon } from "../Icon";
|
||||
import { MessageBody } from "./MessageBody";
|
||||
@@ -357,6 +358,8 @@ function MessageItem({
|
||||
onDelete={onDelete}
|
||||
replyDisabled={!canReply}
|
||||
/>
|
||||
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
|
||||
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
|
||||
</div>
|
||||
</div>
|
||||
{alsoIn.length > 0 && (
|
||||
@@ -386,6 +389,8 @@ function MessageItem({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */}
|
||||
<PluginSlot name="message.banner" props={{ messageId: message.id }} />
|
||||
<div className="px-4 pb-3">
|
||||
<MessageBody message={message} onMailto={onMailto} />
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
UserOut,
|
||||
UserSettings,
|
||||
} from "./types";
|
||||
import type { UiManifest } from "../plugins/types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
@@ -47,7 +48,7 @@ function csrfToken(): string {
|
||||
|
||||
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? "GET").toUpperCase();
|
||||
const headers = new Headers(init.headers);
|
||||
if (MUTATING.has(method)) {
|
||||
@@ -90,6 +91,7 @@ export const api = {
|
||||
|
||||
accounts: () => apiFetch<AccountOut[]>("/accounts"),
|
||||
mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"),
|
||||
uiManifest: () => apiFetch<UiManifest>("/plugins/ui-manifest"),
|
||||
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
|
||||
|
||||
threads: (
|
||||
|
||||
+27
-1
@@ -22,6 +22,19 @@ const ALLOWED_EVENTS = new Set([
|
||||
"outbox.state_changed",
|
||||
]);
|
||||
|
||||
// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3).
|
||||
// Plugins receive plugin.{name}.* events plus any core event they subscribe to.
|
||||
export type WsListener = (event: string, payload: Record<string, unknown>) => void;
|
||||
|
||||
const wsListeners = new Set<WsListener>();
|
||||
|
||||
export function subscribeWs(listener: WsListener): () => void {
|
||||
wsListeners.add(listener);
|
||||
return () => {
|
||||
wsListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function connectWs(queryClient: QueryClient): () => void {
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
@@ -40,8 +53,21 @@ export function connectWs(queryClient: QueryClient): () => void {
|
||||
return;
|
||||
}
|
||||
const result = frameSchema.safeParse(parsed);
|
||||
if (!result.success || !ALLOWED_EVENTS.has(result.data.event)) return;
|
||||
if (!result.success) return;
|
||||
const { event: name, payload } = result.data;
|
||||
const isCore = ALLOWED_EVENTS.has(name);
|
||||
// Fan out core + plugin.* events to plugin subscribers (a failing listener
|
||||
// must not break the others or the cache invalidation below).
|
||||
if (isCore || name.startsWith("plugin.")) {
|
||||
for (const listener of wsListeners) {
|
||||
try {
|
||||
listener(name, payload);
|
||||
} catch (err) {
|
||||
console.error("ws listener failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isCore) return;
|
||||
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
|
||||
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./index.css";
|
||||
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 : []));
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import type { MessageKey } from "../i18n/en";
|
||||
import { api } from "../lib/api";
|
||||
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
import { usePluginPages } from "../plugins/manifest";
|
||||
import { PluginSlot } from "../plugins/PluginSlot";
|
||||
|
||||
function quotaLabel(q: QuotaOut): string {
|
||||
const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0;
|
||||
@@ -43,6 +45,7 @@ const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
|
||||
|
||||
export function MailLayout() {
|
||||
const t = useT();
|
||||
const pluginPages = usePluginPages();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const match = useMatch("/mail/t/:threadId");
|
||||
@@ -536,6 +539,23 @@ export function MailLayout() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Plugin pages (app.page) get a nav entry; clicking opens /p{route}. */}
|
||||
{pluginPages.map((p) => (
|
||||
<SidebarItem
|
||||
key={`${p.plugin}${p.route}`}
|
||||
active={false}
|
||||
collapsed={collapsed}
|
||||
title={collapsed ? p.nav.label : undefined}
|
||||
onClick={() => navigate(`/p${p.route}`)}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="shrink-0" aria-hidden>
|
||||
<path d="M10 3.5a1.5 1.5 0 0 1 3 0V5h3.5A1.5 1.5 0 0 1 18 6.5V10h1.5a1.5 1.5 0 0 1 0 3H18v3.5a1.5 1.5 0 0 1-1.5 1.5H13v-1.5a1.5 1.5 0 0 0-3 0V18H6.5A1.5 1.5 0 0 1 5 16.5V13H6.5a1.5 1.5 0 0 0 0-3H5V6.5A1.5 1.5 0 0 1 6.5 5H10V3.5z" />
|
||||
</svg>
|
||||
{!collapsed && <span className="flex-1 truncate">{p.nav.label}</span>}
|
||||
</SidebarItem>
|
||||
))}
|
||||
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
|
||||
{!collapsed && <PluginSlot name="sidebar.section" />}
|
||||
</nav>
|
||||
<div className="border-t border-app-border p-2 text-[15px]">
|
||||
<Link
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { useMe } from "../../lib/auth";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
|
||||
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
|
||||
type Section = (typeof SECTIONS)[number];
|
||||
@@ -44,6 +45,13 @@ export function Settings() {
|
||||
{section === "security" && <SecuritySection />}
|
||||
{section === "accounts" && <AccountsSection />}
|
||||
{section === "appearance" && <AppearanceSection />}
|
||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
|
||||
Matches the accounts section width (max-w-5xl) with a gap above it. */}
|
||||
{section === "accounts" && (
|
||||
<div className="mt-8 max-w-5xl">
|
||||
<PluginSlot name="settings.page" />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { clearBundleCache, loadBundle } from "../plugins/bundleLoader";
|
||||
|
||||
describe("loadBundle", () => {
|
||||
it("caches per url and returns the default mount export", async () => {
|
||||
clearBundleCache();
|
||||
const mount = () => () => {};
|
||||
const importer = vi.fn(async () => ({ default: mount }));
|
||||
|
||||
const a = await loadBundle("/x.js", importer);
|
||||
const b = await loadBundle("/x.js", importer);
|
||||
|
||||
expect(a).toBe(mount);
|
||||
expect(b).toBe(mount);
|
||||
expect(importer).toHaveBeenCalledTimes(1); // second call served from cache
|
||||
});
|
||||
|
||||
it("rejects a bundle without a default mount export", async () => {
|
||||
clearBundleCache();
|
||||
await expect(loadBundle("/bad.js", async () => ({}))).rejects.toThrow("default mount");
|
||||
});
|
||||
|
||||
it("does not cache a failed import (allows retry)", async () => {
|
||||
clearBundleCache();
|
||||
const importer = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce({ default: () => () => {} });
|
||||
|
||||
await expect(loadBundle("/y.js", importer)).rejects.toThrow("boom");
|
||||
await loadBundle("/y.js", importer); // retried, not stuck on the failure
|
||||
expect(importer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// capabilities.ts (via manifest.ts) imports the api module; provide both symbols.
|
||||
vi.mock("../lib/api", () => ({ apiFetch: vi.fn(), api: {} }));
|
||||
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { queryCapability, type CapabilityProvider } from "../plugins/capabilities";
|
||||
|
||||
const providers: CapabilityProvider[] = [
|
||||
{ plugin: "contacts", endpoint: "/plugins/contacts/suggest" },
|
||||
{ plugin: "dir", endpoint: "/plugins/dir/suggest" },
|
||||
];
|
||||
|
||||
describe("queryCapability", () => {
|
||||
it("merges results from every provider", async () => {
|
||||
vi.mocked(apiFetch)
|
||||
.mockResolvedValueOnce([{ email: "a@x" }])
|
||||
.mockResolvedValueOnce([{ email: "b@x" }]);
|
||||
|
||||
const out = await queryCapability<{ email: string }>(providers, { q: "a" });
|
||||
expect(out).toEqual([{ email: "a@x" }, { email: "b@x" }]);
|
||||
|
||||
// Each provider queried with the encoded query string.
|
||||
expect(vi.mocked(apiFetch)).toHaveBeenCalledWith("/plugins/contacts/suggest?q=a");
|
||||
expect(vi.mocked(apiFetch)).toHaveBeenCalledWith("/plugins/dir/suggest?q=a");
|
||||
});
|
||||
|
||||
it("skips a failing provider instead of breaking the merge", async () => {
|
||||
vi.mocked(apiFetch)
|
||||
.mockRejectedValueOnce(new Error("down"))
|
||||
.mockResolvedValueOnce([{ email: "b@x" }]);
|
||||
|
||||
const out = await queryCapability<{ email: string }>(providers, { q: "a" });
|
||||
expect(out).toEqual([{ email: "b@x" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "../i18n";
|
||||
import type { UiManifest } from "../plugins/types";
|
||||
|
||||
// A fake bundle whose mount writes into the container and returns a cleanup spy.
|
||||
const h = vi.hoisted(() => {
|
||||
const cleanup = vi.fn();
|
||||
const mount = vi.fn((el: HTMLElement, _ctx?: unknown, _props?: unknown) => {
|
||||
el.textContent = "plugin-ui";
|
||||
return cleanup;
|
||||
});
|
||||
return { cleanup, mount };
|
||||
});
|
||||
|
||||
vi.mock("../plugins/bundleLoader", () => ({
|
||||
loadBundle: () => Promise.resolve(h.mount),
|
||||
clearBundleCache: () => {},
|
||||
}));
|
||||
|
||||
// Imported after the mock so PluginSlot picks up the mocked loader.
|
||||
import { PluginSlot } from "../plugins/PluginSlot";
|
||||
|
||||
const MANIFEST: UiManifest = {
|
||||
plugins: [
|
||||
{
|
||||
name: "labels",
|
||||
version: "1.0",
|
||||
builtin: false,
|
||||
slots: [{ name: "message.actions", bundle: "/labels/actions.js" }],
|
||||
pages: [],
|
||||
capabilities: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function wrap(children: ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(["plugin-ui-manifest"], MANIFEST); // seed so no network fetch
|
||||
return (
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PluginSlot", () => {
|
||||
it("mounts registered bundles and tears them down on unmount", async () => {
|
||||
const { unmount } = render(wrap(<PluginSlot name="message.actions" props={{ messageId: "m1" }} />));
|
||||
|
||||
// The bundle's mount ran into the slot container.
|
||||
expect(await screen.findByText("plugin-ui")).toBeTruthy();
|
||||
expect(h.mount).toHaveBeenCalledTimes(1);
|
||||
const [, ctx, props] = h.mount.mock.calls[0] as [HTMLElement, { pluginName: string }, unknown];
|
||||
expect(ctx.pluginName).toBe("labels");
|
||||
expect(props).toEqual({ messageId: "m1" });
|
||||
|
||||
unmount();
|
||||
expect(h.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing when no plugin provides the slot", () => {
|
||||
const { container } = render(wrap(<PluginSlot name="thread.toolbar" />));
|
||||
expect(container.querySelector("[data-plugin-slot]")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user