// Plugin Hook Bus - event bus system for plugin lifecycle hooks import type { CalendarEventFormView, Disposable } from './plugin-types'; // ─── Error Tracker (Circuit Breaker) ───────────────────────── interface ErrorRecord { timestamps: number[]; disabled: boolean; } const ERROR_THRESHOLD = 3; const ERROR_WINDOW_MS = 60_000; class PluginErrorTracker { private records = new Map(); private onAutoDisable?: (pluginId: string, error: unknown) => void; setAutoDisableCallback(cb: (pluginId: string, error: unknown) => void): void { this.onAutoDisable = cb; } record(pluginId: string, error: unknown): void { const now = Date.now(); let rec = this.records.get(pluginId); if (!rec) { rec = { timestamps: [], disabled: false }; this.records.set(pluginId, rec); } // Prune old timestamps rec.timestamps = rec.timestamps.filter(t => now - t < ERROR_WINDOW_MS); rec.timestamps.push(now); console.error(`[plugin:${pluginId}] Hook error:`, error); if (rec.timestamps.length >= ERROR_THRESHOLD && !rec.disabled) { rec.disabled = true; console.error(`[plugin:${pluginId}] Auto-disabled after ${ERROR_THRESHOLD} errors in ${ERROR_WINDOW_MS / 1000}s`); this.onAutoDisable?.(pluginId, error); } } isDisabled(pluginId: string): boolean { return this.records.get(pluginId)?.disabled ?? false; } reset(pluginId: string): void { this.records.delete(pluginId); } resetAll(): void { this.records.clear(); } } export const pluginErrorTracker = new PluginErrorTracker(); // ─── Timeout Helper ────────────────────────────────────────── const DEFAULT_TIMEOUT_MS = 5000; // Intercept hooks frequently block on user confirmation modals (send, // reply-all, mailto, attachment upload), so they need a much longer budget // than observer / transform hooks. const INTERCEPT_TIMEOUT_MS = 60_000; // onBeforeBlobUpload handlers may ask the user and then push the attachment // to an external store before handing back a link (see // ExternalAttachmentResult): a real upload of a file too big to mail, which // takes minutes on a slow line. Two minutes cut off large offloads and // counted them toward auto-disable. const BLOB_UPLOAD_TIMEOUT_MS = 15 * 60_000; class HookTimeoutError extends Error {} /** Tells the hook timer when a plugin is waiting on the user. */ export interface HookDialogGate { /** True while the plugin has a dialog open or queued. */ hasOpenDialog(pluginId: string): boolean; /** Calls `cb` once the plugin has no dialog left; returns an unsubscribe. */ whenDialogsClosed(pluginId: string, cb: () => void): () => void; } let dialogGate: HookDialogGate | null = null; /** * A handler that asks the user something (a PGP passphrase inside * onRenderEmailBody) is not hung. The loader passes the dialog queue in here * so the timer stops while that plugin has a dialog open and starts over with * the full budget once the user has answered (#1302). */ export function setHookDialogGate(gate: HookDialogGate | null): void { dialogGate = gate; } function withTimeout(pluginId: string, promise: T | Promise, ms: number = DEFAULT_TIMEOUT_MS): Promise { if (!(promise instanceof Promise)) return Promise.resolve(promise); return new Promise((resolve, reject) => { let timer: ReturnType | undefined; let stopWaiting: (() => void) | undefined; const arm = () => { stopWaiting = undefined; timer = setTimeout(expire, ms); }; const expire = () => { if (dialogGate?.hasOpenDialog(pluginId)) { stopWaiting = dialogGate.whenDialogsClosed(pluginId, arm); return; } reject(new HookTimeoutError(`Hook timed out after ${ms}ms`)); }; const settle = () => { clearTimeout(timer); stopWaiting?.(); }; arm(); promise.then( (val) => { settle(); resolve(val); }, (err) => { settle(); reject(err); }, ); }); } // ─── HookBus ───────────────────────────────────────────────── /** * What a `takeOver` hook decided: `default` lets the host do its own thing, * `handled` means a plugin did it, `aborted` means a plugin stopped it on * purpose (failed, or the user cancelled a prompt), and `failed` means a * handler threw or timed out. */ export type TakeOverOutcome = 'default' | 'handled' | 'aborted' | 'failed'; /** A handler returns `{ abort: true }` to stop a `takeOver` operation. */ export function isAbortResult(value: unknown): value is { abort: true } { return typeof value === 'object' && value !== null && (value as { abort?: unknown }).abort === true; } // A plugin that takes the send over may wait on a passphrase prompt first. // When this budget runs out the host keeps the draft, but the plugin may // still finish the send, so it has to be long enough for a slow user. const COMPOSE_SEND_TIMEOUT_MS = 10 * 60_000; interface HookEntry unknown> { pluginId: string; handler: T; order: number; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export class HookBus any> { private handlers: HookEntry[] = []; /** * @param timeoutMs Per-handler budget for `emit` / `emitSync` / `transform`. * Defaults to 5s; a bus whose handlers legitimately do network work sets * its own (see `onBeforeBlobUpload`). `intercept` keeps its own longer * budget because those hooks block on user dialogs. * @param opts.failClosed For intercepts guarding an action that can't be * taken back (sending mail): a handler that throws or runs out of time * cancels the action instead of letting it through. */ constructor( private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, private readonly opts: { failClosed?: boolean } = {}, ) {} register(pluginId: string, handler: T, order: number = 100): Disposable { const entry: HookEntry = { pluginId, handler, order }; this.handlers.push(entry); this.handlers.sort((a, b) => a.order - b.order); return { dispose: () => { this.handlers = this.handlers.filter(h => h !== entry); }, }; } /** Remove all handlers for a given plugin */ removePlugin(pluginId: string): void { this.handlers = this.handlers.filter(h => h.pluginId !== pluginId); } /** Remove all handlers */ clear(): void { this.handlers = []; } get size(): number { return this.handlers.length; } /** Fire all handlers (observer pattern - no return values used) */ async emit(...args: Parameters): Promise { for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; try { await withTimeout(pluginId, handler(...args), this.timeoutMs); } catch (err) { pluginErrorTracker.record(pluginId, err); } } } /** Synchronous emit for performance-critical paths */ emitSync(...args: Parameters): void { for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; try { handler(...args); } catch (err) { pluginErrorTracker.record(pluginId, err); } } } /** Observer emit to one plugin's handlers only. */ async emitTo(pluginId: string, ...args: Parameters): Promise { for (const { pluginId: owner, handler } of this.handlers) { if (owner !== pluginId || pluginErrorTracker.isDisabled(owner)) continue; try { await withTimeout(owner, handler(...args), this.timeoutMs); } catch (err) { pluginErrorTracker.record(owner, err); } } } /** Fire handlers as interceptors - any returning false cancels the operation */ async intercept(...args: Parameters): Promise { return (await this.interceptOutcome(...args)) === 'allowed'; } /** * Like `intercept`, but says why an operation was stopped: `vetoed` when a * handler said no (it is expected to have told the user), `failed` when a * fail-closed bus stopped it because a handler threw or ran out of time. */ async interceptOutcome(...args: Parameters): Promise<'allowed' | 'vetoed' | 'failed'> { for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; try { const result = await withTimeout(pluginId, handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS)); if (result === false) return 'vetoed'; } catch (err) { pluginErrorTracker.record(pluginId, err); if (this.opts.failClosed) return 'failed'; } } return 'allowed'; } /** * Fire handlers that may take an operation over (see `onComposeSend`). * Unlike `intercept`, this fails closed: a handler that throws or runs out * of time stops the operation instead of letting the host's default run, * which for a send would put an unsigned message on the wire (#1207). */ async takeOver(...args: Parameters): Promise { for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; let result: unknown; try { result = await withTimeout(pluginId, handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS)); } catch (err) { pluginErrorTracker.record(pluginId, err); return 'failed'; } if (result === false) return 'handled'; if (isAbortResult(result)) return 'aborted'; } return 'default'; } /** * For "before" hooks whose handlers may stop an action (return `false`) or * change what it does (return a value). Handlers run once each, in order, * with the intercept budget; `accept` turns a return value into the next * value, or returns undefined to ignore it. A handler that throws or runs * out of time is skipped (fail open), and counts as a failure like any * other hook error. */ async interceptWithValue( initial: V, accept: (result: unknown, current: V) => V | undefined, opts: { timeoutMs?: number } = {}, ): Promise<{ allowed: false } | { allowed: true; value: V }> { const budget = opts.timeoutMs ?? Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS); let value = initial; for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; let result: unknown; try { result = await withTimeout(pluginId, (handler as (v: V) => unknown)(value), budget); } catch (err) { pluginErrorTracker.record(pluginId, err); continue; } if (result === false) return { allowed: false }; if (result === undefined || result === null || result === true) continue; const next = accept(result, value); if (next !== undefined) value = next; } return { allowed: true, value }; } /** * Ask every handler at once, each with the same initial value, and keep * each answer with the plugin that gave it, for hooks whose results the * host shows per plugin (onProvideSearchResults). A handler that throws or * runs out of time gives nothing. */ async collect(initial: V, ...rest: unknown[]): Promise<{ pluginId: string; value: unknown }[]> { const asked = this.handlers.filter(({ pluginId }) => !pluginErrorTracker.isDisabled(pluginId)); const answers = await Promise.all(asked.map(async ({ pluginId, handler }) => { try { const value: unknown = await withTimeout(pluginId, handler(initial, ...rest), this.timeoutMs); return { pluginId, value }; } catch (err) { pluginErrorTracker.record(pluginId, err); return null; } })); return answers.filter((a): a is { pluginId: string; value: unknown } => a !== null); } /** Fire handlers as transforms - each receives the output of the previous */ async transform(initial: V, ...rest: unknown[]): Promise { let value = initial; for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; try { const result = await withTimeout(pluginId, handler(value, ...rest), this.timeoutMs); if (result !== undefined && result !== false) { value = result as V; } } catch (err) { pluginErrorTracker.record(pluginId, err); } } return value; } } // ─── Attachment Offload Result ─────────────────────────────── /** * Structured value an `onBeforeBlobUpload` handler may return instead of a * file id. It asks the composer to drop the staged binary attachment and * append replacement content to the message body, so a plugin can offload the * file somewhere else (an external file drop, object storage, a share link) * and leave a link behind. * * Returning a string keeps the stock behaviour: it is treated as the id of the * (possibly rewritten) file to upload. */ export interface ExternalAttachmentResult { externalAttachment: true; /** * Inserted into the body when the composer is in rich-text mode. Sanitized * by the composer before it goes anywhere near the document - a handler * cannot inject script into the message being written. */ html: string; /** Appended to the body when the composer is in plain-text mode. */ text: string; /** * Id of the staged file to discard, when the handler re-saved it through * `upfiles.save` (which stores the new copy under a fresh id and deletes the * old one). Defaults to the id the hook was called with. Without this the * re-saved copy would be stranded in IndexedDB, since it is never uploaded. */ fileId?: string; } export function isExternalAttachmentResult(value: unknown): value is ExternalAttachmentResult { if (typeof value !== 'object' || value === null) return false; const candidate = value as Partial; return candidate.externalAttachment === true && typeof candidate.html === 'string' && typeof candidate.text === 'string' && (candidate.fileId === undefined || typeof candidate.fileId === 'string'); } // ─── All Hook Buses (one per hook across all 20 domains) ───── // §7.1 Email Hooks export const emailHooks = { onEmailOpen: new HookBus(), onEmailClose: new HookBus(), onEmailContentRender: new HookBus(), onThreadExpand: new HookBus(), // Intercept hook - fires before the composer opens. // Handlers receive ComposeOptions and may mutate fields in place. // Return false to cancel opening the composer. onBeforeCompose: new HookBus(), onComposerOpen: new HookBus(), onComposerClose: new HookBus(), // Fails closed with the long send budget: a guard that asks the user and // gets no answer must not let the mail go out behind its dialog. onBeforeEmailSend: new HookBus(COMPOSE_SEND_TIMEOUT_MS, { failClosed: true }), onAfterEmailSend: new HookBus(), // Transform hook - fires before the draft is auto-saved to the server. // Receive fields passed to client.createDraft and may mutate fields in place. // Return false to cancel the auto-save or a the fields. onBeforeDraftAutoSave: new HookBus(), onDraftAutoSave: new HookBus(), // Transform hook - fires before a draft is created from an email in draft mailbox. // Receive a Email object and may mutate fields in place. onBeforeEditDraft: new HookBus(), onBeforeEmailDelete: new HookBus(), onAfterEmailDelete: new HookBus(), onBeforeEmailMove: new HookBus(), onAfterEmailMove: new HookBus(), onBeforeEmailArchive: new HookBus(), // Fired after one or more emails are archived to the Archive mailbox onEmailArchive: new HookBus(), // Fired after one or more emails are moved out of the Archive mailbox onEmailUnarchive: new HookBus(), onBeforeEmailReadStateChange: new HookBus(), onEmailReadStateChange: new HookBus(), onBeforeEmailStarToggle: new HookBus(), onEmailStarToggle: new HookBus(), onBeforeEmailSpamToggle: new HookBus(), onEmailSpamToggle: new HookBus(), onBeforeEmailKeywordChange: new HookBus(), onEmailKeywordChange: new HookBus(), onMailboxChange: new HookBus(), onMailboxesRefresh: new HookBus(), onBeforeMailboxCreate: new HookBus(), onMailboxCreate: new HookBus(), onBeforeMailboxRename: new HookBus(), onMailboxRename: new HookBus(), onBeforeMailboxDelete: new HookBus(), onMailboxDelete: new HookBus(), onBeforeMailboxEmpty: new HookBus(), onMailboxEmpty: new HookBus(), onSearch: new HookBus(), onSearchResults: new HookBus(), onEmailSelectionChange: new HookBus(), onNewEmailReceived: new HookBus(), onPushConnectionChange: new HookBus(), onSyncStateChange: new HookBus(), onQuotaChange: new HookBus(), // Intercept hook - fired when a mailto: link is clicked. // Return false to prevent the browser from opening the system mail client. onMailtoIntercept: new HookBus(), // Transform hook - fires after onBeforeEmailSend has not cancelled, // immediately before the message is handed to the JMAP submission. Handlers // receive an OutgoingEmail and return a modified copy (or undefined to pass // through). Use to inject signatures, scrub tracking pixels from forwarded // bodies, encrypt content, or rewrite links. onTransformOutgoingEmail: new HookBus(), // Intercept hooks fired when the user clicks Reply / Reply-All / Forward. // Handler receives a ReplyContext; return false to cancel. onBeforeReply: new HookBus(), onBeforeReplyAll: new HookBus(), onBeforeForward: new HookBus(), // Transform hook - lets plugins replace the quote header block ("On X, // Y wrote:" / "---------- Forwarded message ----------") used when opening // a reply or forward. Initial value: QuoteHeader (host default), second // argument: QuoteHeaderContext. Handlers return a QuoteHeader (or // undefined to pass through). Fires once per composer open. onBuildQuoteHeader: new HookBus(), // Intercept hook fired before a file is added to the composer as an // attachment. Handler receives AttachmentInfo (size/type/name only - the // raw file is not exposed). Return false to refuse the upload. onBeforeAttachmentUpload: new HookBus(), // Intercept hook fired before a file is uploaded to JMAP server. // It is fired after onBeforeAttachmentUpload. // Handler receive the {file: File, blobId: 'undefined'} object. // If it uploaded, it must return the object with true blobId. // Here, the raw file sended is exposed and can be modified or replaced. // A handler may also return an ExternalAttachmentResult to drop the // attachment entirely and append replacement html/text to the body, which // lets a plugin offload the file elsewhere and leave a link behind. That // offload is a real upload, hence the extended per-handler budget. onBeforeBlobUpload: new HookBus(BLOB_UPLOAD_TIMEOUT_MS), // Observer fired after an attachment has been uploaded and its blobId is // available. Handler receives AttachmentInfo with `blobId` populated. onAfterAttachmentUpload: new HookBus(), // Observer fired when the user downloads an attachment from a message. onAttachmentDownload: new HookBus(), // Transform hook - lets plugins replace the preview URL or supply a custom // renderer for an attachment. Initial value: AttachmentPreview, second // argument: AttachmentInfo. onAttachmentPreview: new HookBus(), // Lets plugins contribute results from elsewhere to a search, drawn above // its message list (lib/plugin-search-results.ts). Each handler is asked // on its own with an empty ExternalSearchResult[] and { query, filters }, // so a click can go back to the plugin that gave the entry. onProvideSearchResults: new HookBus(), // Observer fired (debounced) when the composer draft body, subject, or // recipients change. Handler receives a DraftView snapshot. Use for AI // assistants, grammar checkers, etc. onDraftChange: new HookBus(), // Intercept hook - fires at the very TOP of the composer send path, before // the host builds and submits the message. Handler receives a ComposeSend // request (draft fields, recipients, identityId, attachments, and the user's // sign/encrypt intent) and may TAKE OVER sending entirely: build a raw MIME // message, sign/encrypt it, and submit it via `api.jmap.sendRaw`. Returning // false signals "I handled the send": the host SKIPS its default submission, // deletes the draft and closes the composer. Returning { abort: true } means // "nothing was sent" (signing failed, a passphrase prompt was cancelled): the // host keeps the composer and the draft as they are. A handler that throws // or times out counts as an abort, never as permission to send unsigned. // Returning anything else (incl. undefined) lets the host send normally. // This is the send-takeover hook used by the S/MIME plugin to replace the // former native sign+encrypt+sendRaw pipeline. Fired with `takeOver`. onComposeSend: new HookBus(COMPOSE_SEND_TIMEOUT_MS), // Transform hook - receive Email[] or ScheduledEmail[] just after there are fetched to // lets plugin edit emails before they are shown in row. Used to populate preview // field for encryption plugins. onEmailsFetched: new HookBus(), // Transform hook - lets plugins edit the recipient chips in composer fields. // They can add, remove, or modify chips (e.g. rewrite addresses, add colors/icons). // Take Recipient[] as argument. onRecipientChipsChange: new HookBus(), // Transform hooks - lets plugins modify the email before host use it to populate fields. onBeforeComposeOpenToForwardAsAttachment: new HookBus(), onBeforeComposeOpenToReply: new HookBus(), onBeforeComposeOpenToReplyAll: new HookBus(), onBeforeComposeOpenToForward: new HookBus(), }; // §7.2 Calendar Hooks export const calendarHooks = { onCalendarEventOpen: new HookBus(), onBeforeEventCreate: new HookBus(), onAfterEventCreate: new HookBus(), onBeforeEventUpdate: new HookBus(), onAfterEventUpdate: new HookBus(), onBeforeEventDelete: new HookBus(), onAfterEventDelete: new HookBus(), onBeforeEventRsvp: new HookBus(), onEventRsvp: new HookBus(), onBeforeEventsImport: new HookBus(), onEventsImport: new HookBus(), onCalendarDateChange: new HookBus(), onCalendarViewChange: new HookBus(), onCalendarChange: new HookBus(), onCalendarVisibilityToggle: new HookBus(), onICalSubscriptionChange: new HookBus(), onCalendarAlert: new HookBus(), onCalendarAlertAcknowledge: new HookBus(), // Transform hook - fires when the event form is open and start/end change. // Initial value: ConflictWarning[], second argument: { event: CalendarEventFormView }. // Plugins return an extended array; the form renders each warning inline. onCheckEventConflicts: new HookBus(), }; // §7.2b Calendar Form Hooks (UI integration) /** What onCalendarEventFormSave handlers may set on the event being saved. */ export interface CalendarEventFormSavePatch { virtualLocation?: string; } export const calendarFormHooks = { onCalendarEventFormOpen: new HookBus(), /** * Fires while the event form is being saved. Handlers receive the patch * accumulated so far and the form view (including the event uid and * attendee emails), and may return a partial patch that the form merges * into the saved event, so a meeting plugin can refresh its link in the * same save that sends scheduling messages and attendees receive the * current URL. Returning undefined leaves the patch unchanged. */ onCalendarEventFormSave: new HookBus< ( patch: CalendarEventFormSavePatch, view: CalendarEventFormView, ) => | Promise | CalendarEventFormSavePatch | undefined >(), }; // §7.3 Contact Hooks export const contactHooks = { onContactOpen: new HookBus(), onBeforeContactCreate: new HookBus(), onAfterContactCreate: new HookBus(), onBeforeContactUpdate: new HookBus(), onAfterContactUpdate: new HookBus(), onBeforeContactDelete: new HookBus(), onAfterContactDelete: new HookBus(), onContactsImport: new HookBus(), onContactSelectionChange: new HookBus(), onContactGroupChange: new HookBus(), onContactGroupMemberChange: new HookBus(), onContactMove: new HookBus(), // Transform hook - lets plugins contribute extra recipient suggestions to // the composer's autocomplete. Initial value: RecipientSuggestion[], // second argument: { query: string }. onProvideRecipientSuggestions: new HookBus(), }; // §7.4 File Hooks export const fileHooks = { onFileNavigate: new HookBus(), onBeforeFileUpload: new HookBus(), onAfterFileUpload: new HookBus(), onFileDownload: new HookBus(), onFileUploadCancel: new HookBus(), onDirectoryCreate: new HookBus(), onBeforeFileDelete: new HookBus(), onAfterFileDelete: new HookBus(), // Intercept hook - fires before a file is renamed. // Receives { file: FileResourceView, newName: string }. // Return false to cancel the rename. onBeforeFileRename: new HookBus(), onFileRename: new HookBus(), onBeforeFileMove: new HookBus(), onFileMove: new HookBus(), onFileCopy: new HookBus(), onFileDuplicate: new HookBus(), onFileFavoriteToggle: new HookBus(), onFileSelectionChange: new HookBus(), onFileUndo: new HookBus(), }; // §7.5 Auth Hooks export const authHooks = { onLogin: new HookBus(), onBeforeLogout: new HookBus(), onAfterLogout: new HookBus(), onAccountSwitch: new HookBus(), onAccountAdd: new HookBus(), onAccountRemove: new HookBus(), onTokenRefresh: new HookBus(), onAuthReady: new HookBus(), /** * Fired when a third-party OAuth provider redirects back to the generic * plugin OAuth callback page (`/[locale]/plugins/oauth/callback`). The * payload is `{ code, state, receivedAt }` exactly as the provider sent it. * Each plugin validates `state` against the verifier it stashed before * redirecting, so plugins that did not initiate a flow simply ignore the * event. See `components/providers/plugin-oauth-callback-listener.tsx`. */ onOAuthCallback: new HookBus(), }; // §7.6 Settings Hooks export const settingsHooks = { onBeforeSettingChange: new HookBus(), onSettingChange: new HookBus(), onSettingsExport: new HookBus(), onSettingsImport: new HookBus(), onSettingsReset: new HookBus(), onSettingsSync: new HookBus(), onKeywordChange: new HookBus(), onTrustedSenderChange: new HookBus(), }; // §7.7 Identity Hooks export const identityHooks = { onIdentitiesLoaded: new HookBus(), onBeforeIdentityChange: new HookBus(), onIdentityCreate: new HookBus(), onIdentityUpdate: new HookBus(), onIdentityDelete: new HookBus(), onIdentitySelect: new HookBus(), onSignatureRender: new HookBus(), }; // §7.8 Filter Hooks export const filterHooks = { onFiltersLoaded: new HookBus(), onFilterRuleChange: new HookBus(), onBeforeFiltersSave: new HookBus(), // Observer fired after the host successfully uploads the account's active // Sieve script (visual-builder save or plugin-triggered regenerate). onFiltersSave: new HookBus(), onSieveScriptChange: new HookBus(), /** * Transform hook - runs on the full Sieve script text immediately before * the host uploads it as the account's active script. * * handler(script: string, ctx: SieveScriptGenerateContext): string | undefined * * Return a modified script (e.g. append a plugin-managed categorizer * section) or undefined to pass through. Handlers MUST keep the script * valid — put extra `require` statements at the very top. Trigger a * regeneration from a plugin via `api.sieve.regenerate()`. */ onSieveScriptGenerate: new HookBus(), }; // §7.9 Task Hooks export const taskHooks = { onTasksLoaded: new HookBus(), onBeforeTaskCreate: new HookBus(), onTaskCreate: new HookBus(), onBeforeTaskUpdate: new HookBus(), onTaskUpdate: new HookBus(), onBeforeTaskDelete: new HookBus(), onTaskDelete: new HookBus(), onTaskToggleComplete: new HookBus(), onTaskFilterChange: new HookBus(), }; // §7.10 Template Hooks export const templateHooks = { onTemplateCreate: new HookBus(), onTemplateUpdate: new HookBus(), onTemplateDelete: new HookBus(), onTemplateApply: new HookBus(), onTemplatesImport: new HookBus(), onTemplateRender: new HookBus(), }; // §7.12 Vacation Hooks export const vacationHooks = { onVacationLoaded: new HookBus(), onBeforeVacationUpdate: new HookBus(), onVacationUpdate: new HookBus(), }; // §7.13 UI Hooks export const uiHooks = { onViewChange: new HookBus(), onSidebarToggle: new HookBus(), onSidebarCollapse: new HookBus(), onDeviceTypeChange: new HookBus(), onColumnResize: new HookBus(), onMobileBack: new HookBus(), // Intercept hook - fires when the user clicks an external link inside the // app (typically inside an email body iframe). Handler receives // ExternalLinkContext; return false to cancel the navigation. Mutate // `href` in place to rewrite (e.g. strip UTM params, route via a proxy). onBeforeExternalLink: new HookBus(), // Observer (debounced) fired when the user changes the active text // selection inside an app surface. Receives SelectionContext. onTextSelectionChange: new HookBus(), }; // §7.14 Theme Hooks export const themeHooks = { onThemeChange: new HookBus(), onCustomThemeChange: new HookBus(), onLocaleChange: new HookBus(), /** * Transform hook fired immediately before a theme's compiled CSS is * injected into the document. * * handler(css: string, ctx: { themeId: string | null; variant: 'light' | 'dark' }): string | undefined * * Return a new CSS string to override what gets injected, or `undefined` * to pass through unchanged. Use this to inject extra `@font-face` rules, * patch a third-party theme's variables for accessibility, or implement * site-wide design-token overrides. */ onThemeBeforeApply: new HookBus(), }; // §7.15 Toast Hooks export const toastHooks = { onToastShow: new HookBus(), onToastDismiss: new HookBus(), // Observer fired when the user clicks an OS-level browser notification // dispatched by the host. Handler receives { tag: string, data?: unknown } // matching the original notification options. onNotificationClick: new HookBus(), }; // §7.16 Drag & Drop Hooks export const dragDropHooks = { onDragStart: new HookBus(), onDragEnd: new HookBus(), onEmailDrop: new HookBus(), onTagDrop: new HookBus(), }; // §7.17 Keyboard Hooks export const keyboardHooks = { onBeforeShortcut: new HookBus(), onAfterShortcut: new HookBus(), }; // §7.18 App Lifecycle Hooks export const appLifecycleHooks = { onAppReady: new HookBus(), onVisibilityChange: new HookBus(), onBeforeUnload: new HookBus(), onAppError: new HookBus(), onInterval: new HookBus(), // Observer fired when the browser window receives focus / blur. Useful for // refresh-on-focus behaviour (re-poll, recheck staleness, pause timers). onWindowFocus: new HookBus(), onWindowBlur: new HookBus(), // Observer fired when network connectivity transitions. Mirrors the // navigator online / offline events. onOnline: new HookBus(), onOffline: new HookBus(), }; // §7.19 Account Security Hooks export const accountSecurityHooks = { onPasswordChange: new HookBus(), onTotpChange: new HookBus(), onAppPasswordChange: new HookBus(), onEncryptionChange: new HookBus(), onDisplayNameChange: new HookBus(), }; // §7.20 Sidebar App Hooks export const sidebarAppHooks = { onSidebarAppOpen: new HookBus(), onSidebarAppClose: new HookBus(), }; // §7.21 Avatar Hooks // Transform hook: handlers receive (currentUrl: string | null, context: { email: string; name?: string }) // and return a URL string to use as the avatar, or undefined/null to pass through to the next handler. export const avatarHooks = { onAvatarResolve: new HookBus(), }; // §7.23 Router Hooks // Observers fired by the app router. Handlers receive a RouteContext; on // onNavigate the previous path is exposed via `from`. export const routerHooks = { onNavigate: new HookBus(), onRouteEnter: new HookBus(), onRouteLeave: new HookBus(), }; // §7.22 Render Hooks export const renderHooks = { // Transform hook - runs for each visible email list row. // Initial value: EmailListBadge[] (always starts as []) // Second argument: { emailId: string; email: EmailReadView } // Handlers return a new (or extended) badges array. // Rendered by the email list row component next to the subject line. onEmailListItemRender: new HookBus(), // Transform hook - runs when an email is opened, BEFORE the viewer computes // the body it will render. Initial value: RenderableBody { html, text, // attachments, handledBy? }. Second argument: MessageContext { id, // bodyStructure, attachments, blobId, contentType, from }. A handler may // inspect the message (e.g. detect S/MIME), fetch the raw blob via // `api.jmap.fetchBlob`, decrypt/verify in-frame, and return a REPLACED body // with `handledBy` set plus optional `verification` status. Return undefined // (or the unchanged value) to pass through. The host still runs the returned // HTML through its sanitizer — plugin output is not trusted blindly. This is // the render-takeover hook used by the S/MIME plugin to replace the former // native detect/decrypt/verify path in the viewer. // // The host also enforces the user's external-content preference on the // returned HTML: images, media, backgrounds and CSS url() pointing at remote // hosts are neutralised (and the "external content blocked" banner shown) // whenever the setting says block/ask, exactly as for a non-encrypted body. // A handler cannot opt out of this — a decrypted newsletter must not leak a // read receipt the user asked to suppress (#797) — so plugins do NOT need to // reimplement the check themselves. onRenderEmailBody: new HookBus(), }; // §7.24 Message-List Tab Hooks (Gmail-style category tabs) export const messageListTabHooks = { // Observer - the merged tab set changed (a plugin registered or cleared // its tabs). Receives the resolved MessageListTab[] (empty when cleared). onTabsChange: new HookBus(), // Observer - the user switched tabs. Receives TabActivateContext. onTabActivate: new HookBus(), // Intercept - fires before the host applies a category keyword patch to // messages (api.tabs.categorize or native UI). Receives // EmailCategorizeContext; return false to cancel the move. onBeforeEmailCategorize: new HookBus(), // Observer - fires after the keyword patch was applied. Receives the same // EmailCategorizeContext. This is where a plugin persists per-sender // overrides and calls api.sieve.regenerate() ("do this for all mail from X"). onEmailCategorize: new HookBus(), // Observer - per-tab unread counts were refreshed. Receives // Record. onTabCountsRefresh: new HookBus(), }; // ─── Aggregate: remove all handlers for a plugin across all buses ─── const allHookGroups = [ emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, messageListTabHooks, ]; export function removeAllPluginHooks(pluginId: string): void { for (const group of allHookGroups) { for (const bus of Object.values(group)) { (bus as HookBus).removePlugin(pluginId); } } } export function clearAllHooks(): void { for (const group of allHookGroups) { for (const bus of Object.values(group)) { (bus as HookBus).clear(); } } }