'use client'; // Runtime that boots inside the null-origin plugin sandbox iframe. // // Lifecycle: // 1. Iframe loads → posts 'sandbox-ready' to parent (targetOrigin '*' is OK; // the message carries no secrets, and the parent's first inbound message // gives us the origin to pin for everything that follows). // 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, } from './protocol'; import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; import type { SlotName } from '../plugin-types'; import { ContactCard } from '../jmap/types'; // ─── Module-scope state ────────────────────────────────────── interface PluginExports { slots?: Record>; shouldShow?: (ctx: unknown) => boolean; order?: number }>; hooks?: Record unknown>; /** * Keyboard shortcut bindings. Each entry's `handler` is registered as a * hook named `shortcut:` so the host's keydown dispatcher can fire it. */ shortcuts?: Record void | Promise; }>; activate?: (api: unknown) => void | Promise | { 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 void; reject: (err: Error) => void }>(); const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); const hookHandlers: Record unknown> = {}; function sendToHost(msg: SandboxToHost): void { if (!parentWindow || !parentOrigin) return; parentWindow.postMessage(msg, parentOrigin); } // ─── 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 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; function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise { 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); } }); } function invokeHostCallback(callbackId: string, args: unknown[]): Promise { 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; if (typeof obj.__pluginCallback === 'string') { const cbId = obj.__pluginCallback; return (...args: unknown[]) => invokeHostCallback(cbId, args); } const out: Record = {}; for (const [k, v] of Object.entries(obj)) { out[k] = decodeCallbacks(v, depth + 1); } return out; } type PluginManifest = BackgroundInit['manifest']; function buildPluginApi(manifest: PluginManifest) { return { plugin: { id: manifest.id, version: manifest.version, settings: { ...manifest.settings }, }, webauthn: { getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, manifest.id, name, displayName], 0) }, 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', []), }, http: { post: (path: string, body: Record) => callApi('http.post', [path, body]), fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]), }, // Privileged-tier only (same-origin plugins). Calls throw for untrusted // plugins (the host refuses the method) — these power crypto plugins that // need raw message bytes and raw submission. jmap: { /** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */ fetchBlob: (blobId: string, opts?: { name?: string; type?: string }) => callApi('jmap.fetchBlob', [blobId, opts]) as Promise, /** 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; accountId?: string }, ) => callApi('jmap.importRaw', [rawBytes, mailboxRoles, opts]), }, contacts: { get: (contactId: string) => callApi('contact.get', [contactId]) as Promise, update: (contactId: string, updates: Partial) => callApi('contact.update', [contactId, updates]), create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise, search: (query: string) => callApi('contact.search', [query]) as Promise, }, /** * Used to alterate files before they are uploaded to server. * Edited files are saved on indexedDB and remove once the upload to server begins. */ upfiles: { save: (formerFileId:string, file:File) => callApi('upfiles.save', [formerFileId, file]) as Promise, get: (fileId:string) => callApi('upfiles.get', [fileId]) as Promise, }, 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, /** 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, /** 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 | null>, /** 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, rerenderFetchedEmails: () => callApi('ui.rerenderFetchedEmails', []) as Promise, /** 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, /** Downloads a file generated by the plugin. Not a user's file or attachment. */ downloadFile: (opts: { content: string; filename: string; contentType?: string }) => callApi('ui.downloadFile', [opts]) as Promise, }, // Email keyword mutations (permission: email:write). Keywords follow JMAP // syntax, e.g. '$category-promotions' or '$label:'. email: { setKeyword: (emailId: string, keyword: string, accountId?: string) => callApi('email.setKeyword', [emailId, keyword, accountId]) as Promise, removeKeyword: (emailId: string, keyword: string, accountId?: string) => callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise, }, // 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, /** Remove this plugin's tabs from the strip. */ clear: () => callApi('tabs.clear', []) as Promise, /** Current merged tabs, active tab id and unread counts. */ getState: () => callApi('tabs.getState', []) as Promise<{ tabs: unknown[]; activeTabId: string | null; tabCounts: Record; }>, /** Re-query per-tab unread counts for the current mailbox. */ refreshCounts: () => callApi('tabs.refreshCounts', []) as Promise>, /** * 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, }, // 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, 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, }, 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), falling back to English then the key // itself, with optional {placeholder} interpolation. i18n: { get locale(): string { return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; }, t(key: string, vars?: Record): string { const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; const tables = manifest.locales || {}; let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key; if (vars) { for (const [k, v] of Object.entries(vars)) { out = out.split('{' + k + '}').join(String(v)); } } return out; }, }, }; } // ─── 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. */ function makePluginRequire(api: ReturnType | null): (name: string) => unknown { const known: Record = { 'react': React, 'react-dom': ReactDOM, 'react-dom/client': ReactDOM, 'react/jsx-runtime': ReactJSXRuntime, 'react/jsx-dev-runtime': ReactJSXRuntime, }; if (api) known['@plugin-host'] = api; 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 | 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 { 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 }> = []; 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, }); } } // Shortcuts: register each handler as a 'shortcut:' 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; const Component = slotDef.component; // 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) => void>(); slotPropsUpdater = (next) => { currentProps = decodeCallbacks(next) as Record; for (const l of propsListeners) { try { l(currentProps); } catch { /* ignore */ } } }; const SlotShell = () => { const wrapRef = React.useRef(null); const [props, setProps] = React.useState(currentProps); React.useEffect(() => { propsListeners.add(setProps); return () => { propsListeners.delete(setProps); }; }, []); React.useEffect(() => { if (!wrapRef.current) return; let lastHeight = -1; const ro = new ResizeObserver((entries) => { for (const entry of entries) { const h = Math.ceil(entry.contentRect.height); if (h !== lastHeight) { lastHeight = h; sendToHost({ type: 'slot-resize', height: h }); } } }); ro.observe(wrapRef.current); return () => ro.disconnect(); }, []); return React.createElement('div', { ref: wrapRef }, 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) => void) | null = null; async function handleInit(payload: InitPayload): Promise { 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. (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale; 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 { // First inbound message pins source + origin. Reject everything else. if (!parentWindow) { if (!ev.source || ev.source === window) return; parentWindow = ev.source as Window; parentOrigin = ev.origin || null; } if (ev.source !== parentWindow) return; if (parentOrigin && 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': (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale; break; case 'theme-change': applyHostTheme(msg.theme); break; case 'props-update': slotPropsUpdater?.(msg.props ?? {}); break; } } // ─── React entry ───────────────────────────────────────────── export function SandboxRuntime(): React.JSX.Element { useEffect(() => { window.addEventListener('message', handleHostMessage); // Initial ping. We don't know parent origin yet, so '*' is required. // 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 && window.parent && window.parent !== window) { readyPosted = true; window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*'); } return () => { window.removeEventListener('message', handleHostMessage); }; }, []); return
; } // Suppress unused-variable warning when `mode` is only read for debugging. void mode; void slotName;