/** * Channel runtime — templates, consent, fallback chains, receipts. * * Email path uses sently {@link Transport} (MIME / attachments / retry / * error hierarchy via sently). Fallback chains record every attempt. */ import { FallbackTransport, type FallbackAttempt, type Transport } from "sently"; import { RetryTransport } from "sently/transports/retry"; import type { ChannelAttempt, ChannelDriver, ChannelMessage, ChannelOtpSendOptions, ChannelOtpSendResult, ChannelOtpVerifyOptions, ChannelOtpVerifyResult, ChannelSendResult, SmsOtpTransport, } from "../../drivers/channel-types.ts"; import type { DriverExternal } from "../../drivers/external.ts"; import type { ChannelMedium } from "../../manifest/types.ts"; import { CHANNEL_SCHEMA } from "../../kernel/errors-channel.ts"; import { OkeError } from "../../kernel/errors.ts"; import { emitBootWarn } from "../../runtime/boot-warn.ts"; import { isStandardSchema, type SchemaInput } from "../../validation/standard-schema.ts"; import type { ConsentStore } from "./consent.ts"; import { catalogFromTemplates, mergeTemplateCatalogs, type ChannelTemplateDecl, type TemplateCatalog, } from "./declare.ts"; import { DEFAULT_MEDIUM_COSTS, type MediumCosts } from "./costs.ts"; import { resolveLocale, type LocaleChainStep } from "./locale.ts"; import type { DeliveryOutcomeState } from "./outcomes.ts"; import { createReceiptLedger, type DeliveryReceipt, type DeliveryStatus, type IngestOutcomeInput, type ReceiptLedger, } from "./receipts.ts"; import { createSuppressionStore, type SuppressionStore } from "./suppression.ts"; import { deliverOtpAcrossChannels, shouldFallbackOtpMedium, type DeliverOtpOptions, type DeliverOtpResult, } from "./otp-delivery.ts"; export type { TemplateCatalog } from "./declare.ts"; /** * Resolve driver-reported egress for a provider label in a fallback chain. * * @param drivers - Ordered driver chain * @param provider - Provider / driver id that handled (or failed) the attempt * @param index - Optional transport index when known */ function externalForChannelProvider( drivers: readonly ChannelDriver[], provider: string, index?: number, ): DriverExternal | undefined { if (typeof index === "number") { const at = drivers[index]?.external; if (at) return at; } const match = drivers.find( (d) => d.id === provider || d.external?.provider === provider || d.transport?.provider === provider || d.smsTransport?.provider === provider || d.channel?.provider === provider, ); return match?.external; } /** Options for {@link createChannelRuntime}. */ export interface CreateChannelRuntimeOptions { /** Declared templates. */ readonly templates?: readonly ChannelTemplateDecl[]; /** * Ordered drivers for fallback. First success wins; every attempt is recorded. */ readonly drivers?: readonly ChannelDriver[]; /** i18n body catalog. */ readonly catalog?: TemplateCatalog; /** Default locale. */ readonly defaultLocale?: string; /** Suppression store (created if omitted). */ readonly suppression?: SuppressionStore; /** * Consent store — wrapped into suppression when `suppression` is omitted. * Prefer {@link CreateChannelRuntimeOptions.suppression}. */ readonly consent?: ConsentStore; /** Receipt ledger (created if omitted). */ readonly receipts?: ReceiptLedger; /** Medium unit costs (USD / message). */ readonly costs?: MediumCosts; /** Wrap email transports with sently RetryTransport. */ readonly retry?: boolean; /** Injectable clock. */ readonly now?: () => number; /** * Reload durable consent / suppression / receipts. * Boot calls this from the scheduler when a SQL ledger is bound. */ readonly refresh?: () => Promise; } /** Send options for {@link ChannelRuntime.send}. */ export interface ChannelSendOptions { readonly to: string; readonly data?: Readonly>; readonly locale?: string; readonly profileLocale?: string; readonly acceptLanguage?: string; readonly via?: readonly string[]; readonly subject?: string; readonly pushSubscription?: ChannelMessage["pushSubscription"]; } /** Channel runtime surface. */ export interface ChannelRuntime { readonly templates: ReadonlyMap; readonly suppression: SuppressionStore; readonly receipts: ReceiptLedger; readonly costs: MediumCosts; /** Bound driver chain (email + SMS + …). */ readonly drivers: readonly ChannelDriver[]; /** * Reload a shared consent / suppression / receipt ledger. * Memory stores leave this unset. */ refresh?(): Promise; /** * Send a template through the driver chain. * * @param template - Template name * @param options - Recipient / data / locale / via */ send(template: string, options: ChannelSendOptions): Promise; /** * Send a provider-managed OTP via the bound Taqnyat SMS driver. * * Vendor extra (Taqnyat Verify API) — only valid when the configured SMS * driver supports it. Throws loudly when no SMS driver is bound or the * bound driver does not support provider-managed OTP. * * @param options - Recipient + requestId (+ lang / note / from) */ sendOtp(options: ChannelOtpSendOptions): Promise; /** * Verify a provider-managed OTP code via the bound Taqnyat SMS driver. * * @param options - Recipient + requestId + code (+ lang / note / from) */ verifyOtp(options: ChannelOtpVerifyOptions): Promise; /** * Tier-2 OTP delivery across declared channels (sently FallbackTransport). * Pass `only` for explicit single-channel resend (no cross-medium failover). * * @param options - Channels, templates, addresses, OTP data */ deliverOtp(options: DeliverOtpOptions): Promise; /** * Ingest a post-send provider outcome (bounce / complaint / …). * Hard bounce auto-adds suppression. Console projects the ledger — never * raw webhooks. When the ledger can read SQL, a cache miss loads that * receipt before a new one is recorded. * * @param input - Normalized outcome */ ingestOutcome(input: IngestOutcomeInput): Promise; } /** * Create a Channel runtime. * * @param options - Templates + drivers + catalog */ let channelProcessLocalWarned = false; /** Test helper — reset the one-shot Channel process-local warn. */ export function resetChannelProcessLocalWarnForTests(): void { channelProcessLocalWarned = false; } /** * SQL read for a receipt the cache does not have yet. * * @param ledger - Receipt ledger, possibly backed by Postgres */ function lookupReceipt( ledger: ReceiptLedger, ): ((messageId: string) => Promise) | undefined { if (!("lookup" in ledger)) return undefined; const lookup = (ledger as { lookup?: unknown }).lookup; if (typeof lookup !== "function") return undefined; return (messageId: string) => Promise.resolve( ( lookup as ( this: ReceiptLedger, messageId: string, ) => DeliveryReceipt | undefined | Promise ).call(ledger, messageId), ).then((value) => value ?? undefined); } export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}): ChannelRuntime { const templates = new Map(); for (const t of options.templates ?? []) { templates.set(t.name, t); } const usingDefaultSuppression = options.suppression === undefined; const usingDefaultReceipts = options.receipts === undefined; const suppression = options.suppression ?? createSuppressionStore(options.consent ? { consent: options.consent } : {}); const receipts = options.receipts ?? createReceiptLedger(); if ((usingDefaultSuppression || usingDefaultReceipts) && !channelProcessLocalWarned) { channelProcessLocalWarned = true; emitBootWarn( "oke boot: Channel suppression/consent/receipts default to process-local memory — " + "opt-out, bounce, and receipt state on one instance is invisible to others. " + "Inject shared stores for multi-instance, or run a single Channel consumer, until a " + "durable driver ships.", ); } const costs = options.costs ?? { ...DEFAULT_MEDIUM_COSTS }; const catalog = mergeTemplateCatalogs(catalogFromTemplates(options.templates), options.catalog) ?? {}; const defaultLocale = options.defaultLocale ?? "en"; const now = options.now ?? (() => Date.now()); /** * One wire conversation per transport instance — sently transports are not * safe for concurrent `send()` on the shared socket (interleaved SMTP DATA * → "Unexpected SMTP response for DATA end"). Every driver handed to this * runtime gets a serializing wrapper so concurrent flows queue instead of * corrupting the connection. */ function serializeTransport(t: ChannelDriver["transport"] & object): Transport { let tail: Promise = Promise.resolve(); return { ...(t.provider !== undefined ? { provider: t.provider } : {}), send(mail: Parameters[0]): ReturnType { const run = tail.then( () => t.send(mail), () => t.send(mail), ); tail = run.catch(() => undefined); return run as ReturnType; }, } as Transport; } const drivers = [...(options.drivers ?? [])].map((d) => d.transport ? { ...d, transport: serializeTransport(d.transport) } : d, ); function resolveBody( template: string, locale: string, data: Readonly>, ): { subject?: string; text?: string; html?: string } { const byLocale = catalog[template]; const entry = byLocale?.[locale] ?? byLocale?.[defaultLocale] ?? byLocale?.en; if (!entry) { return { subject: template, text: JSON.stringify(data), }; } const interpolate = (s: string | undefined): string | undefined => { if (s === undefined) return undefined; return s.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(data[key] ?? "")); }; return { subject: interpolate(entry.subject), text: interpolate(entry.text), html: interpolate(entry.html), }; } function driversFor(medium: ChannelMedium, via?: readonly string[]): ChannelDriver[] { let list = drivers; if (via && via.length > 0) { list = via .map((id) => drivers.find((d) => d.id === id || d.transport?.provider === id)) .filter((d): d is ChannelDriver => d !== undefined); } return list.filter((d) => { if (medium === "email" || medium === "any") { return ( !!d.transport || d.channel?.mediums.includes(medium) || d.channel?.mediums.includes("any") ); } return d.channel?.mediums.includes(medium) || d.channel?.mediums.includes("any"); }); } async function sendViaEmailChain( chain: ChannelDriver[], mail: Parameters[0], ): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] }> { const attempts: ChannelAttempt[] = []; const transports: Transport[] = []; for (const d of chain) { if (!d.transport) continue; const t = options.retry ? new RetryTransport(d.transport) : d.transport; transports.push(t); } if (transports.length === 0) { throw new Error("channel: no email transport in driver chain"); } const recorded: FallbackAttempt[] = []; const fallback = new FallbackTransport(transports, { // Same permanent-vs-provider distinction as OTP cross-medium failover. shouldFallback: shouldFallbackOtpMedium, onFallback(failedIndex, error) { const provider = transports[failedIndex]?.provider ?? chain[failedIndex]?.id ?? `driver-${failedIndex}`; recorded.push({ provider, error }); const external = externalForChannelProvider(chain, provider, failedIndex); attempts.push({ driverId: provider, ok: false, error: error instanceof Error ? error.message : String(error), at: now(), ...(external !== undefined ? { external } : {}), }); }, }); try { const mailResult = await fallback.send(mail); const providerIndex = mailResult.providerIndex ?? 0; const driverId = mailResult.provider ?? transports[providerIndex]?.provider ?? chain[0]?.id ?? "email"; const external = externalForChannelProvider(chain, driverId, providerIndex); attempts.push({ driverId, ok: true, at: now(), messageId: mailResult.messageId, ...(external !== undefined ? { external } : {}), }); return { result: { ok: true, messageId: mailResult.messageId, driverId, attempts, mail: mailResult, ...(external !== undefined ? { external } : {}), }, attempts, }; } catch (err) { const fbAttempts = err && typeof err === "object" && "attempts" in err && Array.isArray((err as { attempts: FallbackAttempt[] }).attempts) ? (err as { attempts: FallbackAttempt[] }).attempts : recorded; for (const a of fbAttempts) { if (!attempts.some((x) => x.driverId === a.provider && !x.ok)) { const external = externalForChannelProvider(chain, a.provider); attempts.push({ driverId: a.provider, ok: false, error: a.error instanceof Error ? a.error.message : String(a.error), at: now(), ...(external !== undefined ? { external } : {}), }); } } // Permanent client errors abort failover by rethrowing the original error // (no FallbackError.attempts / onFallback) — still record the failed try. if (attempts.length === 0) { const driverId = transports[0]?.provider ?? chain[0]?.id ?? "email"; const external = externalForChannelProvider(chain, driverId, 0); attempts.push({ driverId, ok: false, error: err instanceof Error ? err.message : String(err), at: now(), ...(external !== undefined ? { external } : {}), }); } const lastExternal = attempts[attempts.length - 1]?.external; return { result: { ok: false, messageId: crypto.randomUUID(), driverId: "fallback", attempts, ...(lastExternal !== undefined ? { external: lastExternal } : {}), }, attempts, }; } } async function sendViaSmsFallback( chain: ChannelDriver[], message: ChannelMessage, ): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] } | undefined> { const sms = chain .map((d) => (d.smsTransport ? { driver: d, transport: d.smsTransport } : undefined)) .filter( ( x, ): x is { driver: ChannelDriver; transport: NonNullable } => !!x, ); if (sms.length === 0) return undefined; const attempts: ChannelAttempt[] = []; const transports = sms.map(({ transport }) => options.retry ? new RetryTransport(transport) : transport, ); const fallback = new FallbackTransport(transports, { shouldFallback: shouldFallbackOtpMedium, onFallback(failedIndex, error) { const provider = transports[failedIndex]?.provider ?? sms[failedIndex]?.driver.id ?? `sms-${failedIndex}`; const external = externalForChannelProvider( sms.map((s) => s.driver), provider, failedIndex, ); attempts.push({ driverId: provider, ok: false, error: error instanceof Error ? error.message : String(error), at: now(), ...(external !== undefined ? { external } : {}), }); }, }); const body = { to: message.to, body: message.text ?? String(message.data?.code ?? ""), ...(message.from ? { from: message.from } : {}), }; try { const sendResult = await fallback.send(body); const providerIndex = sendResult.providerIndex ?? 0; const driverId = sendResult.provider ?? transports[providerIndex]?.provider ?? sms[0]?.driver.id ?? "sms"; const smsDrivers = sms.map((s) => s.driver); const external = externalForChannelProvider(smsDrivers, driverId, providerIndex); attempts.push({ driverId, ok: true, at: now(), messageId: sendResult.messageId, ...(external !== undefined ? { external } : {}), }); return { result: { ok: true, messageId: sendResult.messageId, driverId, attempts, ...(external !== undefined ? { external } : {}), }, attempts, }; } catch (err) { const smsDrivers = sms.map((s) => s.driver); const fbAttempts = err && typeof err === "object" && "attempts" in err && Array.isArray((err as { attempts: FallbackAttempt[] }).attempts) ? (err as { attempts: FallbackAttempt[] }).attempts : []; for (const a of fbAttempts) { if (!attempts.some((x) => x.driverId === a.provider && !x.ok)) { const external = externalForChannelProvider(smsDrivers, a.provider); attempts.push({ driverId: a.provider, ok: false, error: a.error instanceof Error ? a.error.message : String(a.error), at: now(), ...(external !== undefined ? { external } : {}), }); } } if (attempts.length === 0) { const driverId = transports[0]?.provider ?? sms[0]?.driver.id ?? "sms"; const external = externalForChannelProvider(smsDrivers, driverId, 0); attempts.push({ driverId, ok: false, error: err instanceof Error ? err.message : String(err), at: now(), ...(external !== undefined ? { external } : {}), }); } const lastExternal = attempts[attempts.length - 1]?.external; return { result: { ok: false, messageId: crypto.randomUUID(), driverId: "fallback", attempts, ...(lastExternal !== undefined ? { external: lastExternal } : {}), }, attempts, }; } } async function sendViaChannelChain( chain: ChannelDriver[], message: ChannelMessage, ): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] }> { if (message.medium === "sms") { const viaSms = await sendViaSmsFallback(chain, message); if (viaSms) return viaSms; } const attempts: ChannelAttempt[] = []; for (const d of chain) { if (!d.channel) continue; try { const r = await d.channel.send(message); const stampedAttempts = r.attempts.map((a) => ({ ...a, ...(a.external === undefined && d.external !== undefined ? { external: d.external } : {}), })); attempts.push(...stampedAttempts); if (r.ok) { const external = r.external ?? d.external; return { result: { ...r, attempts: [...attempts], ...(external !== undefined ? { external } : {}), }, attempts, }; } } catch (err) { attempts.push({ driverId: d.id, ok: false, error: err instanceof Error ? err.message : String(err), at: now(), ...(d.external !== undefined ? { external: d.external } : {}), }); } } return { result: { ok: false, messageId: crypto.randomUUID(), driverId: "fallback", attempts, }, attempts, }; } function classifySendFailure(attempts: readonly ChannelAttempt[]): DeliveryOutcomeState { const err = attempts .map((a) => a.error ?? "") .join(" ") .toLowerCase(); if ( /invalid.*(address|email|recipient)|unknown user|no such user|mailbox unavailable|550 5\.1\.1/.test( err, ) ) { return "blocked/invalid-address"; } return "provider-error"; } function otpSmsDriver(): { driver: ChannelDriver; otp: SmsOtpTransport } { const sms = drivers.filter((d) => d.smsTransport); if (sms.length === 0) { throw new Error( "channel: no SMS driver bound — bind drivers.channel.sms (e.g. taqnyat) to send provider OTP", ); } for (const d of sms) { const t = d.smsTransport as Partial | undefined; if (t && typeof t.sendOtp === "function" && typeof t.verifyOtp === "function") { return { driver: d, otp: t as SmsOtpTransport }; } } const id = sms[0]?.id ?? "unknown"; throw new Error( `channel: SMS driver "${id}" does not support provider-managed OTP; use exposeDevOtp locally or set drivers.channel.sms to "taqnyat"`, ); } async function validateChannelTemplateData( template: string, decl: ChannelTemplateDecl, data: unknown, ): Promise { const schema = decl.schema as SchemaInput | undefined; if (schema === undefined || schema === null || !isStandardSchema(schema)) { return; } const result = await schema["~standard"].validate(data ?? {}); if (!result.issues) return; const detail = result.issues .map((i) => { const path = Array.isArray(i.path) ? i.path .map((p) => (typeof p === "object" && p && "key" in p ? String(p.key) : String(p))) .join(".") : ""; return path.length > 0 ? `${path}: ${i.message}` : i.message; }) .join("; ") || "invalid payload"; throw new OkeError(CHANNEL_SCHEMA, { resource: template, detail }); } async function sendTemplate( template: string, opts: ChannelSendOptions, ): Promise { const decl = templates.get(template); if (!decl) { throw new Error(`channel: unknown template "${template}"`); } await validateChannelTemplateData(template, decl, opts.data); const medium = decl.medium; const checkMedium: ChannelMedium = medium === "any" ? "email" : medium; const suppressed = suppression.isSuppressed(opts.to, checkMedium); if (suppressed.suppressed) { const status: DeliveryStatus = suppressed.reason === "opted-out" ? "suppressed/opted-out" : "suppressed/prior-bounce"; const resolved = resolveLocale({ locale: opts.locale, profileLocale: opts.profileLocale, acceptLanguage: opts.acceptLanguage, defaultLocale, }); const receipt: DeliveryReceipt = { id: crypto.randomUUID(), template, to: opts.to, medium, locale: resolved.locale, localeChain: resolved.chain, status, attempts: [], at: now(), error: suppressed.reason === "opted-out" ? "opted out" : "prior hard bounce", }; receipts.record(receipt); await persistReceipts(); return { ok: false, messageId: receipt.id, driverId: "suppression", attempts: [], }; } const resolved = resolveLocale({ locale: opts.locale, profileLocale: opts.profileLocale, acceptLanguage: opts.acceptLanguage, defaultLocale, }); const locale = resolved.locale; const localeChain: readonly LocaleChainStep[] = resolved.chain; const body = resolveBody(template, locale, opts.data ?? {}); const chain = driversFor(medium, opts.via); let result: ChannelSendResult; let attempts: ChannelAttempt[]; if (medium === "email" || (medium === "any" && chain.some((d) => d.transport))) { // Default sender must pass RFC/sently validation — `oke@localhost` // (dotless) was rejected by every real SMTP transport after retries. const from = decl.from ?? "oke@localhost.test"; const sendResult = await sendViaEmailChain(chain, { from, to: opts.to, subject: opts.subject ?? body.subject ?? template, text: body.text, html: body.html, template, data: opts.data ? { ...opts.data } : undefined, }); result = sendResult.result; attempts = sendResult.attempts; } else { const sendResult = await sendViaChannelChain(chain, { medium: medium === "any" ? "sms" : medium, to: opts.to, from: decl.from, subject: opts.subject ?? body.subject, text: body.text, html: body.html, data: opts.data, locale, template, pushSubscription: opts.pushSubscription, }); result = sendResult.result; attempts = sendResult.attempts; } let status: DeliveryStatus; if (result.ok) { status = attempts.filter((a) => !a.ok).length > 0 ? "fallback" : "sent"; } else { status = classifySendFailure(attempts); } receipts.record({ id: crypto.randomUUID(), template, to: opts.to, medium, locale, localeChain, status, messageId: result.messageId, driverId: result.driverId, attempts, at: now(), ...(result.ok ? {} : { error: attempts .map((a) => a.error) .filter(Boolean) .join("; "), }), }); await persistReceipts(); return { ...result, attempts }; } async function persistReceipts(): Promise { const pending = receipts as ReceiptLedger & { flush?: () => Promise }; if (pending.flush) await pending.flush(); } return { templates, suppression, receipts, costs, drivers, ...(options.refresh ? { refresh: options.refresh } : {}), async sendOtp(opts) { const { otp } = otpSmsDriver(); return otp.sendOtp(opts); }, async verifyOtp(opts) { const { otp } = otpSmsDriver(); return otp.verifyOtp(opts); }, async deliverOtp(opts) { return deliverOtpAcrossChannels(drivers, sendTemplate, opts, now); }, async ingestOutcome(input) { const at = input.at ?? now(); let existing = receipts.byMessageId(input.messageId); if (!existing) { const lookup = lookupReceipt(receipts); if (lookup) existing = await lookup(input.messageId); } if (input.state === "hard-bounce") { const subject = input.to ?? existing?.to; const medium = (input.medium ?? existing?.medium ?? "email") as ChannelMedium; if (subject) { suppression.addPriorBounce(subject, medium === "any" ? "email" : medium); } } if (existing) { const updated = await receipts.updateStatus(input.messageId, { status: input.state, at, error: input.error, }); await persistReceipts(); return updated ?? existing; } const receipt: DeliveryReceipt = { id: crypto.randomUUID(), template: input.template ?? "unknown", to: input.to ?? "unknown", medium: input.medium ?? "email", status: input.state, messageId: input.messageId, attempts: [], at, ...(input.error !== undefined ? { error: input.error } : {}), }; receipts.record(receipt); await persistReceipts(); return receipt; }, send: sendTemplate, }; }