'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 ReactJSXRuntime from 'react/jsx-runtime'; import type { HostToSandbox, SandboxToHost, InitPayload, BackgroundInit, SlotInit, RegionalSnapshot, } from './protocol'; import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; import { backgroundReactDom, backgroundUiKit } from './ui-kit/background-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>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean; startFolded?: boolean }>; 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 }>(); // 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 = {}; // 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) => void>(); function applySettings(next: Record): 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 void; reject: (err: Error) => void }>(); const hookHandlers: Record 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 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 { 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; onProgress?: (loaded: number, total: number) => void; } const proxyStreams = new Map(); interface ProxyFetchInit { method?: string; headers?: Record; 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 { 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({ 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; 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 { 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']; 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 & { color?: string }; type PluginKeywordPatch = Pick & Partial>; 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) => 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, /** 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, /** The user's preferences: layout, formats, reading and sending behaviour. Needs settings:read. */ getSettings: () => callApi('app.getSettings', []) as Promise, }, 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, 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 | Blob, options?: { headers?: Record }) => 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; 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, accountId?: string) => callApi('jmap.setKeywords', [emailId, keywords, accountId]) as Promise, /** 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, /** 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, /** 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, /** 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; 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]) as Promise, create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise, search: (query: string) => callApi('contact.search', [query]) as Promise, list: (addressBookId?: string) => callApi('contact.list', [addressBookId]) as Promise, remove: (contactId: string) => callApi('contact.delete', [contactId]) as Promise, }, addressBooks: { list: () => callApi('addressbook.list', []) as Promise, create: (name: string) => callApi('addressbook.create', [name]) 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. * `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, get: (fileId:string) => callApi('upfiles.get', [fileId]) as Promise, }, // 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, /** 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, /** 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, }, 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>, /** 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; width?: number }) => callApi('ui.openDialog', [opts], 0) as Promise, /** 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. */ /** `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, /** * 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, /** * 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, /** 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, /** 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, /** * 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, /** * 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, closePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'close', opts]) as Promise, togglePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'toggle', opts]) as Promise, }, // 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>, disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise, }, // Reading mail (permission: email:read) and keyword mutations // (permission: email:write). Keywords follow JMAP syntax, e.g. // '$category-promotions' or '$label:'. 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, /** * 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, /** Move messages into the account's Archive folder. Needs email:write. */ archive: (emailIds: string[]) => callApi('email.archive', [emailIds]) as Promise, /** * 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, /** * 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, /** * 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, 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, }, // 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, }, // 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, }, // Folders (permission: email:read). mailboxes: { /** Every folder with its role, parent and message counts. */ list: () => callApi('mailboxes.list', []) as Promise, }, // 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, 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, discover: (options?: { limit?: number }) => callApi('keywords.discover', [options]) as Promise<{ keywords: Record; scanned: number; total: number; complete: boolean; }>, getCounts: (ids?: string[]) => callApi('keywords.getCounts', [ids]) as Promise>, refreshCounts: () => callApi('keywords.refreshCounts', []) 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, }, // 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, }, // 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, /** * 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, /** One event, or null when there is none with that id. */ getEvent: (id: string) => callApi('calendar.getEvent', [id]) as Promise, /** 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, /** Change the given fields; times are worked out against the event's current ones. */ updateEvent: (id: string, patch: Partial) => callApi('calendar.updateEvent', [id, patch]) as Promise, /** Delete an event, or one occurrence when given an occurrence's id. */ deleteEvent: (id: string) => callApi('calendar.deleteEvent', [id]) as Promise, }, // 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, /** One task, or null. */ get: (id: string) => callApi('tasks.get', [id]) as Promise, /** Resolves to the new task, or null when another plugin stopped it. */ create: (task: TaskInput) => callApi('tasks.create', [task]) as Promise, /** Change the given fields. */ update: (id: string, patch: Partial) => callApi('tasks.update', [id, patch]) as Promise, /** Mark a task done or not done. */ setComplete: (id: string, complete: boolean) => callApi('tasks.setComplete', [id, complete]) as Promise, delete: (id: string) => callApi('tasks.delete', [id]) as Promise, }, // 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, /** One template, or null. */ get: (id: string) => callApi('templates.get', [id]) as Promise, /** At most 500 templates in all. An HTML body is sanitised. */ create: (template: TemplateInput) => callApi('templates.create', [template]) as Promise, update: (id: string, patch: Partial) => callApi('templates.update', [id, patch]) as Promise, delete: (id: string) => callApi('templates.delete', [id]) as Promise, }, // 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, }, // Out-of-office reply (permissions: vacation:read / vacation:write). vacation: { get: () => callApi('vacation.get', []) as Promise, /** Resolves to the reply as saved, or null when another plugin stopped the change. */ set: (vacation: VacationInput) => callApi('vacation.set', [vacation]) as Promise, }, // 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, getRedirectUri: () => callApi('oauth.getRedirectUri', []) 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, }, /** * 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>, }, 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 { 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): 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 | null, renderer: Renderer | null = null): (name: string) => unknown { const reactDom = renderer?.ReactDOM ?? backgroundReactDom; 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; // 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. In a // background frame, stand-ins with the same names. known['@plugin-host/ui'] = renderer?.kit ?? backgroundUiKit; 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.`); }; } // ─── Renderer (slot frames only) ───────────────────────────── /** * React DOM and the UI kit. Only slot frames draw, so only they load these * (a separate chunk, ~350 kB to parse); a background frame gets React and * stand-ins. The host marks slot frames in their URL (?frame=slot) so the * load starts with the document, not after init. */ export interface Renderer { ReactDOM: typeof import('react-dom/client'); kit: unknown; SlotPropsContext: React.Context>; } let rendererPromise: Promise | null = null; export function loadRenderer(): Promise { rendererPromise ??= import('./slot-renderer') .then((m) => ({ ReactDOM: m.ReactDOM, kit: m.pluginUiKit, SlotPropsContext: m.SlotPropsContext })); return rendererPromise; } function evaluateBundle(code: string, api: ReturnType | null, renderer: Renderer | null = null): PluginExports { const mod: { exports: PluginExports } = { exports: {} }; const requireShim = makePluginRequire(api, renderer); 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, renderer?.ReactDOM ?? backgroundReactDom, 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; 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:' 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 }); } async function bootSlot(payload: SlotInit): Promise { // 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 renderer = await loadRenderer(); const { SlotPropsContext } = renderer; const api = buildPluginApi(payload.manifest); const exports = evaluateBundle(payload.code, api, renderer); 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'); // Props the host pushed while the renderer was loading replace the init ones. let currentProps = decodeCallbacks(propsBeforeBoot ?? payload.extraProps) as Record; propsBeforeBoot = null; 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) => 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); 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 = renderer.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; /** The last props-update that arrived before bootSlot was ready for it. */ let propsBeforeBoot: Record | 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. applyLocale(payload.locale, payload.regional); try { if (payload.mode === 'background') { await bootBackground(payload); } else { await 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': if (slotPropsUpdater) slotPropsUpdater(msg.props ?? {}); else if (mode === 'slot') propsBeforeBoot = 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 () => {}; if (new URLSearchParams(window.location.search).get('frame') === 'slot') void loadRenderer(); 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
; } // Suppress unused-variable warning when `mode` is only read for debugging. void mode; void slotName;