import type { DefinedJob } from "@wrnexus/queue"; import { createKeyring, open, seal, type EncryptionKeyring } from "@wrnexus/encryption"; export interface MailMessage { from?: string; to: string | readonly string[]; subject: string; html?: string; text?: string; headers?: Record; } export interface MailDriver { send(message: MailMessage): Promise; test?(): Promise<{ ok: boolean; error?: string }>; } export interface SealedMailDriverOptions { /** Ciphertext stored by the application; plaintext is never retained here. */ sealedCredential: string; unseal(value: string): Promise; create(credential: string): MailDriver | Promise; /** Cache the initialized driver. Defaults to true. */ cache?: boolean; } /** * Lazily opens a sealed credential only at the transport boundary. This keeps * encryption policy and SMTP/vendor choice independent while preventing every * app from rebuilding the same decrypt-on-send lifecycle. */ export function sealedMailDriver(options: SealedMailDriverOptions): MailDriver { let cached: Promise | undefined; const create = async () => options.create(await options.unseal(options.sealedCredential)); const driver = () => options.cache === false ? create() : (cached ??= create().catch((error) => { cached = undefined; throw error; })); return { async send(message) { return (await driver()).send(message); }, async test() { const resolved = await driver(); return resolved.test?.() ?? { ok: true }; }, }; } export interface MailTemplate> { subject(data: T): string; html?(data: T): string; text?(data: T): string; } export interface MailOptions { driver: MailDriver; from?: string; sandbox?: { enabled?: boolean; allowlist?: readonly string[] }; } export interface MailSandboxDecision { allowed: boolean; reason?: string; } /** Fail-closed, case-insensitive sandbox policy shared by mail workers and defineMail. */ export function mailSandboxDecision( sandbox: { enabled?: boolean | number; allowlist?: readonly string[] | string }, recipient: unknown, ): MailSandboxDecision { if (!sandbox.enabled) return { allowed: true }; if (typeof recipient !== "string" || !recipient.trim()) { return { allowed: false, reason: "sandbox mode is on and no valid recipient address was given", }; } let source: unknown = sandbox.allowlist ?? []; if (typeof source === "string") { try { source = JSON.parse(source || "[]"); } catch { source = []; } } const allowed = new Set( (Array.isArray(source) ? source : []) .filter((value): value is string => typeof value === "string") .map((value) => value.trim().toLowerCase()) .filter(Boolean), ); const normalized = recipient.trim().toLowerCase(); return allowed.has(normalized) ? { allowed: true } : { allowed: false, reason: `sandbox mode is on and ${recipient} is not in the sandbox allow-list`, }; } export interface SealedMailCredentialsOptions { /** Environment variable holding a base64 AES key. */ env?: string; keyId?: string; } /** Owns validated, lazy encryption-key setup for credentials stored at rest. */ export function defineSealedMailCredentials(options: SealedMailCredentialsOptions = {}) { const env = options.env ?? "APP_ENCRYPTION_KEY"; let cached: EncryptionKeyring | undefined; const keyring = () => { if (cached) return cached; const secret = process.env[env]; if (!secret) throw new Error(`${env} is not set; it is required to seal mail credentials at rest.`); let length: number; try { length = atob(secret).length; } catch { throw new Error(`${env} must be a valid base64-encoded 16, 24 or 32 byte key.`); } if (![16, 24, 32].includes(length)) { throw new Error( `${env} must decode to 16, 24 or 32 bytes; the current value decodes to ${length}.`, ); } return (cached = createKeyring([{ id: options.keyId ?? "primary", secret, active: true }])); }; return { keyring, seal: (plain: string) => seal(plain, keyring()), open: (sealed: string) => open(sealed, keyring()), }; } export function defineMail(options: MailOptions) { const assertAllowed = (recipients: readonly string[]) => { for (const recipient of recipients) { const decision = mailSandboxDecision(options.sandbox ?? {}, recipient); if (!decision.allowed) throw new Error(`WRN-MAIL-SANDBOX: ${decision.reason}`); } }; return { async send(message: MailMessage) { const recipients = typeof message.to === "string" ? [message.to] : [...message.to]; assertAllowed(recipients); return options.driver.send({ ...message, from: message.from ?? options.from }); }, async render(template: MailTemplate, data: T, to: string | readonly string[]) { return { from: options.from, to, subject: template.subject(data), html: template.html?.(data), text: template.text?.(data), } satisfies MailMessage; }, test: () => options.driver.test?.() ?? Promise.resolve({ ok: true }), }; } export function defineMailTemplate(template: MailTemplate): MailTemplate { return template; } export function interpolateTemplate(source: string, data: Record): string { return source.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (_match, path: string) => { let value: unknown = data; for (const part of path.split(".")) value = value && typeof value === "object" ? (value as Record)[part] : undefined; return value == null ? "" : String(value); }); } export function storedMailTemplate(template: { subject: string; html?: string; text?: string; }): MailTemplate> { return { subject: (data) => interpolateTemplate(template.subject, data), html: template.html === undefined ? undefined : (data) => interpolateTemplate(template.html!, data), text: template.text === undefined ? undefined : (data) => interpolateTemplate(template.text!, data), }; } /** Queue a fully rendered message without coupling the mail package to one queue backend. */ export function queuedMail(job: Pick, "add">) { return { send: (message: T) => job.add(message) }; }