Files
webmail/lib/plugin-sandbox/runtime.tsx
T
Linus Rath e38e326efb perf: load plugin frames as a plain page instead of a Next app
Every plugin iframe loaded /plugin-sandbox, a force-dynamic Next page.
Each frame then fetched, parsed and hydrated Next's client router on top
of React and the runtime, and the server rendered the page once per
frame, one after another when a dozen plugins started together.
Sandboxed frames have opaque origins, so the browser's code cache never
helps, and they run on the host page's main thread: with 12 plugins
the frames took 2.5-3 s to come up after the app had loaded.

The sandbox routes now return a small HTML page with the UI kit's
stylesheet and the runtime as an ES module, both built ahead of time:

- `scripts/build-plugin-sandbox.mjs` bundles the runtime with esbuild
  and compiles `plugin-kit.css` with the app's Tailwind setup into
  `public/plugin-sandbox-assets/`, with a manifest. Named Tabler imports
  are pointed at the icons' own files, so the full icon set stays in a
  chunk that only loads for an icon name the kit doesn't ship.
- `npm run build`, `npm run dev`, the Dockerfile and the perf-guard
  workflow run it before `next build`.
- `lib/plugin-sandbox/sandbox-document.ts` writes the page; proxy.ts
  still sets the sandbox CSP and the script carries its nonce.
- The assets are served from public/ with CORS and immutable caching.

A frame on an idle page is ready in 255 ms instead of 362 ms, eleven at
once in 1.0 s instead of 2.1 s.
2026-10-25 02:01:47 +01:00

1340 lines
63 KiB
TypeScript

