Something went wrong. Try again.
Comail's webmail client, based on Bulwark. Public source mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707// Plugin Hook Bus - event bus system for plugin lifecycle hooks
import type { 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;
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 Error(`Hook timed out after ${ms}ms`)), ms); promise.then( (val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); }, ); });}
// ─── HookBus ─────────────────────────────────────────────────
interface HookEntry<T extends (...args: never[]) => unknown> { pluginId: string; handler: T; order: number;}
// eslint-disable-next-line @typescript-eslint/no-explicit-anyexport 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. */ constructor(private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
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) { pluginErrorTracker.record(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); } } }
/** Fire handlers as interceptors - any returning false cancels the operation */ async intercept(...args: Parameters<T>): Promise<boolean> { 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 false; } catch (err) { pluginErrorTracker.record(pluginId, err); } } return true; }
/** 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) { 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<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 Hooksexport 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(), onBeforeEmailSend: new HookBus(), 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(), // 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(), onEmailReadStateChange: new HookBus(), onEmailStarToggle: new HookBus(), onEmailSpamToggle: new HookBus(), onEmailKeywordChange: new HookBus(), onMailboxChange: new HookBus(), onMailboxesRefresh: new HookBus(), onMailboxCreate: new HookBus(), onMailboxRename: new HookBus(), onMailboxDelete: new HookBus(), onMailboxEmpty: new HookBus(), onSearch: new HookBus(), onSearchResults: new HookBus(), onEmailSelectionChange: new HookBus(), onNewEmailReceived: new HookBus(), onPushConnectionChange: 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" and the host SKIPS its default // submission. 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. onComposeSend: new HookBus(), // 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 Hooksexport const calendarHooks = { onCalendarEventOpen: new HookBus(), onBeforeEventCreate: new HookBus(), onAfterEventCreate: new HookBus(), onBeforeEventUpdate: new HookBus(), onAfterEventUpdate: new HookBus(), onBeforeEventDelete: new HookBus(), onAfterEventDelete: new HookBus(), onEventRsvp: 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)export const calendarFormHooks = { onCalendarEventFormOpen: new HookBus(), onCalendarEventFormSave: new HookBus(),};
// §7.3 Contact Hooksexport 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 Hooksexport 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(), onFileMove: new HookBus(), onFileCopy: new HookBus(), onFileDuplicate: new HookBus(), onFileFavoriteToggle: new HookBus(), onFileSelectionChange: new HookBus(), onFileUndo: new HookBus(),};
// §7.5 Auth Hooksexport 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 Hooksexport const settingsHooks = { onSettingChange: new HookBus(), onSettingsExport: new HookBus(), onSettingsImport: new HookBus(), onSettingsReset: new HookBus(), onSettingsSync: new HookBus(), onKeywordChange: new HookBus(), onTrustedSenderChange: new HookBus(),};
// §7.7 Identity Hooksexport const identityHooks = { onIdentitiesLoaded: new HookBus(), onIdentityCreate: new HookBus(), onIdentityUpdate: new HookBus(), onIdentityDelete: new HookBus(), onIdentitySelect: new HookBus(), onSignatureRender: new HookBus(),};
// §7.8 Filter Hooksexport const filterHooks = { onFiltersLoaded: new HookBus(), onFilterRuleChange: 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 Hooksexport const taskHooks = { onTasksLoaded: new HookBus(), onTaskCreate: new HookBus(), onTaskUpdate: new HookBus(), onTaskDelete: new HookBus(), onTaskToggleComplete: new HookBus(), onTaskFilterChange: new HookBus(),};
// §7.10 Template Hooksexport const templateHooks = { onTemplateCreate: new HookBus(), onTemplateUpdate: new HookBus(), onTemplateDelete: new HookBus(), onTemplateApply: new HookBus(), onTemplatesImport: new HookBus(), onTemplateRender: new HookBus(),};
// §7.11 S/MIME Hooksexport const smimeHooks = { onSmimeKeyImport: new HookBus(), onSmimeCertImport: new HookBus(), onSmimeKeyStateChange: new HookBus(), onSmimeDefaultsChange: new HookBus(),};
// §7.12 Vacation Hooksexport const vacationHooks = { onVacationLoaded: new HookBus(), onVacationUpdate: new HookBus(),};
// §7.13 UI Hooksexport const uiHooks = { onViewChange: new HookBus(), onSidebarToggle: new HookBus(), onSidebarCollapse: new HookBus(), onDeviceTypeChange: new HookBus(), onColumnResize: new HookBus(), onMobileBack: new HookBus(), onMobileViewSwitch: 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 Hooksexport 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 Hooksexport 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 Hooksexport const dragDropHooks = { onDragStart: new HookBus(), onDragEnd: new HookBus(), onEmailDrop: new HookBus(), onTagDrop: new HookBus(),};
// §7.17 Keyboard Hooksexport const keyboardHooks = { registerShortcut: new HookBus(), onBeforeShortcut: new HookBus(), onAfterShortcut: new HookBus(),};
// §7.18 App Lifecycle Hooksexport 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 Hooksexport const accountSecurityHooks = { onPasswordChange: new HookBus(), onTotpChange: new HookBus(), onAppPasswordChange: new HookBus(), onEncryptionChange: new HookBus(), onDisplayNameChange: new HookBus(),};
// §7.20 Sidebar App Hooksexport const sidebarAppHooks = { onSidebarAppOpen: new HookBus(), onSidebarAppClose: new HookBus(), onSidebarAppChange: 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 Hooksexport 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, smimeHooks, 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(); } }}