Files
webmail/lib/plugin-hooks.ts
T
Linus Rath a3cb9e630e refactor: describe every plugin hook in one catalogue
The hook buses, the permission each hook needs and the docs were three
separate lists that had drifted: 145 of 198 hooks were never fired and
nothing noticed. `lib/plugin-hook-catalog.ts` now names each hook with
its kind, permission, payload and when it fires; the permission check
reads it, and a test fails for any catalogued hook without a call site.
Groups still being wired in this branch are listed in that test.

Also:
- new before-hooks for archive, spam, read state, star, keywords, folder
  changes, RSVP, event import, tasks, file moves, settings, identities,
  filters and out-of-office, plus onComposerClose and onSyncStateChange
- removed hooks that can never fire: the S/MIME group (S/MIME is a
  plugin now), onMobileViewSwitch, onSidebarAppChange and the
  registerShortcut pseudo-hook
- smime:read no longer grants anything; manifests that name it still
  install, with a warning
2026-10-14 03:14:29 +02:00

833 lines
32 KiB
TypeScript

// 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<string, ErrorRecord>();
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 push the attachment to an external store
// before handing back a link (see ExternalAttachmentResult), which is a real
// upload over the wire - 5s would kill every offload of a file worth
// offloading, and three of those in a minute would auto-disable the plugin.
const BLOB_UPLOAD_TIMEOUT_MS = 120_000;
class HookTimeoutError extends Error {}
let timeoutListener: ((pluginId: string) => void) | null = null;
/**
* Called with the plugin id whenever one of its handlers runs out of time.
* The loader uses it to close that plugin's open dialogs: the host has
* already moved on, so a dialog left on screen would answer nothing.
*/
export function setHookTimeoutListener(listener: ((pluginId: string) => void) | null): void {
timeoutListener = listener;
}
function withTimeout<T>(promise: T | Promise<T>, ms: number = DEFAULT_TIMEOUT_MS): Promise<T> {
if (!(promise instanceof Promise)) return Promise.resolve(promise);
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new HookTimeoutError(`Hook timed out after ${ms}ms`)), ms);
promise.then(
(val) => { clearTimeout(timer); resolve(val); },
(err) => { clearTimeout(timer); 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<T extends (...args: never[]) => unknown> {
pluginId: string;
handler: T;
order: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class HookBus<T extends (...args: any[]) => any> {
private handlers: HookEntry<T>[] = [];
/**
* @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 } = {},
) {}
private recordFailure(pluginId: string, err: unknown): void {
pluginErrorTracker.record(pluginId, err);
if (err instanceof HookTimeoutError) {
try { timeoutListener?.(pluginId); } catch { /* ignore */ }
}
}
register(pluginId: string, handler: T, order: number = 100): Disposable {
const entry: HookEntry<T> = { 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<T>): Promise<void> {
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
await withTimeout(handler(...args), this.timeoutMs);
} catch (err) {
this.recordFailure(pluginId, err);
}
}
}
/** Synchronous emit for performance-critical paths */
emitSync(...args: Parameters<T>): 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<T>): Promise<void> {
for (const { pluginId: owner, handler } of this.handlers) {
if (owner !== pluginId || pluginErrorTracker.isDisabled(owner)) continue;
try {
await withTimeout(handler(...args), this.timeoutMs);
} catch (err) {
this.recordFailure(owner, err);
}
}
}
/** Fire handlers as interceptors - any returning false cancels the operation */
async intercept(...args: Parameters<T>): Promise<boolean> {
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<T>): Promise<'allowed' | 'vetoed' | 'failed'> {
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
const result = await withTimeout(handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS));
if (result === false) return 'vetoed';
} catch (err) {
this.recordFailure(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<T>): Promise<TakeOverOutcome> {
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
let result: unknown;
try {
result = await withTimeout(handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS));
} catch (err) {
this.recordFailure(pluginId, err);
return 'failed';
}
if (result === false) return 'handled';
if (isAbortResult(result)) return 'aborted';
}
return 'default';
}
/** Fire handlers as transforms - each receives the output of the previous */
async transform<V>(initial: V, ...rest: unknown[]): Promise<V> {
let value = initial;
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
const result = await withTimeout(handler(value, ...rest), this.timeoutMs);
if (result !== undefined && result !== false) {
value = result as V;
}
} catch (err) {
this.recordFailure(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<ExternalAttachmentResult>;
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(),
// Transform hook - lets plugins contribute additional results to the global
// search panel. Initial value: ExternalSearchResult[]. Second argument:
// { query: string, filters: SearchFilters }.
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>
| 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(),
onBrowserNotification: 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<tabId, number>.
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<never>).removePlugin(pluginId);
}
}
}
export function clearAllHooks(): void {
for (const group of allHookGroups) {
for (const bus of Object.values(group)) {
(bus as HookBus<never>).clear();
}
}
}