UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

428 lines (391 loc) 9.8 kB
/** * @beignet/core/mail * * Shared mail port and test adapters for Beignet applications. */ import { createProvider, createProviderInstrumentation, } from "../providers/index.js"; /** * Value or promise of that value. */ export type MaybePromise<T> = T | Promise<T>; /** * Email address accepted by Beignet mail helpers. */ export type MailAddress = | string | { /** * Email address. */ email: string; /** * Optional display name. */ name?: string; }; /** * Single address or address list. */ export type MailAddressList = MailAddress | readonly MailAddress[]; /** * Common mail message fields shared by all send options. */ export interface MailBaseMessage { /** * Required recipients. */ to: MailAddressList; /** * Message subject. */ subject: string; /** * Sender address. Providers or adapters may supply a default. */ from?: MailAddress; /** * Carbon-copy recipients. */ cc?: MailAddressList; /** * Blind-carbon-copy recipients. */ bcc?: MailAddressList; /** * Reply-to recipients. */ replyTo?: MailAddressList; /** * Provider-specific message headers. */ headers?: Record<string, string>; } /** * Mail send options. * * A message must include at least one body format: `text` or `html`. */ export type SendMailOptions = MailBaseMessage & ( | { text: string; html?: string; } | { html: string; text?: string; } ); /** * Normalized mail message with address fields converted to arrays. */ export interface NormalizedMailMessage extends MailBaseMessage { /** * Normalized recipients. */ to: readonly MailAddress[]; /** * Sender address after applying any default. */ from?: MailAddress; /** * Normalized carbon-copy recipients. */ cc?: readonly MailAddress[]; /** * Normalized blind-carbon-copy recipients. */ bcc?: readonly MailAddress[]; /** * Normalized reply-to recipients. */ replyTo?: readonly MailAddress[]; /** * Plain text body. */ text?: string; /** * HTML body. */ html?: string; } /** * Result returned by a mail provider. */ export interface SendMailResult { /** * Provider message ID when available. */ id?: string; /** * Provider name. */ provider?: string; } /** * App-facing mailer port. */ export interface MailerPort { /** * Send one mail message. */ send(message: SendMailOptions): Promise<SendMailResult>; } /** * Delivery captured by the memory mailer. */ export interface MemoryMailDelivery { /** * Generated delivery ID. */ id: string; /** * Normalized message that would have been sent. */ message: NormalizedMailMessage; /** * Timestamp assigned by the memory mailer. */ sentAt: Date; } /** * In-memory mailer port for tests and local examples. */ export interface MemoryMailerPort extends MailerPort { /** * Captured deliveries. */ readonly deliveries: readonly MemoryMailDelivery[]; /** * Clear captured deliveries. */ clear(): void; } /** * Options for `createMemoryMailer(...)`. */ export interface CreateMemoryMailerOptions { /** * Sender used when a message does not specify `from`. */ defaultFrom?: MailAddress; /** * Clock used for captured deliveries. */ now?: () => Date; /** * ID factory used for captured deliveries. */ id?: () => string; /** * Observer called after a delivery is captured. */ onSend?: (delivery: MemoryMailDelivery) => MaybePromise<void>; } /** * Error thrown by mail helpers and provider adapters. */ export class MailDeliveryError extends Error { /** * Provider name when known. */ readonly provider?: string; /** * Original provider error when available. */ readonly cause?: unknown; constructor(args: { provider?: string; message: string; cause?: unknown }) { super(args.message); this.name = "MailDeliveryError"; this.provider = args.provider; this.cause = args.cause; } } /** * Normalize a single address or address list into an array. * * This helper does not validate email syntax. */ export function normalizeMailAddressList( addresses: MailAddressList | undefined, ): readonly MailAddress[] | undefined { if (addresses === undefined) return undefined; return Array.isArray(addresses) ? [...addresses] : [addresses as MailAddress]; } function normalizeOptionalMailAddressList( addresses: MailAddressList | undefined, ): readonly MailAddress[] | undefined { const normalized = normalizeMailAddressList(addresses); return normalized && normalized.length > 0 ? normalized : undefined; } /** * Format one address for providers that accept RFC-like address strings. * * Rejects carriage returns and line feeds so address values cannot inject * additional mail header lines. This helper does not validate email syntax. */ export function formatMailAddress(address: MailAddress): string { if (typeof address === "string") { assertMailAddressLineSafe(address); return address; } assertMailAddressLineSafe(address.email); if (!address.name) return address.email; assertMailAddressLineSafe(address.name); const escapedName = address.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return `"${escapedName}" <${address.email}>`; } function assertMailAddressLineSafe(value: string): void { if (/[\r\n]/.test(value)) { throw new MailDeliveryError({ message: "Mail addresses must not include carriage returns or line feeds.", }); } } /** * Format one or more addresses for providers that accept string address fields. */ export function formatMailAddressList( addresses: MailAddressList, ): string | string[] { const normalized = normalizeMailAddressList(addresses) ?? []; const formatted = normalized.map(formatMailAddress); return formatted.length === 1 ? formatted[0] : formatted; } /** * Normalize a mail message and apply a default sender. * * Throws when the message has no recipients. */ export function normalizeMailMessage( message: SendMailOptions, options: { defaultFrom?: MailAddress } = {}, ): NormalizedMailMessage { const to = normalizeMailAddressList(message.to) ?? []; if (to.length === 0) { throw new MailDeliveryError({ message: "Cannot send email without at least one recipient.", }); } return { ...message, to, from: message.from ?? options.defaultFrom, cc: normalizeOptionalMailAddressList(message.cc), bcc: normalizeOptionalMailAddressList(message.bcc), replyTo: normalizeOptionalMailAddressList(message.replyTo), text: "text" in message ? message.text : undefined, html: "html" in message ? message.html : undefined, }; } /** * Create an in-memory mailer for tests, local development, and examples. * * The memory mailer does not send real email or validate address syntax. */ export function createMemoryMailer( options: CreateMemoryMailerOptions = {}, ): MemoryMailerPort { return createMemoryMailerInternal(options); } function createMemoryMailerInternal( options: CreateMemoryMailerOptions, onDelivery?: (delivery: MemoryMailDelivery, durationMs: number) => void, ): MemoryMailerPort { const deliveries: MemoryMailDelivery[] = []; const now = options.now ?? (() => new Date()); const id = options.id ?? (() => crypto.randomUUID()); return { get deliveries() { return deliveries; }, async send(message) { const startedAt = Date.now(); const delivery: MemoryMailDelivery = { id: id(), message: normalizeMailMessage(message, { defaultFrom: options.defaultFrom, }), sentAt: now(), }; deliveries.push(delivery); onDelivery?.(delivery, Date.now() - startedAt); await options.onSend?.(delivery); return { id: delivery.id, provider: "memory", }; }, clear() { deliveries.length = 0; }, }; } /** * Options for the memory mailer provider. */ export interface MemoryMailerProviderOptions extends CreateMemoryMailerOptions { /** * Provider name. Defaults to "memory-mailer". */ name?: string; } /** * Ports contributed by the memory mailer provider. */ export interface MemoryMailerProviderPorts { /** * Beignet mailer port. */ mailer: MailerPort; } /** * Create a provider that contributes an in-memory mailer. * * Use it as the dev-default `mailer` port in `server/providers.ts` until a * real mail provider such as Resend or SMTP takes over. Deliveries are * captured in memory and recorded as `mail.sent` devtools events through the * `mail` watcher when an instrumentation port is installed. */ export function createMemoryMailerProvider( options: MemoryMailerProviderOptions = {}, ) { const { name = "memory-mailer", onSend, ...mailerOptions } = options; return createProvider({ name, setup({ ports }) { const instrumentation = createProviderInstrumentation(ports, { providerName: name, watcher: "mail", }); const mailer: MailerPort = createMemoryMailerInternal( { ...mailerOptions, onSend, }, (delivery, durationMs) => { instrumentation.custom({ name: "mail.sent", label: "Mail sent", summary: delivery.message.subject, details: { to: delivery.message.to, subject: delivery.message.subject, id: delivery.id, durationMs, }, }); }, ); return { ports: { mailer, } satisfies MemoryMailerProviderPorts, }; }, }); }