'use client';
// Runtime that boots inside the null-origin plugin sandbox iframe.
//
// Lifecycle:
// 1. Iframe loads → posts 'sandbox-ready' to the window that framed it
// (`window.parent`, targeted at this document's own URL origin - see
// `resolveHost`). A top-level load has no host and stays inert.
// 2. Parent posts 'init' with the bundle code + manifest + mode/slot.
// 3. We evaluate the bundle in a `new Function` scope with React/ReactDOM
// injected as globals; the bundle is CommonJS-style (`module.exports = {
// slots, hooks, activate }`). ES-module syntax inside the bundle is a
// build-time concern handled by the plugin's bundler.
// 4. In background mode: register hook handlers and call `activate(api)`.
// The host installs HookBus stubs and dispatches via 'hook-invoke'.
// 5. In slot mode: look up `slots[slot].component`, render it into the
// iframe body, push height back via ResizeObserver.
import { useEffect } from 'react';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import type {
HostToSandbox,
SandboxToHost,
InitPayload,
BackgroundInit,
SlotInit,
RegionalSnapshot,
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import { pluginUiKit, SlotPropsContext } from './ui-kit';
import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui-kit/floating-dropdown-bridge';
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type {
AppInfoView, AppSettingsView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView,
EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput,
TaskView, TemplateInput, TemplateView, VacationInput, VacationView,
} from '../plugin-types';
import { AddressBook, ContactCard } from '../jmap/types';
import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store';
// ─── Module-scope state ──────────────────────────────────────
interface PluginExports {
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean; startFolded?: boolean }>;
hooks?: Record<string, (...args: unknown[]) => unknown>;
/**
* Keyboard shortcut bindings. Each entry's `handler` is registered as a
* hook named `shortcut:<id>` so the host's keydown dispatcher can fire it.
*/
shortcuts?: Record<string, {
keys: string;
label: string;
category?: string;
handler: () => void | Promise<void>;
}>;
activate?: (api: unknown) => void | Promise<void> | { dispose: () => void };
default?: unknown;
}
let parentWindow: Window | null = null;
let parentOrigin: string | null = null;
let pluginExports: PluginExports | null = null;
let mode: 'background' | 'slot' | null = null;
let slotName: SlotName | null = null;
let bootDone = false;
// Guards the initial sandbox-ready post against React strict mode's double
// useEffect invocation; the parent only needs to be pinged once per iframe.
let readyPosted = false;
const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
// The plugin's settings. One object for the iframe's lifetime, updated in
// place on 'settings-change', so code that kept a reference sees new values.
const settingsState: Record<string, unknown> = {};
// The app language and regional settings, pushed by the host on init and on every change.
let currentRegional: RegionalSnapshot = {
locale: 'en',
dateLocale: 'en',
hour12: false,
timeZone: 'UTC',
firstDayOfWeek: 1,
};
const localeListeners = new Set<(locale: string) => void>();
// The plugin's strings. The host sends only the tables of the current locale
// chain (plugin-i18n tablesFor) and the next ones with each locale-change.
let localeTables: LocaleTables = {};
/** Add the tables a locale-change brought; strings the plugin added itself win. */
function mergeLocaleTables(next: LocaleTables | undefined): void {
if (!next) return;
for (const [locale, table] of Object.entries(next)) {
localeTables[locale] = { ...table, ...(localeTables[locale] ?? {}) };
}
}
function currentLocale(): string {
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
}
function applyLocale(locale: string, regional?: RegionalSnapshot): void {
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale;
if (regional) {
currentRegional = regional;
} else {
currentRegional = {
...currentRegional,
locale,
dateLocale: locale,
};
}
document.documentElement.lang = locale;
// Slots sit inside an RTL page in Arabic, Hebrew and Persian; their own
// document has to flip too.
document.documentElement.dir = textDirection(locale);
for (const listener of localeListeners) {
try { listener(locale); } catch (err) { console.error('[plugin] locale listener failed', err); }
}
}
function formatIntl(date: Date | number | string, options: Intl.DateTimeFormatOptions): string {
try {
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return '';
const locale = currentRegional.dateLocale || currentRegional.locale || 'en';
return new Intl.DateTimeFormat(locale, { timeZone: currentRegional.timeZone, ...options }).format(d);
} catch {
return '';
}
}
function formatTime(date: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
return formatIntl(date, { hour12: currentRegional.hour12, hour: 'numeric', minute: 'numeric', ...options });
}
function formatDate(date: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
return formatIntl(date, options ?? {});
}
const settingsListeners = new Set<(settings: Record<string, unknown>) => void>();
function applySettings(next: Record<string, unknown>): void {
for (const key of Object.keys(settingsState)) delete settingsState[key];
Object.assign(settingsState, next);
for (const listener of settingsListeners) {
try { listener(settingsState); } catch (err) { console.error('[plugin] settings listener failed', err); }
}
}
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const hookHandlers: Record<string, (...args: unknown[]) => unknown> = {};
function sendToHost(msg: SandboxToHost): void {
if (!parentWindow || !parentOrigin) return;
parentWindow.postMessage(msg, parentOrigin);
}
// ─── Host binding ────────────────────────────────────────────
/**
* The one window allowed to drive this runtime, and the origin it must post
* from. Both are fixed from browser-owned facts at load time and never
* learned from a message:
*
* - `window.parent` is the document that framed us. The host bridge creates
* that iframe itself, so this is the mirror of its own contentWindow check.
* A top-level load (window.open from another site, a typed URL) has no
* parent - `parent === window` - and gets no host at all: `window.opener`
* is never a host.
* - `location.origin` is the URL origin even when this iframe runs with an
* opaque origin (sandbox="allow-scripts"), and the host page is served
* from that same origin: the bridge loads a same-origin path and the route
* CSP pins `frame-ancestors 'self'`.
*
* Pinning the first sender instead (pre-fix) let any website window.open()
* the route, post its own `init` before a real host could, and run its code
* under this document's `'unsafe-eval'` CSP in the webmail origin
* (GHSA-96cx-gx36-3g79). Returns null when there is no acceptable host.
*/
function resolveHost(): { window: Window; origin: string } | null {
if (typeof window === 'undefined') return null;
const parent = window.parent;
if (!parent || parent === window) return null;
const origin = window.location.origin;
// 'null' is what opaque URL origins (blob:, data:, file:) serialise to; an
// attacker frame could match it, so it never counts as a host origin.
if (!origin || origin === 'null') return null;
return { window: parent, origin };
}
// ─── Theme replay ────────────────────────────────────────────
const THEME_STYLE_ID = '__plugin_host_theme';
/**
* Replay a host theme snapshot inside the iframe: inject the token + font CSS
* and mirror the `.dark` class onto <html> so plugin styles that key off
* `.dark` (or read `var(--color-*)`) behave like the host. Idempotent — safe
* to call again on every 'theme-change'.
*/
function applyHostTheme(theme: ThemeSnapshot): void {
if (typeof document === 'undefined') return;
let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = THEME_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = themeSnapshotToCSS(theme);
document.documentElement.classList.toggle('dark', theme.dark);
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
const DEFAULT_API_TIMEOUT_MS = 30_000;
// http.post / http.fetch can be carrying an attachment to an external store,
// which is a transfer rather than a round-trip - 30s is not enough for the
// files a plugin has any reason to offload. Still bounded so a hung host
// can't leak the pending promise.
const NETWORK_API_TIMEOUT_MS = 120_000;
function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
const id = uid();
return new Promise((resolve, reject) => {
pendingApi.set(id, { resolve, reject });
sendToHost({ type: 'api-request', id, method, args });
// Bounded so a hung host can't leak the promise forever. Interactive UI
// dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait
// for human input, the host always resolves them on confirm/cancel/close,
// and any still-pending call dies with the iframe on teardown - so there's
// nothing to leak, and a thinking user must not trip a 30s timeout.
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
setTimeout(() => {
const entry = pendingApi.get(id);
if (!entry) return;
pendingApi.delete(id);
entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
}, timeoutMs);
}
});
}
// ─── api.proxy streams ───────────────────────────────────────
interface ProxyStream {
controller?: ReadableStreamDefaultController<Uint8Array>;
onProgress?: (loaded: number, total: number) => void;
}
const proxyStreams = new Map<string, ProxyStream>();
interface ProxyFetchInit {
method?: string;
headers?: Record<string, string>;
body?: string | ArrayBuffer | Uint8Array | Blob | null;
/** "text" (default), "json", "arraybuffer", or "stream" for a ReadableStream body. */
responseType?: 'text' | 'json' | 'arraybuffer' | 'stream';
/** Upload progress, in bytes. */
onProgress?: (loaded: number, total: number) => void;
/** Store these JSON fields of the response as the credential instead of returning them. */
capture?: { secret: string; username?: string };
/** Stops the request (and a streamed body). */
signal?: AbortSignal;
}
async function proxyFetch(credentialId: string, url: string, init: ProxyFetchInit = {}): Promise<unknown> {
const streamId = uid().replace(/[^a-z0-9]/g, '').slice(0, 24);
const entry: ProxyStream = { onProgress: init.onProgress };
proxyStreams.set(streamId, entry);
const stream = init.responseType === 'stream'
? new ReadableStream<Uint8Array>({
start(controller) { entry.controller = controller; },
cancel() {
proxyStreams.delete(streamId);
void callApi('proxy.cancel', [streamId]).catch(() => {});
},
})
: null;
const abort = () => {
void callApi('proxy.cancel', [streamId]).catch(() => {});
try { entry.controller?.error(new Error('cancelled')); } catch { /* closed */ }
proxyStreams.delete(streamId);
};
init.signal?.addEventListener('abort', abort, { once: true });
const { onProgress: _p, signal: _s, ...plain } = init;
void _p; void _s;
try {
// No overall timeout: a big upload or a long AI answer takes as long as
// it takes, and api.proxy.cancel / the signal stop it.
const res = await callApi('proxy.fetch', [credentialId, url, { ...plain, streamId }], 0) as Record<string, unknown>;
if (!stream || !res.ok) proxyStreams.delete(streamId);
return stream && res.ok ? { ...res, body: stream } : res;
} catch (err) {
proxyStreams.delete(streamId);
throw err;
}
}
function invokeHostCallback(callbackId: string, args: unknown[]): Promise<unknown> {
const id = uid();
return new Promise((resolve, reject) => {
pendingCallbacks.set(id, { resolve, reject });
sendToHost({ type: 'callback-invoke', id, callbackId, args });
setTimeout(() => {
const entry = pendingCallbacks.get(id);
if (!entry) return;
pendingCallbacks.delete(id);
entry.reject(new Error('host callback timed out after 30s'));
}, 30_000);
});
}
/**
* Walks an object graph received from the host and rehydrates
* `{ __pluginCallback: id }` markers into stub functions that round-trip via
* the 'callback-invoke' RPC. Mirrors `encodeCallbacks` in host-bridge.ts.
*/
function decodeCallbacks(value: unknown, depth = 0): unknown {
if (depth > 6) return null;
if (value === null || value === undefined) return value;
const t = typeof value;
if (t !== 'object') return value;
if (Array.isArray(value)) return value.map((v) => decodeCallbacks(v, depth + 1));
const obj = value as Record<string, unknown>;
if (typeof obj.__pluginCallback === 'string') {
const cbId = obj.__pluginCallback;
return (...args: unknown[]) => invokeHostCallback(cbId, args);
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = decodeCallbacks(v, depth + 1);
}
return out;
}
type PluginManifest = BackgroundInit['manifest'];
type PluginKeywordVisibility = 'show' | 'hide' | 'unread';
interface PluginKeywordDefinition {
id: string;
label: string;
color: string;
visibility?: PluginKeywordVisibility;
/** The plugin that added it with keywords.add; absent on the user's own tags. */
addedBy?: string;
}
type PluginKeywordDefinitionInput = Omit<PluginKeywordDefinition, 'color' | 'addedBy'> & { color?: string };
type PluginKeywordPatch = Pick<PluginKeywordDefinition, 'id'> &
Partial<Pick<PluginKeywordDefinition, 'label' | 'color' | 'visibility'>>;
interface PluginKeywordCounts {
total: number;
unread: number;
}
function buildPluginApi(manifest: PluginManifest) {
applySettings({ ...manifest.settings });
localeTables = JSON.parse(JSON.stringify(manifest.locales ?? {}));
return {
plugin: {
id: manifest.id,
version: manifest.version,
/** Current settings, defaults filled in. Updated in place when the user changes them. */
settings: settingsState,
/** Called with the new settings whenever the user changes them. Returns an unsubscribe function. */
onSettingsChange: (listener: (settings: Record<string, unknown>) => void) => {
settingsListeners.add(listener);
return () => { settingsListeners.delete(listener); };
},
},
/** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */
app: {
getInfo: () => callApi('app.getInfo', []) as Promise<AppInfoView>,
/** Settings matching a query (the user's language or English): page, label, description. No permission needed. */
searchSettings: (query: string, options?: { limit?: number }) =>
callApi('app.searchSettings', [query, options]) as Promise<SettingsSearchResult[]>,
/** The user's preferences: layout, formats, reading and sending behaviour. Needs settings:read. */
getSettings: () => callApi('app.getSettings', []) as Promise<AppSettingsView>,
},
crypto: {
getPublicKeys: () => callApi('crypto.getPublicKeys', []),
createPublicKey: (input: PublicKeyInput) => callApi('crypto.createPublicKey', [input]),
removePublicKey: (keyId: string) => callApi('crypto.removePublicKey', [keyId]),
setEncryptionAtRest: (config: EncryptionAtRestConfig) => callApi('crypto.setEncryptionAtRest', [config]),
getEncryptionAtRest: () => callApi('crypto.getEncryptionAtRest', []),
getWebAuthn: (masterCredentialIdBytes: number[]) => callApi('crypto.getWebAuthn', [masterCredentialIdBytes, manifest.id], 0),
createWebAuthn: (name: string, displayName: string) => callApi('crypto.createWebAuthn', [manifest.id, name, displayName], 0),
getPublicKeyFromWKD: (email: string) => callApi('crypto.getPublicKeyFromWKD', [email]),
},
storage: {
get: (key: string) => callApi('storage.get', [key]),
set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
remove: (key: string) => callApi('storage.remove', [key]),
keys: () => callApi('storage.keys', []),
},
user: {
getAccounts: () => callApi('user.getAccounts', []),
getIdentities: () => callApi('user.getIdentities', []),
/** The IANA time zone the app shows times in (the user's setting, else the browser's). */
getTimeZone: () => callApi('user.getTimeZone', []) as Promise<string>,
logout: () => callApi('user.logout', []),
},
http: {
// A Blob/File body is sent as a binary request; options.headers may then
// carry Content-Type and X-Plugin-* metadata for the receiving route.
// Any other body keeps the stock JSON behaviour.
post: (path: string, body: Record<string, unknown> | Blob, options?: { headers?: Record<string, string> }) =>
callApi('http.post', [path, body, options], NETWORK_API_TIMEOUT_MS),
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init], NETWORK_API_TIMEOUT_MS),
},
// Safe keyword methods are available to untrusted plugins with the
// matching email permission. Raw blob/submission methods remain restricted
// to the privileged tier for crypto plugins.
jmap: {
/**
* Enumerate keywords in the active account. JMAP servers supporting
* Keyword/get supply exact counts and provider-label metadata; other
* servers fall back to scanning message keywords.
*/
getKeywords: (options?: { limit?: number }) =>
callApi('jmap.getKeywords', [options]) as Promise<{
keywords: Record<string, number>;
scanned: number;
total: number;
complete: boolean;
labels: Array<{
id: string;
name: string;
color: string | null;
total: number;
unread: number;
isProviderLabel: boolean;
source: 'provider' | 'message';
}>;
}>,
/**
* Replace an email's complete keyword map. Omitted keywords are removed;
* use api.email.setKeyword/removeKeyword for an incremental mutation.
*/
setKeywords: (emailId: string, keywords: Record<string, true>, accountId?: string) =>
callApi('jmap.setKeywords', [emailId, keywords, accountId]) as Promise<void>,
/** Add one keyword without changing the message's other keywords. */
setKeyword: (emailId: string, keyword: string, accountId?: string) =>
callApi('jmap.setKeyword', [emailId, keyword, accountId]) as Promise<void>,
/** Remove one keyword without changing the message's other keywords. */
removeKeyword: (emailId: string, keyword: string, accountId?: string) =>
callApi('jmap.removeKeyword', [emailId, keyword, accountId]) as Promise<void>,
/** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */
fetchBlob: (blobId: string, opts?: { name?: string; type?: string, rangeHeader?: number }) =>
callApi('jmap.fetchBlob', [blobId, opts]) as Promise<Uint8Array>,
/** Upload bytes to the mail server (email:blob-write); pass the blob id to a composer's onAttach. */
uploadBlob: (content: Uint8Array, name: string, type: string) =>
callApi('jmap.uploadBlob', [content, name, type]) as Promise<{ blobId: string; size: number; type: string; }>,
/** Submit a fully-formed raw RFC822 message (already signed/encrypted). */
sendRaw: (
rawBytes: ArrayBuffer | ArrayBufferView,
identityId: string,
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]),
/** Submit without putting in sent box a fully-formed raw RFC822 message (already signed/encrypted). */
submitRaw: (
rawBytes: ArrayBuffer | ArrayBufferView,
identityId: string,
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
) => callApi('jmap.submitRaw', [rawBytes, identityId, opts]),
/** Import a fully-formed raw RFC822 message into the user's mailbox. */
importRaw: (
rawBytes: ArrayBuffer | ArrayBufferView,
mailboxRoles: string[],
opts?: { keywords?: Record<string, boolean>; accountId?: string },
) => callApi('jmap.importRaw', [rawBytes, mailboxRoles, opts]),
},
contacts: {
get: (contactId: string) => callApi('contact.get', [contactId]) as Promise<ContactCard | null>,
update: (contactId: string, updates: Partial<ContactCard>) => callApi('contact.update', [contactId, updates]) as Promise<void>,
create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise<ContactCard>,
search: (query: string) => callApi('contact.search', [query]) as Promise<ContactCard[]>,
list: (addressBookId?: string) => callApi('contact.list', [addressBookId]) as Promise<ContactCard[]>,
remove: (contactId: string) => callApi('contact.delete', [contactId]) as Promise<void>,
},
addressBooks: {
list: () => callApi('addressbook.list', []) as Promise<AddressBook[]>,
create: (name: string) => callApi('addressbook.create', [name]) as Promise<AddressBook>,
},
/**
* Used to alterate files before they are uploaded to server.
* Edited files are saved on indexedDB and remove once the upload to server begins.
* `get` needs the email:blob-read permission. `save` needs
* email:blob-write AND the privileged tier - replacing the bytes of a file
* the user is about to send is not something an untrusted plugin may do.
*/
upfiles: {
save: (formerFileId:string, file:File) =>
callApi('upfiles.save', [formerFileId, file]) as Promise<string>,
get: (fileId:string) =>
callApi('upfiles.get', [fileId]) as Promise<File>,
},
// Notifications (permission: ui:notifications).
notifications: {
/**
* A system notification when the user allowed them for the webmail,
* otherwise a toast; either way it names this plugin. At most 5 a
* minute. Clicking a system notification brings the webmail to the
* front and fires this plugin's onNotificationClick hook with the tag.
* Resolves to 'system' or 'toast'.
*/
show: (notification: NotificationInput) =>
callApi('notifications.show', [notification]) as Promise<'system' | 'toast'>,
},
// The message being written. `composerId` comes from the composer: the
// context of a click on a composer action, or a composer slot's props.
composer: {
/** The user's own text (no signature, no quote) as { html, text }. Needs compose:read. */
getBody: (composerId: string) =>
callApi('composer.getBody', [composerId]) as Promise<{ html: string; text: string }>,
/** The selection as { html, text, empty }. Needs compose:read. */
getSelection: (composerId: string) =>
callApi('composer.getSelection', [composerId]) as Promise<{ html: string; text: string; empty: boolean }>,
/**
* Replace the selection, or insert at the caret when nothing is
* selected; `{ below: true }` adds after the selection instead. One
* Ctrl+Z undoes it. Needs compose:write.
*/
replaceSelection: (composerId: string, content: { text: string } | { html: string }, options?: { below?: boolean }) =>
callApi('composer.replaceSelection', [composerId, content, options]) as Promise<boolean>,
/** Replace the user's own text; the signature and the quote stay. Needs compose:write. */
replaceBody: (composerId: string, content: { text: string } | { html: string }) =>
callApi('composer.replaceBody', [composerId, content]) as Promise<boolean>,
/** Attach a blob from jmap.uploadBlob. Needs email:blob-write. */
attach: (composerId: string, file: { blobId: string; name: string; type?: string; size: number }) =>
callApi('composer.attach', [composerId, file]) as Promise<boolean>,
},
toast: {
success: (m: string) => { void callApi('toast.success', [m]); },
error: (m: string) => { void callApi('toast.error', [m]); },
info: (m: string) => { void callApi('toast.info', [m]); },
warning: (m: string) => { void callApi('toast.warning', [m]); },
},
ui: {
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise.
* No timeout - it waits for the user's choice. */
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
callApi('ui.confirm', [opts], 0) as Promise<boolean>,
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
callApi('ui.alert', [opts], 0) as Promise<void>,
/** Opens a host-rendered prompt collecting one or more (optionally masked)
* fields. Resolves to a name→value map on submit, or null if cancelled.
* No timeout. */
prompt: (opts: {
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
fields?: Array<{ name: string; label: string; type?: 'text' | 'password'; placeholder?: string; required?: boolean }>;
}) => callApi('ui.prompt', [opts], 0) as Promise<Record<string, string> | null>,
/** Opens one of this plugin's OWN slots (see the 'plugin-dialog'
* SlotName) inside a real, app-root, full-size dialog - unlike every
* other slot, which renders wherever it's placed in the page and is
* constrained by that spot's own layout. Use this for anything that
* needs to be a large, genuinely clickable custom UI (a file browser,
* a multi-step form, etc.) rather than a toolbar/row-sized control.
* Resolves to whatever value the slot component passes to its
* `onResult` prop, or null if the user closes the dialog without
* calling it. No timeout - it waits for the user. */
openDialog: (opts: { title?: string; slot: string; extraProps?: Record<string, unknown>; width?: number }) =>
callApi('ui.openDialog', [opts], 0) as Promise<unknown>,
/** Re-runs the onRenderEmailBody hook for the open message (e.g. after a
* crypto plugin unlocks a key) so its body re-renders without a reload. */
rerenderEmail: () => callApi('ui.rerenderEmail', []) as Promise<void>,
rerenderFetchedEmails: () => callApi('ui.rerenderFetchedEmails', []) as Promise<void>,
/** Opens an http/https URL in a new tab via host `window.open`. */
openExternalUrl: (url: string, target?: string) =>
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
/** Downloads a file generated by the plugin. Not a user's file or attachment. */
/** `content` is text, or the file's bytes (ArrayBuffer / Uint8Array). */
downloadFile: (opts: { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string }) =>
callApi('ui.downloadFile', [opts]) as Promise<void>,
/**
* Buttons or menu entries on an action surface (see the slot catalogue:
* message rows, the selection toolbar, attachment menus, the command
* palette, ...). Replaces what this plugin set there before; [] removes
* them. Clicks arrive in the `onAction({ surface, actionId, context })` hook.
*/
setActions: (surface: string, actions: Array<{ id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean }>) =>
callApi('ui.setActions', [surface, actions]) as Promise<void>,
/**
* Ask every visible message row for its onEmailListItemRender badges
* again, e.g. after a task was created elsewhere. At most once a second.
*/
refreshRowBadges: () => callApi('ui.refreshRowBadges', []) as Promise<void>,
/** Short labels and dots on calendar days. Replaces this plugin's earlier labels. */
setDayLabels: (labels: Array<{ date: string; text?: string; title?: string; dot?: boolean }>) =>
callApi('ui.setDayLabels', [labels]) as Promise<void>,
/** Full pages with a button in the navigation bar; the `app-page` slot renders them. */
setPages: (pages: Array<{ id: string; title: string; icon?: string }>) =>
callApi('ui.setPages', [pages]) as Promise<void>,
/**
* The header of this plugin's sidebar section (the host draws it like
* Folders and Tags), e.g. a translated "Upcoming birthdays". null goes
* back to the plugin's name. At most 40 characters.
*/
setSectionTitle: (title: string | null) => callApi('ui.setSectionTitle', [title]) as Promise<void>,
/**
* Open, close or toggle the panel this plugin's slot sits in:
* 'composer-sidebar', 'composer-sidebar-right' (pass the composerId
* from the click context or the slot's props) or 'email-detail-sidebar'.
* Resolve to whether the panel is open afterwards, or null when no such
* panel is on screen (no composer or message open, or a phone), so the
* plugin can fall back to a dialog. A panel opened this way gets a new
* `focusRequest` prop, so the slot can focus its input.
*/
openPanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'open', opts]) as Promise<boolean | null>,
closePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'close', opts]) as Promise<boolean | null>,
togglePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'toggle', opts]) as Promise<boolean | null>,
},
// Requests with an account or key the manifest declares under
// `credentials`; the server adds the secret (permission http:proxy).
proxy: {
fetch: proxyFetch,
/** Show the host's dialog to connect a user credential. */
connect: (credentialId: string, opts?: { mode?: 'password' | 'signin'; server?: string; username?: string }) =>
callApi('proxy.connect', [credentialId, opts ?? {}], 0) as Promise<{ connected: boolean; origin?: string }>,
/**
* Which credentials are ready: accounts the user connected (scope
* 'user', with server and user name) and the admin's (scope 'admin',
* connected = set up). No secrets.
*/
accounts: () => callApi('proxy.accounts', []) as Promise<Array<{ credentialId: string; scope: 'user' | 'admin'; origin?: string; username?: string; connected: boolean }>>,
disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise<boolean>,
},
// Reading mail (permission: email:read) and keyword mutations
// (permission: email:write). Keywords follow JMAP syntax, e.g.
// '$category-promotions' or '$label:<tagId>'.
email: {
/**
* One message with its plain text, headers and source, or null when
* there is none with that id. Any message of the account, not only the
* open one.
*/
get: (emailId: string) => callApi('email.get', [emailId]) as Promise<EmailReadView | null>,
/**
* Move messages (at most 100) into a folder of the account, like the
* user would; the move hooks run. 20 moves a minute. Needs email:write.
*/
move: (emailIds: string[], mailboxId: string) => callApi('email.move', [emailIds, mailboxId]) as Promise<void>,
/** Move messages into the account's Archive folder. Needs email:write. */
archive: (emailIds: string[]) => callApi('email.archive', [emailIds]) as Promise<void>,
/**
* A page of a folder (or of all mail without mailboxId), in the user's
* list order: summaries without the body. limit 1-100 (default 50).
*/
list: (options?: { mailboxId?: string; limit?: number; position?: number }) =>
callApi('email.list', [options]) as Promise<EmailSummary[]>,
/**
* Messages matching a search, newest first, as summaries without the
* body: `text` (sender, recipients, subject and body, whole words),
* `from`, `to`, `subject`, `after`/`before` (dates), `hasAttachment`,
* in one folder with `mailboxId`. At least one condition; limit 1-50
* (default 20).
*/
search: (query: { text?: string; from?: string; to?: string; subject?: string; after?: string; before?: string; hasAttachment?: boolean; mailboxId?: string; limit?: number }) =>
callApi('email.search', [query]) as Promise<EmailSummary[]>,
/**
* The bytes of one attachment of a message (email:blob-read, at most
* 25 MB). `blobId` must be one of that message's attachments, e.g.
* from an `attachment-actions` slot or onAttachmentDownload.
*/
getAttachment: (emailId: string, blobId: string) =>
callApi('email.getAttachment', [emailId, blobId], NETWORK_API_TIMEOUT_MS) as Promise<{
name: string; type: string; size: number; data: ArrayBuffer;
}>,
/** The full RFC 5322 source of a message, to save as .eml (email:blob-read, at most 25 MB). */
getRaw: (emailId: string) =>
callApi('email.getRaw', [emailId], NETWORK_API_TIMEOUT_MS) as Promise<ArrayBuffer>,
setKeyword: (emailId: string, keyword: string, accountId?: string) =>
callApi('email.setKeyword', [emailId, keyword, accountId]) as Promise<void>,
removeKeyword: (emailId: string, keyword: string, accountId?: string) =>
callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise<void>,
},
// The composer (permission: compose:open). Opens it filled in; the user
// reviews the message and decides whether to send it.
compose: {
/**
* Open the composer with these fields. On the mail page it opens in
* place; anywhere else the app first goes to the mail page (the
* plugin is reloaded with it). Resolves to 'opened' or 'navigating'.
* At most 5 times a minute.
*/
open: (message: ComposeInput) => callApi('compose.open', [message]) as Promise<'opened' | 'navigating'>,
/**
* Reply to the message open in the reader (`all` for reply to all),
* starting the reply with `text`. Only the open message; resolves to
* true when the composer opened. Shares compose.open's 5 a minute.
*/
reply: (emailId: string, reply?: { text?: string; all?: boolean }) =>
callApi('compose.reply', [emailId, reply]) as Promise<boolean>,
},
// Drafts (permission: email:write).
drafts: {
/**
* Save a draft in the Drafts folder, from the user's default sender
* address, without opening the composer. Attachments are blobs the
* plugin uploaded with api.jmap.uploadBlob. Resolves to the draft's
* email id. At most 10 a minute.
*/
create: (draft: DraftInput) => callApi('drafts.create', [draft]) as Promise<string>,
},
// Folders (permission: email:read).
mailboxes: {
/** Every folder with its role, parent and message counts. */
list: () => callApi('mailboxes.list', []) as Promise<MailboxView[]>,
},
// Native sidebar tag definitions. Definition reads/writes use the existing
// settings permissions; server discovery and message counts use email:read.
// add() never overwrites a tag that is there. update() and remove() reach
// only the tags this plugin added, never the user's own or another
// plugin's. reorder() requires a complete permutation of existing ids.
keywords: {
list: () => callApi('keywords.list', []) as Promise<PluginKeywordDefinition[]>,
add: (definitions: PluginKeywordDefinitionInput[]) =>
callApi('keywords.add', [definitions]) as Promise<{
added: PluginKeywordDefinition[];
skipped: string[];
}>,
/** Change the label, color or visibility of tags this plugin added; the id stays. */
update: (definitions: PluginKeywordPatch[]) =>
callApi('keywords.update', [definitions]) as Promise<{
updated: PluginKeywordDefinition[];
skipped: string[];
}>,
/** Delete tags this plugin added. Messages keep their keywords. */
remove: (ids: string[]) =>
callApi('keywords.remove', [ids]) as Promise<{
removed: string[];
skipped: string[];
}>,
reorder: (ids: string[], options?: { caseSensitive?: boolean }) =>
callApi('keywords.reorder', [ids, options]) as Promise<PluginKeywordDefinition[]>,
discover: (options?: { limit?: number }) =>
callApi('keywords.discover', [options]) as Promise<{
keywords: Record<string, number>;
scanned: number;
total: number;
complete: boolean;
}>,
getCounts: (ids?: string[]) =>
callApi('keywords.getCounts', [ids]) as Promise<Record<string, PluginKeywordCounts>>,
refreshCounts: () =>
callApi('keywords.refreshCounts', []) as Promise<Record<string, PluginKeywordCounts>>,
},
// Message-list category tabs (permission: ui:message-list-tabs; categorize
// additionally needs email:write). The host renders the strip natively and
// ANDs the active tab's JMAP search filter (`query`) or keyword into the
// mailbox Email/query - see MessageListTabsConfig in plugin-types. Tabs
// are cleared automatically when the plugin unloads.
tabs: {
/** Register (or replace) this plugin's tab set. */
set: (config: unknown) => callApi('tabs.set', [config]) as Promise<void>,
/** Remove this plugin's tabs from the strip. */
clear: () => callApi('tabs.clear', []) as Promise<void>,
/** Current merged tabs, active tab id and unread counts. */
getState: () => callApi('tabs.getState', []) as Promise<{
tabs: unknown[]; activeTabId: string | null; tabCounts: Record<string, number>;
}>,
/** Re-query per-tab unread counts for the current mailbox. */
refreshCounts: () => callApi('tabs.refreshCounts', []) as Promise<Record<string, number>>,
/**
* Move messages to a tab (patches the category keywords via Email/set).
* Fires onBeforeEmailCategorize (cancellable) and onEmailCategorize.
* Resolves false when cancelled.
*/
categorize: (emailIds: string[], tabId: string) =>
callApi('tabs.categorize', [emailIds, tabId]) as Promise<boolean>,
},
// Message-list search (permission: email:read). Runs a JMAP Email
// FilterCondition/FilterOperator the way a user's search runs: in the
// scope picked in the search panel, shown as a chip, cleared with the
// search. Same contract as a tab's `query`. The plugin gets no results
// back; the list shows them.
search: {
run: (query: unknown, options?: { label?: string }) =>
callApi('search.run', [query, options]) as Promise<void>,
},
// Calendar events (permissions: calendar:read / calendar:write). Writes
// go through the calendar the way the user's own edits do, so other
// plugins' hooks run and may stop them. Nothing here sends invitations,
// updates or cancellations to participants.
calendar: {
/** The user's calendars. */
listCalendars: () => callApi('calendar.listCalendars', []) as Promise<CalendarView[]>,
/**
* Events from `from` to `to` (at most 366 days; `2026-05-01`,
* `2026-05-01T09:00` or `...Z`), recurring events expanded into their
* occurrences, earliest first, at most 1000.
*/
getEvents: (range: { from: string; to: string; calendarIds?: string[] }) =>
callApi('calendar.getEvents', [range]) as Promise<CalendarEventView[]>,
/** One event, or null when there is none with that id. */
getEvent: (id: string) => callApi('calendar.getEvent', [id]) as Promise<CalendarEventView | null>,
/** Resolves to the new event, or null when it was not created (a plugin stopped it or the server refused). */
createEvent: (event: CalendarEventInput) =>
callApi('calendar.createEvent', [event]) as Promise<CalendarEventView | null>,
/** Change the given fields; times are worked out against the event's current ones. */
updateEvent: (id: string, patch: Partial<CalendarEventInput>) =>
callApi('calendar.updateEvent', [id, patch]) as Promise<void>,
/** Delete an event, or one occurrence when given an occurrence's id. */
deleteEvent: (id: string) => callApi('calendar.deleteEvent', [id]) as Promise<void>,
},
// Tasks (permissions: tasks:read / tasks:write), through the task list
// the way the user's own edits go.
tasks: {
/** Open tasks, or every task with includeCompleted. At most 1000. */
list: (options?: { includeCompleted?: boolean }) => callApi('tasks.list', [options]) as Promise<TaskView[]>,
/** One task, or null. */
get: (id: string) => callApi('tasks.get', [id]) as Promise<TaskView | null>,
/** Resolves to the new task, or null when another plugin stopped it. */
create: (task: TaskInput) => callApi('tasks.create', [task]) as Promise<TaskView | null>,
/** Change the given fields. */
update: (id: string, patch: Partial<TaskInput>) => callApi('tasks.update', [id, patch]) as Promise<void>,
/** Mark a task done or not done. */
setComplete: (id: string, complete: boolean) => callApi('tasks.setComplete', [id, complete]) as Promise<void>,
delete: (id: string) => callApi('tasks.delete', [id]) as Promise<void>,
},
// Message templates (permissions: templates:read / templates:write).
// They live with the user's settings and sync across devices.
templates: {
list: () => callApi('templates.list', []) as Promise<TemplateView[]>,
/** One template, or null. */
get: (id: string) => callApi('templates.get', [id]) as Promise<TemplateView | null>,
/** At most 500 templates in all. An HTML body is sanitised. */
create: (template: TemplateInput) => callApi('templates.create', [template]) as Promise<TemplateView>,
update: (id: string, patch: Partial<TemplateInput>) =>
callApi('templates.update', [id, patch]) as Promise<TemplateView>,
delete: (id: string) => callApi('templates.delete', [id]) as Promise<void>,
},
// Sender identities. Read them with api.user.getIdentities()
// (identity:read); change one with identity:write.
identities: {
/**
* Change the name, reply-to addresses or signatures of one identity.
* The address can't be changed. An HTML signature is sanitised like
* one typed into Settings. Resolves to the identity as changed, or null
* when an onBeforeIdentityChange handler stopped it.
*/
update: (id: string, patch: IdentityPatch) =>
callApi('identities.update', [id, patch]) as Promise<IdentityView | null>,
},
// Out-of-office reply (permissions: vacation:read / vacation:write).
vacation: {
get: () => callApi('vacation.get', []) as Promise<VacationView>,
/** Resolves to the reply as saved, or null when another plugin stopped the change. */
set: (vacation: VacationInput) => callApi('vacation.set', [vacation]) as Promise<VacationView | null>,
},
// OAuth with a third party (permission: http:fetch). Put createState()'s
// value in the authorize URL; the code comes back through the
// onOAuthCallback hook, to this plugin only.
oauth: {
createState: () => callApi('oauth.createState', []) as Promise<string>,
getRedirectUri: () => callApi('oauth.getRedirectUri', []) as Promise<string>,
},
// Sieve integration for delivery-time classification (permissions:
// filters:read / filters:write). Plugins never write scripts directly -
// they register an onSieveScriptGenerate transform hook and call
// regenerate(), so user filter rules and plugin sections coexist in the
// single active script.
sieve: {
isSupported: () => callApi('sieve.isSupported', []) as Promise<boolean>,
getActiveScript: () => callApi('sieve.getActiveScript', []) as Promise<{ id: string; name: string; content: string } | null>,
validateScript: (content: string) =>
callApi('sieve.validateScript', [content]) as Promise<{ isValid: boolean; errors?: string[] }>,
/** Rebuild + re-upload the active script, running onSieveScriptGenerate. */
regenerate: () => callApi('sieve.regenerate', []) as Promise<void>,
},
/**
* The admin's settings for this plugin (configSchema) that aren't secrets,
* e.g. a server address. No permission needed; `type: "secret"` fields
* are never included. Use credentials for secrets.
*/
config: {
get: () => callApi('plugin.getConfig', []) as Promise<Record<string, unknown>>,
},
admin: {
getConfig: (key: string) => callApi('admin.getConfig', [key]),
getAllConfig: () => callApi('admin.getAllConfig', []),
setConfig: (key: string, v: unknown) => callApi('admin.setConfig', [key, v]),
deleteConfig: (key: string) => callApi('admin.deleteConfig', [key]),
},
log: {
debug: (...a: unknown[]) => console.debug(`[plugin:${manifest.id}]`, ...a),
info: (...a: unknown[]) => console.info(`[plugin:${manifest.id}]`, ...a),
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
},
// Localization for plugins. The host pushes the active locale (init +
// 'locale-change'); `t` resolves a key against the plugin's declared
// `locales` map (manifest.locales) plus anything added at runtime,
// trying `de-CH`, then `de`, then English, then the key itself, with
// optional {placeholder} interpolation.
i18n: {
get locale(): string {
return currentLocale();
},
getLocale: () => currentLocale(),
get dateLocale(): string {
return currentRegional.dateLocale;
},
get timeFormat(): '12h' | '24h' {
return currentRegional.hour12 ? '12h' : '24h';
},
get hour12(): boolean {
return currentRegional.hour12;
},
get timeZone(): string {
return currentRegional.timeZone;
},
get firstDayOfWeek(): 0 | 1 | 6 {
return currentRegional.firstDayOfWeek;
},
formatTime,
formatDate,
t(key: string, vars?: Record<string, string | number>): string {
return interpolate(lookup(localeTables, currentLocale(), key) ?? key, vars);
},
/** Whether any table has `key`, for `has(k) ? t(k) : fallback`. */
has: (key: string) => lookup(localeTables, currentLocale(), key) !== undefined,
/** Add or override strings for one locale. */
addTranslations(locale: string, strings: Record<string, string>): void {
localeTables[locale] = { ...(localeTables[locale] ?? {}), ...strings };
},
/** Called with the new locale when the user switches language. Returns an unsubscribe function. */
onLocaleChange: (listener: (locale: string) => void) => {
localeListeners.add(listener);
return () => { localeListeners.delete(listener); };
},
},
};
}
// ─── Bundle evaluation ───────────────────────────────────────
/**
* Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
* bundlers should be configured to externalise React; the runtime provides
* those modules here. Anything else is refused - the sandbox has no Node-
* compatible module resolution and we don't want plugins probing globals.
*
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
* `const api = require('@plugin-host')` in both background and slot modes.
*/
export function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null): (name: string) => unknown {
const known: Record<string, unknown> = {
'react': React,
'react-dom': ReactDOM,
'react-dom/client': ReactDOM,
'react/jsx-runtime': ReactJSXRuntime,
'react/jsx-dev-runtime': ReactJSXRuntime,
};
if (api) known['@plugin-host'] = api;
// The app's controls (lib/plugin-sandbox/ui-kit), so plugins draw the same
// buttons, fields and lists as the app without bundling their own.
known['@plugin-host/ui'] = pluginUiKit;
return (name: string) => {
if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
};
}
function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null): PluginExports {
const mod: { exports: PluginExports } = { exports: {} };
const requireShim = makePluginRequire(api);
let fn: (...args: unknown[]) => void;
try {
fn = new Function(
'module', 'exports', 'require', 'React', 'ReactDOM', 'JsxRuntime', 'console',
code,
) as (...args: unknown[]) => void;
} catch (err) {
throw new Error(`Bundle parse error: ${(err as Error).message}`);
}
try {
fn(mod, mod.exports, requireShim, React, ReactDOM, ReactJSXRuntime, console);
} catch (err) {
throw new Error(`Bundle evaluation threw: ${(err as Error).message}`);
}
const exports = (mod.exports?.default ?? mod.exports) as PluginExports;
if (!exports || typeof exports !== 'object') {
throw new Error('Bundle did not produce module.exports object');
}
return exports;
}
// ─── Init flow ───────────────────────────────────────────────
async function bootBackground(payload: BackgroundInit): Promise<void> {
const api = buildPluginApi(payload.manifest);
const exports = evaluateBundle(payload.code, api);
pluginExports = exports;
// Register hooks (each value must be a function).
const hookNames: string[] = [];
const hooks = exports.hooks ?? {};
for (const [name, handler] of Object.entries(hooks)) {
if (typeof handler === 'function') {
hookHandlers[name] = handler;
hookNames.push(name);
}
}
// Enumerate slot offers.
const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }> = [];
const slots = exports.slots ?? {};
for (const [name, def] of Object.entries(slots)) {
if (def && typeof def.component === 'function') {
slotInfo.push({
name: name as SlotName,
hasShouldShow: typeof def.shouldShow === 'function',
order: typeof def.order === 'number' ? def.order : 100,
// A panel that fills its panel instead of growing with its content.
...(def.fill === true && isFillableSlot(name) ? { fill: true } : {}),
// A panel the user opens when they want it (a chat), not on every message.
...(def.startFolded === true && isFillableSlot(name) ? { startFolded: true } : {}),
});
}
}
// Shortcuts: register each handler as a 'shortcut:<id>' hook so the host's
// global keydown dispatcher can invoke it.
const shortcutInfo: Array<{ id: string; keys: string; label: string; category?: string }> = [];
const shortcuts = exports.shortcuts ?? {};
for (const [id, def] of Object.entries(shortcuts)) {
if (!def || typeof def.handler !== 'function' || typeof def.keys !== 'string') continue;
hookHandlers[`shortcut:${id}`] = def.handler as (...args: unknown[]) => unknown;
hookNames.push(`shortcut:${id}`);
shortcutInfo.push({
id,
keys: def.keys,
label: typeof def.label === 'string' ? def.label : id,
category: typeof def.category === 'string' ? def.category : undefined,
});
}
// Side effects.
if (typeof exports.activate === 'function') {
await Promise.resolve(exports.activate(api));
}
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo, shortcuts: shortcutInfo });
}
function bootSlot(payload: SlotInit): void {
// Replay the host theme before first paint so the slot never flashes the UA
// default serif font or a light-on-light/dark mismatch.
applyHostTheme(payload.theme);
const api = buildPluginApi(payload.manifest);
const exports = evaluateBundle(payload.code, api);
pluginExports = exports;
slotName = payload.slot;
const slotDef = exports.slots?.[payload.slot];
if (!slotDef || typeof slotDef.component !== 'function') {
throw new Error(`Plugin "${payload.pluginId}" does not export slots["${payload.slot}"].component`);
}
const rootEl = document.getElementById('plugin-sandbox-root');
if (!rootEl) throw new Error('Sandbox root element missing');
let currentProps = decodeCallbacks(payload.extraProps) as Record<string, unknown>;
const Component = slotDef.component;
const inline = isInlineSlot(payload.slot);
// Filling its panel: the document is as tall as the frame and the slot's
// root takes all of it; the height is the panel's, so nothing is measured.
const fill = payload.fill === true && isFillableSlot(payload.slot);
if (fill) {
document.documentElement.style.height = '100%';
document.body.style.height = '100%';
document.body.style.overflow = 'hidden';
rootEl.style.height = '100%';
}
// A trivial pub/sub so host-pushed `props-update` messages re-render the
// slot tree without tearing down the iframe.
const propsListeners = new Set<(p: Record<string, unknown>) => void>();
slotPropsUpdater = (next) => {
currentProps = decodeCallbacks(next) as Record<string, unknown>;
for (const l of propsListeners) {
try { l(currentProps); } catch { /* ignore */ }
}
};
const SlotShell = () => {
const wrapRef = React.useRef<HTMLDivElement>(null);
const [props, setProps] = React.useState(currentProps);
const [, setSettingsVersion] = React.useState(0);
React.useEffect(() => {
propsListeners.add(setProps);
// An update can land between the first render and this effect; the
// props it replaced carry callbacks the host has already dropped.
setProps(currentProps);
return () => { propsListeners.delete(setProps); };
}, []);
// Re-render the slot when its settings change, so a component that reads
// api.plugin.settings while rendering shows the new values.
React.useEffect(() => {
const bump = () => setSettingsVersion((v) => v + 1);
settingsListeners.add(bump);
localeListeners.add(bump);
return () => {
settingsListeners.delete(bump);
localeListeners.delete(bump);
};
}, []);
React.useEffect(() => {
if (!wrapRef.current || fill) return;
const el = wrapRef.current;
let lastHeight = -1;
let lastWidth = -1;
const ro = new ResizeObserver(() => {
const rect = el.getBoundingClientRect();
const h = Math.ceil(rect.height);
const w = inline ? Math.ceil(rect.width) : undefined;
if (h !== lastHeight || (w !== undefined && w !== lastWidth)) {
lastHeight = h;
if (w !== undefined) lastWidth = w;
sendToHost({ type: 'slot-resize', height: h, ...(w !== undefined ? { width: w } : {}) });
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// An inline slot is measured at its natural width (max-content), not the
// iframe's: in a toolbar row the iframe starts out narrow, and content
// that wraps to fit it reports a narrow width back, so it never grew.
const style = inline
? { display: 'inline-block', width: 'max-content', maxWidth: MAX_INLINE_SLOT_WIDTH }
: fill ? { height: '100%' } : undefined;
return React.createElement('div', { ref: wrapRef, style },
React.createElement(SlotPropsContext.Provider, { value: props }, React.createElement(Component, props)));
};
const reactRoot = ReactDOM.createRoot(rootEl);
reactRoot.render(React.createElement(SlotShell));
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
}
// Populated by bootSlot - receives `props-update` messages.
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
async function handleInit(payload: InitPayload): Promise<void> {
if (bootDone) return;
bootDone = true;
mode = payload.mode;
// Make the active locale available to plugin code (api.i18n) right away -
// not only after the first 'locale-change' push.
applyLocale(payload.locale, payload.regional);
try {
if (payload.mode === 'background') {
await bootBackground(payload);
} else {
bootSlot(payload);
}
} catch (err) {
sendToHost({ type: 'init-error', error: (err as Error).message ?? String(err) });
}
}
// ─── Host message handler ────────────────────────────────────
function handleHostMessage(ev: MessageEvent): void {
// Only the framing window, posting from our own origin, may talk to us.
// Both were bound by `resolveHost` before this listener was installed; a
// message never establishes them (GHSA-96cx-gx36-3g79).
if (!parentWindow || !parentOrigin) return;
if (ev.source !== parentWindow) return;
if (ev.origin !== parentOrigin) return;
const msg = ev.data as HostToSandbox;
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
switch (msg.type) {
case 'init':
void handleInit(msg.payload);
break;
case 'api-response': {
const pending = pendingApi.get(msg.id);
if (!pending) return;
pendingApi.delete(msg.id);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error ?? 'api error'));
break;
}
case 'callback-response': {
const pending = pendingCallbacks.get(msg.id);
if (!pending) return;
pendingCallbacks.delete(msg.id);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error ?? 'callback error'));
break;
}
case 'hook-invoke': {
const handler = hookHandlers[msg.hookName];
if (!handler) {
sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: `no handler for ${msg.hookName}` });
return;
}
try {
const result = handler(...(msg.args ?? []));
Promise.resolve(result).then(
(v) => sendToHost({ type: 'hook-result', id: msg.id, ok: true, result: v }),
(e) => sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (e as Error).message ?? String(e) }),
);
} catch (err) {
sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (err as Error).message });
}
break;
}
case 'slot-should-show': {
// Resolved by the background instance for any slot it offers.
const slotDef = pluginExports?.slots?.[msg.slot];
let show = true;
try {
if (slotDef && typeof slotDef.shouldShow === 'function') {
show = !!slotDef.shouldShow(msg.context);
}
} catch {
show = false;
}
sendToHost({ type: 'slot-should-show-result', id: msg.id, show });
break;
}
case 'locale-change':
mergeLocaleTables(msg.locales);
applyLocale(msg.locale, msg.regional);
break;
case 'theme-change':
applyHostTheme(msg.theme);
// The host switches the iframe element's color-scheme on this, not
// before: while the two differ the browser paints an opaque canvas.
sendToHost({ type: 'theme-applied', dark: msg.theme.dark === true });
break;
case 'settings-change':
applySettings(msg.settings ?? {});
break;
case 'props-update':
slotPropsUpdater?.(msg.props ?? {});
break;
case 'stream-chunk': {
const entry = proxyStreams.get(msg.streamId);
try { entry?.controller?.enqueue(new Uint8Array(msg.chunk)); } catch { /* cancelled */ }
break;
}
case 'stream-end': {
const entry = proxyStreams.get(msg.streamId);
proxyStreams.delete(msg.streamId);
try {
if (msg.error) entry?.controller?.error(new Error(msg.error));
else entry?.controller?.close();
} catch { /* already closed */ }
break;
}
case 'stream-progress':
try { proxyStreams.get(msg.streamId)?.onProgress?.(msg.loaded, msg.total); } catch { /* plugin's own error */ }
break;
case 'floating-dropdown-select':
dispatchFloatingDropdownEvent(msg.id, { type: 'select', option: msg.option });
break;
case 'floating-dropdown-highlight':
dispatchFloatingDropdownEvent(msg.id, { type: 'highlight', index: msg.index });
break;
case 'floating-dropdown-dismiss':
dispatchFloatingDropdownEvent(msg.id, { type: 'dismiss' });
break;
}
}
// ─── Entry ───────────────────────────────────────────────────
/**
* Bind to the host and tell it the frame is ready. The sandbox document
* (sandbox-document.ts) runs this from its script, next to an existing
* #plugin-sandbox-root. Returns the teardown.
*/
export function startSandboxRuntime(): () => void {
// Not framed by a same-origin host: stay inert. No listener, no ping -
// nothing a window.open()-ing site could talk to.
const host = resolveHost();
if (!host) return () => {};
parentWindow = host.window;
parentOrigin = host.origin;
initFloatingDropdownBridge(
(msg) => sendToHost(msg),
() => !!parentWindow,
);
window.addEventListener('message', handleHostMessage);
// Initial ping, targeted at the host origin so it can only land on the
// page that framed us. Guard at module scope so React strict mode's
// double-invoke doesn't re-post (and so a re-post can't race with the
// parent's init reply).
if (!readyPosted) {
readyPosted = true;
sendToHost({ type: 'sandbox-ready' });
}
return () => {
window.removeEventListener('message', handleHostMessage);
};
}
/** The runtime as a React component, for tests that render it. */
export function SandboxRuntime(): React.JSX.Element {
useEffect(() => startSandboxRuntime(), []);
return <div id="plugin-sandbox-root" />;
}
// Suppress unused-variable warning when `mode` is only read for debugging.
void mode;
void slotName;