import { randomUUID } from 'node:crypto'; import { createTransport } from 'nodemailer'; import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts'; /** One SMTP sending identity (a mailbox + how to reach its server). */ export interface SmtpIdentity { host: string; port: number; secure: boolean; user: string; pass: string; from: string; } export interface MailAttachment { filename: string; content: Buffer; contentType?: string; } /** Fetch an attachment's bytes by its opaque contentRef (backed by the media StoragePort). */ export type AttachmentResolver = (contentRef: string) => Promise<{ filename?: string; content: Buffer; contentType?: string } | null>; /** Build an AttachmentResolver over the media StoragePort (contentRef = the storage object key). */ export function storageResolver(storage: { get(key: string): Promise<{ data: Buffer; mime: string } | null> }): AttachmentResolver { return async (contentRef) => { const o = await storage.get(contentRef); return o ? { content: o.data, contentType: o.mime } : null; }; } /** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */ export interface MailTransport { sendMail(mail: { from: string; to: string; subject?: string; text?: string; html?: string; inReplyTo?: string; references?: string; attachments?: MailAttachment[]; }): Promise<{ messageId: string; accepted?: unknown[] }>; } const bool = (v: string | undefined): boolean => v === 'true' || v === '1'; /** Build the primary SMTP identity from env, or null if the required trio is incomplete. */ export function smtpIdentityFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null { return identityFrom(env, ''); } /** Build the optional fallback identity (accounts@ → ceo@), or null if not configured. */ export function smtpFallbackFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null { const fb = identityFrom(env, 'FALLBACK_'); if (fb) return fb; // Fallback may reuse the primary host/port and only override the mailbox identity. const host = env.IIOS_SMTP_HOST; const user = env.IIOS_SMTP_FALLBACK_USER; const pass = env.IIOS_SMTP_FALLBACK_PASS; if (!host || !user || !pass) return null; return { host, port: Number(env.IIOS_SMTP_PORT ?? 587), secure: bool(env.IIOS_SMTP_SECURE), user, pass, from: env.IIOS_SMTP_FALLBACK_FROM ?? user, }; } function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | null { const host = env[`IIOS_SMTP_${prefix}HOST`] ?? (prefix ? undefined : env.IIOS_SMTP_HOST); const user = env[`IIOS_SMTP_${prefix}USER`]; const pass = env[`IIOS_SMTP_${prefix}PASS`]; if (!host || !user || !pass) return null; return { host, port: Number(env[`IIOS_SMTP_${prefix}PORT`] ?? env.IIOS_SMTP_PORT ?? 587), secure: bool(env[`IIOS_SMTP_${prefix}SECURE`] ?? env.IIOS_SMTP_SECURE), user, pass, from: env[`IIOS_SMTP_${prefix}FROM`] ?? user, }; } interface EmailPayload { subject?: string; text?: string; html?: string; inReplyTo?: string; /** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */ attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>; } /** * SMTP egress provider (nodemailer). Bound for EMAIL when the SMTP env trio is set (else the sandbox * stays). A transport failure surfaces as FAILED, never thrown. On a PRE-acceptance failure it retries * once via the fallback identity (accounts@ → ceo@); a post-acceptance failure is NOT retried, so a * message the server already accepted can't be double-delivered. */ export class SmtpProvider implements CapabilityProvider { readonly name = 'smtp'; readonly channelTypes = ['EMAIL']; readonly capabilities = { canSend: true }; private readonly makeTransport: (id: SmtpIdentity) => MailTransport; constructor( private readonly primary: SmtpIdentity, private readonly fallback?: SmtpIdentity, makeTransport?: (id: SmtpIdentity) => MailTransport, private readonly resolveAttachment?: AttachmentResolver, ) { this.makeTransport = makeTransport ?? defaultTransport; } async send(req: CapabilityRequest): Promise { const started = Date.now(); const p = (req.payload ?? {}) as EmailPayload; // Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing // its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch. let attachments: MailAttachment[] | undefined; if (p.attachments && p.attachments.length > 0) { if (!this.resolveAttachment) return this.failed('NO_ATTACHMENT_RESOLVER', started); const out: MailAttachment[] = []; for (const a of p.attachments) { const r = await this.resolveAttachment(a.contentRef).catch(() => null); if (!r) return this.failed('ATTACHMENT_UNRESOLVED', started); out.push({ filename: a.filename ?? r.filename ?? 'attachment', content: r.content, ...(a.mimeType ?? r.contentType ? { contentType: a.mimeType ?? r.contentType } : {}) }); } attachments = out; } const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> => this.makeTransport(id).sendMail({ from: id.from, to: req.target, subject: p.subject ?? '(no subject)', text: p.text, html: p.html, ...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}), ...(attachments ? { attachments } : {}), }); try { const info = await attempt(this.primary); return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started }; } catch (err) { // Retry via the fallback identity ONLY if the primary never got the message accepted. if (this.fallback && isPreAcceptanceFailure(err)) { try { const info = await attempt(this.fallback); return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started }; } catch (err2) { return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started }; } } return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started }; } } private failed(errorCode: string, started: number): ProviderResult { return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode, latencyMs: Date.now() - started }; } } /** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */ function isPreAcceptanceFailure(err: unknown): boolean { const e = err as { code?: string; responseCode?: number }; const preCodes = ['ECONNECTION', 'ETIMEDOUT', 'ECONNREFUSED', 'EDNS', 'EAUTH', 'ESOCKET', 'EENVELOPE']; if (e.code && preCodes.includes(e.code)) return true; // A responseCode present means the server spoke — treat 5xx after acceptance as terminal (no retry). return e.responseCode == null && e.code == null; } function codeOf(err: unknown): string { const e = err as { code?: string; message?: string }; return (e.code ?? e.message ?? 'SMTP_ERROR').slice(0, 60); } function defaultTransport(id: SmtpIdentity): MailTransport { return createTransport({ host: id.host, port: id.port, secure: id.secure, auth: { user: id.user, pass: id.pass } }) as unknown as MailTransport; }