Files
webmail/lib/plugin-sandbox/actions.ts
T

159 lines
6.5 KiB
TypeScript

// Buttons and menu entries plugins declare as data (`api.ui.setActions`).
// The host draws them in its own style on the surface named in the slot
// catalogue (kind: 'action'); a click goes back to the plugin with the
// surface's context, filtered by the plugin's permissions.
import { slotSpec } from '../plugin-slot-catalog';
export interface PluginAction {
id: string;
label: string;
/** Name of a host icon (see components/icons); unknown names show none. */
icon?: string;
/** Lower comes first; default 100. */
order?: number;
/** Show only for these folder roles (message surfaces), e.g. ["inbox"]. */
mailboxRoles?: string[];
/** Show only for attachments or files of these MIME types (prefix match, "image/"). */
mimeTypes?: string[];
/** A danger action is drawn in red. */
danger?: boolean;
}
export interface RegisteredAction extends PluginAction {
pluginId: string;
pluginName: string;
}
export const MAX_ACTIONS_PER_SURFACE = 8;
const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i;
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
const ROLE_RE = /^[a-z]{1,20}$/;
const MIME_RE = /^[a-z0-9.+-]+\/[a-z0-9.+*-]*$/i;
/** Surfaces that take actions, from the slot catalogue. */
export function isActionSurface(surface: string): boolean {
return slotSpec(surface)?.kind === 'action';
}
/** Check and copy what a plugin sent; throws with a message the plugin sees. */
export function checkActions(value: unknown): PluginAction[] {
if (!Array.isArray(value)) throw new Error('actions must be a list');
if (value.length > MAX_ACTIONS_PER_SURFACE) throw new Error(`At most ${MAX_ACTIONS_PER_SURFACE} actions per surface`);
const seen = new Set<string>();
return value.map((raw, i) => {
if (!raw || typeof raw !== 'object') throw new Error(`actions[${i}] must be an object`);
const a = raw as Record<string, unknown>;
if (typeof a.id !== 'string' || !ID_RE.test(a.id)) throw new Error(`actions[${i}].id is invalid`);
if (seen.has(a.id)) throw new Error(`actions[${i}].id is used twice`);
seen.add(a.id);
if (typeof a.label !== 'string' || !a.label.trim() || a.label.length > 60) throw new Error(`actions[${i}].label is required (max 60 characters)`);
const out: PluginAction = { id: a.id, label: a.label.trim() };
if (a.icon !== undefined) {
if (typeof a.icon !== 'string' || !ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`);
out.icon = a.icon;
}
if (a.order !== undefined) {
if (typeof a.order !== 'number' || !Number.isFinite(a.order)) throw new Error(`actions[${i}].order must be a number`);
out.order = a.order;
}
if (a.mailboxRoles !== undefined) {
if (!Array.isArray(a.mailboxRoles) || a.mailboxRoles.length > 10 || !a.mailboxRoles.every((r) => typeof r === 'string' && ROLE_RE.test(r))) {
throw new Error(`actions[${i}].mailboxRoles must be a list of folder roles`);
}
out.mailboxRoles = [...a.mailboxRoles] as string[];
}
if (a.mimeTypes !== undefined) {
if (!Array.isArray(a.mimeTypes) || a.mimeTypes.length > 20 || !a.mimeTypes.every((m) => typeof m === 'string' && MIME_RE.test(m))) {
throw new Error(`actions[${i}].mimeTypes must be a list of MIME types`);
}
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
}
if (a.danger !== undefined) out.danger = a.danger === true;
return out;
});
}
// pluginId → surface → actions
const registry = new Map<string, Map<string, RegisteredAction[]>>();
const listeners = new Set<() => void>();
const snapshots = new Map<string, RegisteredAction[]>();
function changed(): void {
snapshots.clear();
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
export function subscribeActions(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function setPluginActions(pluginId: string, pluginName: string, surface: string, actions: PluginAction[]): void {
let surfaces = registry.get(pluginId);
if (!surfaces) {
surfaces = new Map();
registry.set(pluginId, surfaces);
}
if (actions.length === 0) surfaces.delete(surface);
else surfaces.set(surface, actions.map((a) => ({ ...a, pluginId, pluginName })));
changed();
}
/** Drop everything a plugin declared (it was turned off or removed). */
export function clearPluginActions(pluginId: string): void {
if (registry.delete(pluginId)) changed();
}
/**
* The actions on a surface, in order. The same array is returned until
* something changes, so it can feed useSyncExternalStore.
*/
export function actionsFor(surface: string): RegisteredAction[] {
const cached = snapshots.get(surface);
if (cached) return cached;
const out: RegisteredAction[] = [];
for (const surfaces of registry.values()) out.push(...(surfaces.get(surface) ?? []));
out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
snapshots.set(surface, out);
return out;
}
/** Whether an action applies to a folder role and/or a MIME type. */
export function actionApplies(action: PluginAction, ctx: { mailboxRole?: string | null; mimeType?: string | null }): boolean {
if (action.mailboxRoles && ctx.mailboxRole !== undefined && !action.mailboxRoles.includes(ctx.mailboxRole ?? '')) return false;
if (action.mimeTypes && ctx.mimeType !== undefined) {
const type = (ctx.mimeType ?? '').toLowerCase();
if (!action.mimeTypes.some((m) => (m.endsWith('/') || m.endsWith('/*') ? type.startsWith(m.replace(/\*$/, '')) : type === m))) return false;
}
return true;
}
/** Test helper. */
export function resetActions(): void {
registry.clear();
changed();
}
// ─── Clicks ───────────────────────────────────────────────────
/**
* Delivers a click to the plugin. Set by the loader, which owns the running
* plugins; it filters `context` by the surface's props and the plugin's
* permissions before anything crosses into the sandbox.
*/
export type ActionDispatcher = (pluginId: string, surface: string, actionId: string, context: Record<string, unknown>) => Promise<void>;
let dispatcher: ActionDispatcher | null = null;
export function setActionDispatcher(next: ActionDispatcher | null): void {
dispatcher = next;
}
export async function runAction(action: RegisteredAction, surface: string, context: Record<string, unknown>): Promise<void> {
if (!dispatcher) return;
await dispatcher(action.pluginId, surface, action.id, context);
}