Files
webmail/lib/plugin-sandbox/runtime.tsx
T

975 lines
44 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,
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type { SlotName } 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 }>;
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, pushed by the host on init and on every change.
const localeListeners = new Set<(locale: string) => void>();
function currentLocale(): string {
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
}
function applyLocale(locale: string): void {
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = 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); }
}
}
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;
}
type PluginKeywordDefinitionInput = Omit<PluginKeywordDefinition, 'color'> & { color?: string };
interface PluginKeywordCounts {
total: number;
unread: number;
}
function buildPluginApi(manifest: PluginManifest) {
applySettings({ ...manifest.settings });
const localeTables: 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); };
},
},
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', []),
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>,
},
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. */
downloadFile: (opts: { content: string; 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>,
/** 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>,
},
// 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 connected (no secrets). */
accounts: () => callApi('proxy.accounts', []) as Promise<Array<{ credentialId: string; origin: string; username?: string; connected: boolean }>>,
disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise<boolean>,
},
// Email keyword mutations (permission: email:write). Keywords follow JMAP
// syntax, e.g. '$category-promotions' or '$label:<tagId>'.
email: {
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>,
},
// Native sidebar tag definitions. Definition reads/writes use the existing
// settings permissions; server discovery and message counts use email:read.
// add() is intentionally append-only. reorder() requires a complete
// permutation of existing ids. Neither method overwrites or removes tags.
keywords: {
list: () => callApi('keywords.list', []) as Promise<PluginKeywordDefinition[]>,
add: (definitions: PluginKeywordDefinitionInput[]) =>
callApi('keywords.add', [definitions]) as Promise<{
added: PluginKeywordDefinition[];
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>,
},
// 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>,
},
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(),
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.
*/
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;
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 }> = [];
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:<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);
// 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);
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) 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 }
: undefined;
return React.createElement('div', { ref: wrapRef, style }, 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);
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':
applyLocale(msg.locale);
break;
case 'theme-change':
applyHostTheme(msg.theme);
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;
}
}
// ─── React entry ─────────────────────────────────────────────
export function SandboxRuntime(): React.JSX.Element {
useEffect(() => {
// 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;
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);
};
}, []);
return <div id="plugin-sandbox-root" />;
}
// Suppress unused-variable warning when `mode` is only read for debugging.
void mode;
void slotName;