feat(smtp): SMTP egress provider (nodemailer) for the EMAIL channel

Makes external email actually leave the building (was sandbox-only). SmtpProvider
implements CapabilityProvider; env-activated (accounts@ primary, ceo@ fallback);
transporter injected for tests.

- send() maps target+payload -> {from,to,subject,html,text,inReplyTo,references};
  providerRef = nodemailer's real Message-ID (so replies thread via In-Reply-To).
- Fallback ONLY on pre-acceptance failures (connect/auth/timeout) — a post-acceptance
  error is terminal, so a message the server already took can't be double-delivered.
- Never throws — transport failure -> FAILED, per the adapter doctrine.
- Registry precedence via registration order: SMTP > HTTP relay > sandbox for EMAIL.

Verified: 13 unit tests (config/envelope/fallback/precedence) + a REAL SMTP round-trip
against nodemailer Ethereal (SENT, genuine Message-ID). Full suite 281/281, boundary+build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:31:04 +05:30
parent 7d7c75915a
commit 74cca2d534
6 changed files with 297 additions and 0 deletions
@@ -0,0 +1,145 @@
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;
}
/** 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;
}): 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;
}
/**
* 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,
) {
this.makeTransport = makeTransport ?? defaultTransport;
}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload;
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 } : {}),
});
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 };
}
}
}
/** 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;
}