/** * Email Pipeline * * Orchestrates the three-stage email pipeline: * 1. email:beforeSend hooks (middleware — transform, validate, cancel) * 2. email:deliver hook (exclusive — exactly one provider delivers) * 3. email:afterSend hooks (logging, analytics, fire-and-forget) * * Security features: * - Recursion guard prevents re-entrant sends (e.g. plugin calling ctx.email.send from a hook) * - System emails (source="system") bypass email:beforeSend and email:afterSend hooks entirely * to protect auth tokens from exfiltration by plugin hooks * */ import { AsyncLocalStorage } from "node:async_hooks"; import type { HookPipeline } from "./hooks.js"; import type { EmailDeliverEvent, EmailMessage } from "./types.js"; /** Hook name for the exclusive email delivery hook */ const EMAIL_DELIVER_HOOK = "email:deliver"; /** Source value used for auth emails (magic links, invites, password resets) */ const SYSTEM_SOURCE = "system"; /** * Error thrown when ctx.email.send() is called but no provider is configured. */ export class EmailNotConfiguredError extends Error { constructor() { super( "No email provider is configured. Install and activate an email provider plugin, " + "then select it in Settings > Email.", ); this.name = "EmailNotConfiguredError"; } } /** * Error thrown when a recursive email send is detected. */ export class EmailRecursionError extends Error { constructor() { super( "Recursive email send detected. A plugin hook attempted to send an email " + "from within the email pipeline, which would cause infinite recursion.", ); this.name = "EmailRecursionError"; } } /** * Recursion guard using AsyncLocalStorage. * * EmailPipeline is a singleton (worker-lifetime cached via EmDashRuntime). * Instance state like `sendDepth` would false-positive under concurrent * requests because two unrelated sends would increment the same counter. * ALS scopes the guard to the current async execution context, so concurrent * requests each get their own independent recursion tracking. */ const emailSendALS = new AsyncLocalStorage<{ depth: number }>(); /** * EmailPipeline orchestrates email delivery through the plugin hook system. * * The pipeline runs in three stages: * 1. email:beforeSend — middleware hooks that can transform or cancel messages * 2. email:deliver — exclusive hook dispatching to the selected provider * 3. email:afterSend — fire-and-forget hooks for logging/analytics */ export class EmailPipeline { private pipeline: HookPipeline; constructor(pipeline: HookPipeline) { this.pipeline = pipeline; } /** * Replace the underlying hook pipeline. * * Called by the runtime when rebuilding the hook pipeline after a * plugin is enabled or disabled, so the email pipeline dispatches * to the current set of active hooks. */ setPipeline(pipeline: HookPipeline): void { this.pipeline = pipeline; } /** * Send an email through the full pipeline. * * @param message - The email to send * @param source - Where the email originated ("system" for auth, plugin ID for plugins) * @throws EmailNotConfiguredError if no provider is selected * @throws EmailRecursionError if called re-entrantly from within a hook * @throws Error if the provider handler throws */ async send(message: EmailMessage, source: string): Promise { // Recursion guard: a plugin with email:send + email:intercept calling // ctx.email.send() from an email hook would loop forever. // Uses AsyncLocalStorage so concurrent requests don't interfere — // each async context tracks its own depth independently. const store = emailSendALS.getStore(); if (store && store.depth > 0) { throw new EmailRecursionError(); } const run = () => this.sendInner(message, source); if (store) { // Already inside an ALS context (e.g. nested call) — increment depth store.depth++; try { await run(); } finally { store.depth--; } } else { // First call — create new ALS context await emailSendALS.run({ depth: 1 }, run); } } /** * Inner send implementation, separated from the recursion guard. */ private async sendInner(message: EmailMessage, source: string): Promise { // Validate message fields at the pipeline boundary. TypeScript enforces // this at compile time, but sandboxed plugins cross an RPC boundary // where runtime types aren't guaranteed. if (!message || typeof message !== "object") { throw new Error("Invalid email message: message must be an object"); } if (!message.to || typeof message.to !== "string") { throw new Error("Invalid email message: 'to' is required and must be a string"); } if (!message.subject || typeof message.subject !== "string") { throw new Error("Invalid email message: 'subject' is required and must be a string"); } if (!message.text || typeof message.text !== "string") { throw new Error("Invalid email message: 'text' is required and must be a string"); } const isSystemEmail = source === SYSTEM_SOURCE; // System emails (auth tokens, magic links, invites) skip the // email:beforeSend pipeline entirely. These contain sensitive tokens // that must never be exposed to plugin hooks — a malicious interceptor // could rewrite the body/URL to steal auth tokens even if the `to` // field is protected. let finalMessage: EmailMessage; if (isSystemEmail) { finalMessage = message; } else { // Stage 1: email:beforeSend middleware (can transform or cancel) const beforeResult = await this.pipeline.runEmailBeforeSend(message, source); if (beforeResult.message === false) { // Cancelled by middleware — find which plugin cancelled for audit log const cancellingResult = beforeResult.results.find((r) => r.value === false); const cancelledBy = cancellingResult?.pluginId ?? "unknown"; console.info(`[email] Email to "${message.to}" cancelled by plugin "${cancelledBy}"`); return; } finalMessage = beforeResult.message; } // Stage 2: email:deliver (exclusive hook) const deliverEvent: EmailDeliverEvent = { message: finalMessage, source }; const deliverResult = await this.pipeline.invokeExclusiveHook(EMAIL_DELIVER_HOOK, deliverEvent); if (!deliverResult) { throw new EmailNotConfiguredError(); } if (deliverResult.error) { throw deliverResult.error; } // Stage 3: email:afterSend (fire-and-forget) // System emails skip afterSend for the same reason they skip beforeSend: // the message contains plaintext auth tokens that must not be exposed to // plugin hooks. A logging/analytics hook could exfiltrate magic link URLs. // Errors are logged internally by the pipeline, not propagated. if (!isSystemEmail) { this.pipeline .runEmailAfterSend(finalMessage, source) .catch((err) => console.error( "[email] afterSend pipeline error:", err instanceof Error ? err.message : err, ), ); } } /** * Check if an email provider is configured and available. * * Returns true if an email:deliver provider is selected in the exclusive * hook system. Plugins and auth code use this to decide whether to show * "send invite" vs "copy invite link" UI. */ isAvailable(): boolean { return this.pipeline.getExclusiveSelection(EMAIL_DELIVER_HOOK) !== undefined; } }