mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
A handler can now return an ExternalAttachmentResult ({externalAttachment:
true, html, text}) instead of a file id. The composer then deletes the
staged file, removes the attachment and appends the replacement content
to the body, so a plugin can push a large file to an external store and
leave a download link behind. Returning a string (or nothing) keeps the
stock upload path byte-for-byte.
Also adds plainTextMode to the upload callback deps, which the new branch
reads to pick between the html and text replacement.
659 lines
24 KiB
TypeScript
659 lines
24 KiB
TypeScript
// 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;
|
|
|
|
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-any
|
|
export class HookBus<T extends (...args: any[]) => any> {
|
|
private handlers: HookEntry<T>[] = [];
|
|
|
|
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));
|
|
} 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), 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));
|
|
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;
|
|
/** Appended to the body when the composer is in rich-text mode. */
|
|
html: string;
|
|
/** Appended to the body when the composer is in plain-text mode. */
|
|
text: 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';
|
|
}
|
|
|
|
// ─── 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(),
|
|
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.
|
|
onBeforeBlobUpload: new HookBus(),
|
|
// 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(),
|
|
};
|
|
|
|
// §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(),
|
|
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 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(),
|
|
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(),
|
|
};
|
|
|
|
// §7.6 Settings Hooks
|
|
export 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 Hooks
|
|
export const identityHooks = {
|
|
onIdentitiesLoaded: 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(),
|
|
// 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(),
|
|
onTaskCreate: new HookBus(),
|
|
onTaskUpdate: 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.11 S/MIME Hooks
|
|
export const smimeHooks = {
|
|
onSmimeKeyImport: new HookBus(),
|
|
onSmimeCertImport: new HookBus(),
|
|
onSmimeKeyStateChange: new HookBus(),
|
|
onSmimeDefaultsChange: new HookBus(),
|
|
};
|
|
|
|
// §7.12 Vacation Hooks
|
|
export const vacationHooks = {
|
|
onVacationLoaded: 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(),
|
|
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 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 = {
|
|
registerShortcut: new HookBus(),
|
|
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(),
|
|
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 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.
|
|
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();
|
|
}
|
|
}
|
|
}
|