mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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
833 lines
32 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|