mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
The hook was called on every search, but nothing showed its entries, the list waited for it, and an entry's onClick could not cross from the plugin's sandbox. Its entries are now drawn as a block above the search's message list, a group per plugin and source. Each plugin is asked on its own (HookBus.collect) beside the search, so the list no longer waits, and the answer to a search that was replaced is dropped. The answer is checked: at most 20 entries, a key and a title, plain text cut to its limits. A click goes to the plugin that gave the entry as onAction with surface "search-results", the entry's key and the query; ExternalSearchResult loses onClick. Closes #1336 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
905 lines
36 KiB
TypeScript
905 lines
36 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 ask the user and then push the attachment
|
|
// to an external store before handing back a link (see
|
|
// ExternalAttachmentResult): a real upload of a file too big to mail, which
|
|
// takes minutes on a slow line. Two minutes cut off large offloads and
|
|
// counted them toward auto-disable.
|
|
const BLOB_UPLOAD_TIMEOUT_MS = 15 * 60_000;
|
|
|
|
class HookTimeoutError extends Error {}
|
|
|
|
/** Tells the hook timer when a plugin is waiting on the user. */
|
|
export interface HookDialogGate {
|
|
/** True while the plugin has a dialog open or queued. */
|
|
hasOpenDialog(pluginId: string): boolean;
|
|
/** Calls `cb` once the plugin has no dialog left; returns an unsubscribe. */
|
|
whenDialogsClosed(pluginId: string, cb: () => void): () => void;
|
|
}
|
|
|
|
let dialogGate: HookDialogGate | null = null;
|
|
|
|
/**
|
|
* A handler that asks the user something (a PGP passphrase inside
|
|
* onRenderEmailBody) is not hung. The loader passes the dialog queue in here
|
|
* so the timer stops while that plugin has a dialog open and starts over with
|
|
* the full budget once the user has answered (#1302).
|
|
*/
|
|
export function setHookDialogGate(gate: HookDialogGate | null): void {
|
|
dialogGate = gate;
|
|
}
|
|
|
|
function withTimeout<T>(pluginId: string, 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) => {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let stopWaiting: (() => void) | undefined;
|
|
const arm = () => {
|
|
stopWaiting = undefined;
|
|
timer = setTimeout(expire, ms);
|
|
};
|
|
const expire = () => {
|
|
if (dialogGate?.hasOpenDialog(pluginId)) {
|
|
stopWaiting = dialogGate.whenDialogsClosed(pluginId, arm);
|
|
return;
|
|
}
|
|
reject(new HookTimeoutError(`Hook timed out after ${ms}ms`));
|
|
};
|
|
const settle = () => {
|
|
clearTimeout(timer);
|
|
stopWaiting?.();
|
|
};
|
|
arm();
|
|
promise.then(
|
|
(val) => { settle(); resolve(val); },
|
|
(err) => { settle(); reject(err); },
|
|
);
|
|
});
|
|
}
|
|
|
|
// ─── HookBus ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* What a `takeOver` hook decided: `default` lets the host do its own thing,
|
|
* `handled` means a plugin did it, `aborted` means a plugin stopped it on
|
|
* purpose (failed, or the user cancelled a prompt), and `failed` means a
|
|
* handler threw or timed out.
|
|
*/
|
|
export type TakeOverOutcome = 'default' | 'handled' | 'aborted' | 'failed';
|
|
|
|
/** A handler returns `{ abort: true }` to stop a `takeOver` operation. */
|
|
export function isAbortResult(value: unknown): value is { abort: true } {
|
|
return typeof value === 'object' && value !== null && (value as { abort?: unknown }).abort === true;
|
|
}
|
|
|
|
// A plugin that takes the send over may wait on a passphrase prompt first.
|
|
// When this budget runs out the host keeps the draft, but the plugin may
|
|
// still finish the send, so it has to be long enough for a slow user.
|
|
const COMPOSE_SEND_TIMEOUT_MS = 10 * 60_000;
|
|
|
|
interface HookEntry<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 } = {},
|
|
) {}
|
|
|
|
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(pluginId, 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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(owner, handler(...args), this.timeoutMs);
|
|
} catch (err) {
|
|
pluginErrorTracker.record(owner, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Fire handlers as interceptors - any returning false cancels the operation */
|
|
async intercept(...args: Parameters<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(pluginId, handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS));
|
|
if (result === false) return 'vetoed';
|
|
} catch (err) {
|
|
pluginErrorTracker.record(pluginId, err);
|
|
if (this.opts.failClosed) return 'failed';
|
|
}
|
|
}
|
|
return 'allowed';
|
|
}
|
|
|
|
/**
|
|
* Fire handlers that may take an operation over (see `onComposeSend`).
|
|
* Unlike `intercept`, this fails closed: a handler that throws or runs out
|
|
* of time stops the operation instead of letting the host's default run,
|
|
* which for a send would put an unsigned message on the wire (#1207).
|
|
*/
|
|
async takeOver(...args: Parameters<T>): Promise<TakeOverOutcome> {
|
|
for (const { pluginId, handler } of this.handlers) {
|
|
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
|
let result: unknown;
|
|
try {
|
|
result = await withTimeout(pluginId, handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS));
|
|
} catch (err) {
|
|
pluginErrorTracker.record(pluginId, err);
|
|
return 'failed';
|
|
}
|
|
if (result === false) return 'handled';
|
|
if (isAbortResult(result)) return 'aborted';
|
|
}
|
|
return 'default';
|
|
}
|
|
|
|
/**
|
|
* For "before" hooks whose handlers may stop an action (return `false`) or
|
|
* change what it does (return a value). Handlers run once each, in order,
|
|
* with the intercept budget; `accept` turns a return value into the next
|
|
* value, or returns undefined to ignore it. A handler that throws or runs
|
|
* out of time is skipped (fail open), and counts as a failure like any
|
|
* other hook error.
|
|
*/
|
|
async interceptWithValue<V>(
|
|
initial: V,
|
|
accept: (result: unknown, current: V) => V | undefined,
|
|
opts: { timeoutMs?: number } = {},
|
|
): Promise<{ allowed: false } | { allowed: true; value: V }> {
|
|
const budget = opts.timeoutMs ?? Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS);
|
|
let value = initial;
|
|
for (const { pluginId, handler } of this.handlers) {
|
|
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
|
let result: unknown;
|
|
try {
|
|
result = await withTimeout(pluginId, (handler as (v: V) => unknown)(value), budget);
|
|
} catch (err) {
|
|
pluginErrorTracker.record(pluginId, err);
|
|
continue;
|
|
}
|
|
if (result === false) return { allowed: false };
|
|
if (result === undefined || result === null || result === true) continue;
|
|
const next = accept(result, value);
|
|
if (next !== undefined) value = next;
|
|
}
|
|
return { allowed: true, value };
|
|
}
|
|
|
|
/**
|
|
* Ask every handler at once, each with the same initial value, and keep
|
|
* each answer with the plugin that gave it, for hooks whose results the
|
|
* host shows per plugin (onProvideSearchResults). A handler that throws or
|
|
* runs out of time gives nothing.
|
|
*/
|
|
async collect<V>(initial: V, ...rest: unknown[]): Promise<{ pluginId: string; value: unknown }[]> {
|
|
const asked = this.handlers.filter(({ pluginId }) => !pluginErrorTracker.isDisabled(pluginId));
|
|
const answers = await Promise.all(asked.map(async ({ pluginId, handler }) => {
|
|
try {
|
|
const value: unknown = await withTimeout(pluginId, handler(initial, ...rest), this.timeoutMs);
|
|
return { pluginId, value };
|
|
} catch (err) {
|
|
pluginErrorTracker.record(pluginId, err);
|
|
return null;
|
|
}
|
|
}));
|
|
return answers.filter((a): a is { pluginId: string; value: unknown } => a !== null);
|
|
}
|
|
|
|
/** Fire handlers as transforms - each receives the output of the previous */
|
|
async transform<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(pluginId, handler(value, ...rest), this.timeoutMs);
|
|
if (result !== undefined && result !== false) {
|
|
value = result as V;
|
|
}
|
|
} catch (err) {
|
|
pluginErrorTracker.record(pluginId, err);
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
}
|
|
|
|
// ─── Attachment Offload Result ───────────────────────────────
|
|
|
|
/**
|
|
* Structured value an `onBeforeBlobUpload` handler may return instead of a
|
|
* file id. It asks the composer to drop the staged binary attachment and
|
|
* append replacement content to the message body, so a plugin can offload the
|
|
* file somewhere else (an external file drop, object storage, a share link)
|
|
* and leave a link behind.
|
|
*
|
|
* Returning a string keeps the stock behaviour: it is treated as the id of the
|
|
* (possibly rewritten) file to upload.
|
|
*/
|
|
export interface ExternalAttachmentResult {
|
|
externalAttachment: true;
|
|
/**
|
|
* Inserted into the body when the composer is in rich-text mode. Sanitized
|
|
* by the composer before it goes anywhere near the document - a handler
|
|
* cannot inject script into the message being written.
|
|
*/
|
|
html: string;
|
|
/** Appended to the body when the composer is in plain-text mode. */
|
|
text: string;
|
|
/**
|
|
* Id of the staged file to discard, when the handler re-saved it through
|
|
* `upfiles.save` (which stores the new copy under a fresh id and deletes the
|
|
* old one). Defaults to the id the hook was called with. Without this the
|
|
* re-saved copy would be stranded in IndexedDB, since it is never uploaded.
|
|
*/
|
|
fileId?: string;
|
|
}
|
|
|
|
export function isExternalAttachmentResult(value: unknown): value is ExternalAttachmentResult {
|
|
if (typeof value !== 'object' || value === null) return false;
|
|
const candidate = value as Partial<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(),
|
|
// Lets plugins contribute results from elsewhere to a search, drawn above
|
|
// its message list (lib/plugin-search-results.ts). Each handler is asked
|
|
// on its own with an empty ExternalSearchResult[] and { query, filters },
|
|
// so a click can go back to the plugin that gave the entry.
|
|
onProvideSearchResults: new HookBus(),
|
|
// Observer fired (debounced) when the composer draft body, subject, or
|
|
// recipients change. Handler receives a DraftView snapshot. Use for AI
|
|
// assistants, grammar checkers, etc.
|
|
onDraftChange: new HookBus(),
|
|
// Intercept hook - fires at the very TOP of the composer send path, before
|
|
// the host builds and submits the message. Handler receives a ComposeSend
|
|
// request (draft fields, recipients, identityId, attachments, and the user's
|
|
// sign/encrypt intent) and may TAKE OVER sending entirely: build a raw MIME
|
|
// message, sign/encrypt it, and submit it via `api.jmap.sendRaw`. Returning
|
|
// false signals "I handled the send": the host SKIPS its default submission,
|
|
// deletes the draft and closes the composer. Returning { abort: true } means
|
|
// "nothing was sent" (signing failed, a passphrase prompt was cancelled): the
|
|
// host keeps the composer and the draft as they are. A handler that throws
|
|
// or times out counts as an abort, never as permission to send unsigned.
|
|
// Returning anything else (incl. undefined) lets the host send normally.
|
|
// This is the send-takeover hook used by the S/MIME plugin to replace the
|
|
// former native sign+encrypt+sendRaw pipeline. Fired with `takeOver`.
|
|
onComposeSend: new HookBus(COMPOSE_SEND_TIMEOUT_MS),
|
|
// Transform hook - receive Email[] or ScheduledEmail[] just after there are fetched to
|
|
// lets plugin edit emails before they are shown in row. Used to populate preview
|
|
// field for encryption plugins.
|
|
onEmailsFetched: new HookBus(),
|
|
// Transform hook - lets plugins edit the recipient chips in composer fields.
|
|
// They can add, remove, or modify chips (e.g. rewrite addresses, add colors/icons).
|
|
// Take Recipient[] as argument.
|
|
onRecipientChipsChange: new HookBus(),
|
|
// Transform hooks - lets plugins modify the email before host use it to populate fields.
|
|
onBeforeComposeOpenToForwardAsAttachment: new HookBus(),
|
|
onBeforeComposeOpenToReply: new HookBus(),
|
|
onBeforeComposeOpenToReplyAll: new HookBus(),
|
|
onBeforeComposeOpenToForward: new HookBus(),
|
|
};
|
|
|
|
// §7.2 Calendar Hooks
|
|
export const calendarHooks = {
|
|
onCalendarEventOpen: new HookBus(),
|
|
onBeforeEventCreate: new HookBus(),
|
|
onAfterEventCreate: new HookBus(),
|
|
onBeforeEventUpdate: new HookBus(),
|
|
onAfterEventUpdate: new HookBus(),
|
|
onBeforeEventDelete: new HookBus(),
|
|
onAfterEventDelete: new HookBus(),
|
|
onBeforeEventRsvp: new HookBus(),
|
|
onEventRsvp: new HookBus(),
|
|
onBeforeEventsImport: new HookBus(),
|
|
onEventsImport: new HookBus(),
|
|
onCalendarDateChange: new HookBus(),
|
|
onCalendarViewChange: new HookBus(),
|
|
onCalendarChange: new HookBus(),
|
|
onCalendarVisibilityToggle: new HookBus(),
|
|
onICalSubscriptionChange: new HookBus(),
|
|
onCalendarAlert: new HookBus(),
|
|
onCalendarAlertAcknowledge: new HookBus(),
|
|
// Transform hook - fires when the event form is open and start/end change.
|
|
// Initial value: ConflictWarning[], second argument: { event: CalendarEventFormView }.
|
|
// Plugins return an extended array; the form renders each warning inline.
|
|
onCheckEventConflicts: new HookBus(),
|
|
};
|
|
|
|
// §7.2b Calendar Form Hooks (UI integration)
|
|
|
|
/** What onCalendarEventFormSave handlers may set on the event being saved. */
|
|
export interface CalendarEventFormSavePatch {
|
|
virtualLocation?: string;
|
|
}
|
|
|
|
export const calendarFormHooks = {
|
|
onCalendarEventFormOpen: new HookBus(),
|
|
/**
|
|
* Fires while the event form is being saved. Handlers receive the patch
|
|
* accumulated so far and the form view (including the event uid and
|
|
* attendee emails), and may return a partial patch that the form merges
|
|
* into the saved event, so a meeting plugin can refresh its link in the
|
|
* same save that sends scheduling messages and attendees receive the
|
|
* current URL. Returning undefined leaves the patch unchanged.
|
|
*/
|
|
onCalendarEventFormSave: new HookBus<
|
|
(
|
|
patch: CalendarEventFormSavePatch,
|
|
view: CalendarEventFormView,
|
|
) =>
|
|
| Promise<CalendarEventFormSavePatch | undefined>
|
|
| CalendarEventFormSavePatch
|
|
| undefined
|
|
>(),
|
|
};
|
|
|
|
// §7.3 Contact Hooks
|
|
export const contactHooks = {
|
|
onContactOpen: new HookBus(),
|
|
onBeforeContactCreate: new HookBus(),
|
|
onAfterContactCreate: new HookBus(),
|
|
onBeforeContactUpdate: new HookBus(),
|
|
onAfterContactUpdate: new HookBus(),
|
|
onBeforeContactDelete: new HookBus(),
|
|
onAfterContactDelete: new HookBus(),
|
|
onContactsImport: new HookBus(),
|
|
onContactSelectionChange: new HookBus(),
|
|
onContactGroupChange: new HookBus(),
|
|
onContactGroupMemberChange: new HookBus(),
|
|
onContactMove: new HookBus(),
|
|
// Transform hook - lets plugins contribute extra recipient suggestions to
|
|
// the composer's autocomplete. Initial value: RecipientSuggestion[],
|
|
// second argument: { query: string }.
|
|
onProvideRecipientSuggestions: new HookBus(),
|
|
};
|
|
|
|
// §7.4 File Hooks
|
|
export const fileHooks = {
|
|
onFileNavigate: new HookBus(),
|
|
onBeforeFileUpload: new HookBus(),
|
|
onAfterFileUpload: new HookBus(),
|
|
onFileDownload: new HookBus(),
|
|
onFileUploadCancel: new HookBus(),
|
|
onDirectoryCreate: new HookBus(),
|
|
onBeforeFileDelete: new HookBus(),
|
|
onAfterFileDelete: new HookBus(),
|
|
// Intercept hook - fires before a file is renamed.
|
|
// Receives { file: FileResourceView, newName: string }.
|
|
// Return false to cancel the rename.
|
|
onBeforeFileRename: new HookBus(),
|
|
onFileRename: new HookBus(),
|
|
onBeforeFileMove: new HookBus(),
|
|
onFileMove: new HookBus(),
|
|
onFileCopy: new HookBus(),
|
|
onFileDuplicate: new HookBus(),
|
|
onFileFavoriteToggle: new HookBus(),
|
|
onFileSelectionChange: new HookBus(),
|
|
onFileUndo: new HookBus(),
|
|
};
|
|
|
|
// §7.5 Auth Hooks
|
|
export const authHooks = {
|
|
onLogin: new HookBus(),
|
|
onBeforeLogout: new HookBus(),
|
|
onAfterLogout: new HookBus(),
|
|
onAccountSwitch: new HookBus(),
|
|
onAccountAdd: new HookBus(),
|
|
onAccountRemove: new HookBus(),
|
|
onTokenRefresh: new HookBus(),
|
|
onAuthReady: new HookBus(),
|
|
/**
|
|
* Fired when a third-party OAuth provider redirects back to the generic
|
|
* plugin OAuth callback page (`/[locale]/plugins/oauth/callback`). The
|
|
* payload is `{ code, state, receivedAt }` exactly as the provider sent it.
|
|
* Each plugin validates `state` against the verifier it stashed before
|
|
* redirecting, so plugins that did not initiate a flow simply ignore the
|
|
* event. See `components/providers/plugin-oauth-callback-listener.tsx`.
|
|
*/
|
|
onOAuthCallback: new HookBus(),
|
|
};
|
|
|
|
// §7.6 Settings Hooks
|
|
export const settingsHooks = {
|
|
onBeforeSettingChange: new HookBus(),
|
|
onSettingChange: new HookBus(),
|
|
onSettingsExport: new HookBus(),
|
|
onSettingsImport: new HookBus(),
|
|
onSettingsReset: new HookBus(),
|
|
onSettingsSync: new HookBus(),
|
|
onKeywordChange: new HookBus(),
|
|
onTrustedSenderChange: new HookBus(),
|
|
};
|
|
|
|
// §7.7 Identity Hooks
|
|
export const identityHooks = {
|
|
onIdentitiesLoaded: new HookBus(),
|
|
onBeforeIdentityChange: new HookBus(),
|
|
onIdentityCreate: new HookBus(),
|
|
onIdentityUpdate: new HookBus(),
|
|
onIdentityDelete: new HookBus(),
|
|
onIdentitySelect: new HookBus(),
|
|
onSignatureRender: new HookBus(),
|
|
};
|
|
|
|
// §7.8 Filter Hooks
|
|
export const filterHooks = {
|
|
onFiltersLoaded: new HookBus(),
|
|
onFilterRuleChange: new HookBus(),
|
|
onBeforeFiltersSave: new HookBus(),
|
|
// Observer fired after the host successfully uploads the account's active
|
|
// Sieve script (visual-builder save or plugin-triggered regenerate).
|
|
onFiltersSave: new HookBus(),
|
|
onSieveScriptChange: new HookBus(),
|
|
/**
|
|
* Transform hook - runs on the full Sieve script text immediately before
|
|
* the host uploads it as the account's active script.
|
|
*
|
|
* handler(script: string, ctx: SieveScriptGenerateContext): string | undefined
|
|
*
|
|
* Return a modified script (e.g. append a plugin-managed categorizer
|
|
* section) or undefined to pass through. Handlers MUST keep the script
|
|
* valid — put extra `require` statements at the very top. Trigger a
|
|
* regeneration from a plugin via `api.sieve.regenerate()`.
|
|
*/
|
|
onSieveScriptGenerate: new HookBus(),
|
|
};
|
|
|
|
// §7.9 Task Hooks
|
|
export const taskHooks = {
|
|
onTasksLoaded: new HookBus(),
|
|
onBeforeTaskCreate: new HookBus(),
|
|
onTaskCreate: new HookBus(),
|
|
onBeforeTaskUpdate: new HookBus(),
|
|
onTaskUpdate: new HookBus(),
|
|
onBeforeTaskDelete: new HookBus(),
|
|
onTaskDelete: new HookBus(),
|
|
onTaskToggleComplete: new HookBus(),
|
|
onTaskFilterChange: new HookBus(),
|
|
};
|
|
|
|
// §7.10 Template Hooks
|
|
export const templateHooks = {
|
|
onTemplateCreate: new HookBus(),
|
|
onTemplateUpdate: new HookBus(),
|
|
onTemplateDelete: new HookBus(),
|
|
onTemplateApply: new HookBus(),
|
|
onTemplatesImport: new HookBus(),
|
|
onTemplateRender: new HookBus(),
|
|
};
|
|
|
|
// §7.12 Vacation Hooks
|
|
export const vacationHooks = {
|
|
onVacationLoaded: new HookBus(),
|
|
onBeforeVacationUpdate: new HookBus(),
|
|
onVacationUpdate: new HookBus(),
|
|
};
|
|
|
|
// §7.13 UI Hooks
|
|
export const uiHooks = {
|
|
onViewChange: new HookBus(),
|
|
onSidebarToggle: new HookBus(),
|
|
onSidebarCollapse: new HookBus(),
|
|
onDeviceTypeChange: new HookBus(),
|
|
onColumnResize: new HookBus(),
|
|
onMobileBack: new HookBus(),
|
|
// Intercept hook - fires when the user clicks an external link inside the
|
|
// app (typically inside an email body iframe). Handler receives
|
|
// ExternalLinkContext; return false to cancel the navigation. Mutate
|
|
// `href` in place to rewrite (e.g. strip UTM params, route via a proxy).
|
|
onBeforeExternalLink: new HookBus(),
|
|
// Observer (debounced) fired when the user changes the active text
|
|
// selection inside an app surface. Receives SelectionContext.
|
|
onTextSelectionChange: new HookBus(),
|
|
};
|
|
|
|
// §7.14 Theme Hooks
|
|
export const themeHooks = {
|
|
onThemeChange: new HookBus(),
|
|
onCustomThemeChange: new HookBus(),
|
|
onLocaleChange: new HookBus(),
|
|
/**
|
|
* Transform hook fired immediately before a theme's compiled CSS is
|
|
* injected into the document.
|
|
*
|
|
* handler(css: string, ctx: { themeId: string | null; variant: 'light' | 'dark' }): string | undefined
|
|
*
|
|
* Return a new CSS string to override what gets injected, or `undefined`
|
|
* to pass through unchanged. Use this to inject extra `@font-face` rules,
|
|
* patch a third-party theme's variables for accessibility, or implement
|
|
* site-wide design-token overrides.
|
|
*/
|
|
onThemeBeforeApply: new HookBus(),
|
|
};
|
|
|
|
// §7.15 Toast Hooks
|
|
export const toastHooks = {
|
|
onToastShow: new HookBus(),
|
|
onToastDismiss: new HookBus(),
|
|
// Observer fired when the user clicks an OS-level browser notification
|
|
// dispatched by the host. Handler receives { tag: string, data?: unknown }
|
|
// matching the original notification options.
|
|
onNotificationClick: new HookBus(),
|
|
};
|
|
|
|
// §7.16 Drag & Drop Hooks
|
|
export const dragDropHooks = {
|
|
onDragStart: new HookBus(),
|
|
onDragEnd: new HookBus(),
|
|
onEmailDrop: new HookBus(),
|
|
onTagDrop: new HookBus(),
|
|
};
|
|
|
|
// §7.17 Keyboard Hooks
|
|
export const keyboardHooks = {
|
|
onBeforeShortcut: new HookBus(),
|
|
onAfterShortcut: new HookBus(),
|
|
};
|
|
|
|
// §7.18 App Lifecycle Hooks
|
|
export const appLifecycleHooks = {
|
|
onAppReady: new HookBus(),
|
|
onVisibilityChange: new HookBus(),
|
|
onBeforeUnload: new HookBus(),
|
|
onAppError: new HookBus(),
|
|
onInterval: new HookBus(),
|
|
// Observer fired when the browser window receives focus / blur. Useful for
|
|
// refresh-on-focus behaviour (re-poll, recheck staleness, pause timers).
|
|
onWindowFocus: new HookBus(),
|
|
onWindowBlur: new HookBus(),
|
|
// Observer fired when network connectivity transitions. Mirrors the
|
|
// navigator online / offline events.
|
|
onOnline: new HookBus(),
|
|
onOffline: new HookBus(),
|
|
};
|
|
|
|
// §7.19 Account Security Hooks
|
|
export const accountSecurityHooks = {
|
|
onPasswordChange: new HookBus(),
|
|
onTotpChange: new HookBus(),
|
|
onAppPasswordChange: new HookBus(),
|
|
onEncryptionChange: new HookBus(),
|
|
onDisplayNameChange: new HookBus(),
|
|
};
|
|
|
|
// §7.20 Sidebar App Hooks
|
|
export const sidebarAppHooks = {
|
|
onSidebarAppOpen: new HookBus(),
|
|
onSidebarAppClose: new HookBus(),
|
|
};
|
|
|
|
// §7.21 Avatar Hooks
|
|
// Transform hook: handlers receive (currentUrl: string | null, context: { email: string; name?: string })
|
|
// and return a URL string to use as the avatar, or undefined/null to pass through to the next handler.
|
|
export const avatarHooks = {
|
|
onAvatarResolve: new HookBus(),
|
|
};
|
|
|
|
// §7.23 Router Hooks
|
|
// Observers fired by the app router. Handlers receive a RouteContext; on
|
|
// onNavigate the previous path is exposed via `from`.
|
|
export const routerHooks = {
|
|
onNavigate: new HookBus(),
|
|
onRouteEnter: new HookBus(),
|
|
onRouteLeave: new HookBus(),
|
|
};
|
|
|
|
// §7.22 Render Hooks
|
|
export const renderHooks = {
|
|
// Transform hook - runs for each visible email list row.
|
|
// Initial value: EmailListBadge[] (always starts as [])
|
|
// Second argument: { emailId: string; email: EmailReadView }
|
|
// Handlers return a new (or extended) badges array.
|
|
// Rendered by the email list row component next to the subject line.
|
|
onEmailListItemRender: new HookBus(),
|
|
// Transform hook - runs when an email is opened, BEFORE the viewer computes
|
|
// the body it will render. Initial value: RenderableBody { html, text,
|
|
// attachments, handledBy? }. Second argument: MessageContext { id,
|
|
// bodyStructure, attachments, blobId, contentType, from }. A handler may
|
|
// inspect the message (e.g. detect S/MIME), fetch the raw blob via
|
|
// `api.jmap.fetchBlob`, decrypt/verify in-frame, and return a REPLACED body
|
|
// with `handledBy` set plus optional `verification` status. Return undefined
|
|
// (or the unchanged value) to pass through. The host still runs the returned
|
|
// HTML through its sanitizer — plugin output is not trusted blindly. This is
|
|
// the render-takeover hook used by the S/MIME plugin to replace the former
|
|
// native detect/decrypt/verify path in the viewer.
|
|
//
|
|
// The host also enforces the user's external-content preference on the
|
|
// returned HTML: images, media, backgrounds and CSS url() pointing at remote
|
|
// hosts are neutralised (and the "external content blocked" banner shown)
|
|
// whenever the setting says block/ask, exactly as for a non-encrypted body.
|
|
// A handler cannot opt out of this — a decrypted newsletter must not leak a
|
|
// read receipt the user asked to suppress (#797) — so plugins do NOT need to
|
|
// reimplement the check themselves.
|
|
onRenderEmailBody: new HookBus(),
|
|
};
|
|
|
|
// §7.24 Message-List Tab Hooks (Gmail-style category tabs)
|
|
export const messageListTabHooks = {
|
|
// Observer - the merged tab set changed (a plugin registered or cleared
|
|
// its tabs). Receives the resolved MessageListTab[] (empty when cleared).
|
|
onTabsChange: new HookBus(),
|
|
// Observer - the user switched tabs. Receives TabActivateContext.
|
|
onTabActivate: new HookBus(),
|
|
// Intercept - fires before the host applies a category keyword patch to
|
|
// messages (api.tabs.categorize or native UI). Receives
|
|
// EmailCategorizeContext; return false to cancel the move.
|
|
onBeforeEmailCategorize: new HookBus(),
|
|
// Observer - fires after the keyword patch was applied. Receives the same
|
|
// EmailCategorizeContext. This is where a plugin persists per-sender
|
|
// overrides and calls api.sieve.regenerate() ("do this for all mail from X").
|
|
onEmailCategorize: new HookBus(),
|
|
// Observer - per-tab unread counts were refreshed. Receives
|
|
// Record<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();
|
|
}
|
|
}
|
|
}
|