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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user