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,34 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { CapabilityProviderRegistry } from './capability.registry';
|
||||
|
||||
// The registry reads env in its constructor, so each case sets env → constructs a fresh registry →
|
||||
// asserts → restores env.
|
||||
const SMTP_KEYS = ['IIOS_SMTP_HOST', 'IIOS_SMTP_USER', 'IIOS_SMTP_PASS', 'IIOS_PROVIDER_URL_EMAIL'];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
function set(env: Record<string, string | undefined>) {
|
||||
for (const k of SMTP_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||
for (const [k, v] of Object.entries(env)) if (v != null) process.env[k] = v;
|
||||
}
|
||||
afterEach(() => { for (const k of SMTP_KEYS) { if (saved[k] == null) delete process.env[k]; else process.env[k] = saved[k]; } });
|
||||
|
||||
describe('CapabilityProviderRegistry — EMAIL precedence (SMTP > HTTP > sandbox)', () => {
|
||||
it('binds SMTP for EMAIL when the SMTP env trio is set', () => {
|
||||
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' });
|
||||
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
|
||||
});
|
||||
|
||||
it('binds the HTTP EmailProvider when only IIOS_PROVIDER_URL_EMAIL is set', () => {
|
||||
set({ IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
|
||||
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('email-http');
|
||||
});
|
||||
|
||||
it('SMTP wins over the HTTP relay when both are set', () => {
|
||||
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p', IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
|
||||
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
|
||||
});
|
||||
|
||||
it('falls back to the sandbox when neither is configured', () => {
|
||||
set({});
|
||||
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('sandbox');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { CapabilityProvider } from '@insignia/iios-contracts';
|
||||
import { SandboxProvider } from './sandbox.provider';
|
||||
import { HttpProvider } from './http.provider';
|
||||
import { EmailProvider } from './email.provider';
|
||||
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv } from './smtp.provider';
|
||||
|
||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||
|
||||
@@ -11,6 +12,9 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
|
||||
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
|
||||
* channels fail closed — no silent egress path.
|
||||
*
|
||||
* Precedence is registration ORDER (register() does Map.set → last wins). For EMAIL:
|
||||
* sandbox → HTTP EmailProvider (if URL set) → SMTP (if SMTP env set), so SMTP > HTTP > sandbox.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CapabilityProviderRegistry {
|
||||
@@ -24,6 +28,9 @@ export class CapabilityProviderRegistry {
|
||||
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
|
||||
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
||||
}
|
||||
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last).
|
||||
const smtp = smtpIdentityFromEnv();
|
||||
if (smtp) this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined));
|
||||
}
|
||||
|
||||
register(provider: CapabilityProvider): void {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, type MailTransport, type SmtpIdentity } from './smtp.provider';
|
||||
import type { CapabilityRequest } from '@insignia/iios-contracts';
|
||||
|
||||
const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
|
||||
const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin <ceo@lynkeduppro.com>' };
|
||||
|
||||
const req = (payload: Record<string, unknown>): CapabilityRequest => ({
|
||||
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1',
|
||||
});
|
||||
|
||||
/** A recording transport; optionally throws a given error on send. */
|
||||
function stub(opts: { throwErr?: unknown; messageId?: string } = {}) {
|
||||
const calls: Array<{ id: SmtpIdentity; mail: Parameters<MailTransport['sendMail']>[0] }> = [];
|
||||
const make = (id: SmtpIdentity): MailTransport => ({
|
||||
async sendMail(mail) {
|
||||
calls.push({ id, mail });
|
||||
if (opts.throwErr) throw opts.throwErr;
|
||||
return { messageId: opts.messageId ?? '<generated@smtp.test>' };
|
||||
},
|
||||
});
|
||||
return { make, calls };
|
||||
}
|
||||
|
||||
describe('smtpIdentityFromEnv', () => {
|
||||
const base = { IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' };
|
||||
|
||||
it('builds the identity from a complete env trio (defaults port 587, secure false)', () => {
|
||||
const id = smtpIdentityFromEnv({ ...base } as NodeJS.ProcessEnv);
|
||||
expect(id).toMatchObject({ host: 'smtp.test', port: 587, secure: false, user: 'accounts@x', from: 'accounts@x' });
|
||||
});
|
||||
|
||||
it('returns null when the trio is incomplete', () => {
|
||||
expect(smtpIdentityFromEnv({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'a@x' } as NodeJS.ProcessEnv)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the fallback identity, reusing the primary host', () => {
|
||||
const fb = smtpFallbackFromEnv({ ...base, IIOS_SMTP_FALLBACK_USER: 'ceo@x', IIOS_SMTP_FALLBACK_PASS: 'q', IIOS_SMTP_FALLBACK_FROM: 'CEO <ceo@x>' } as NodeJS.ProcessEnv);
|
||||
expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO <ceo@x>' });
|
||||
});
|
||||
|
||||
it('returns null fallback when not configured', () => {
|
||||
expect(smtpFallbackFromEnv({ ...base } as NodeJS.ProcessEnv)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SmtpProvider.send — envelope', () => {
|
||||
it('maps target + payload into the mail and returns the messageId as providerRef', async () => {
|
||||
const t = stub({ messageId: '<abc@smtp.test>' });
|
||||
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '<p>x</p>', text: 'x' }));
|
||||
expect(res).toMatchObject({ outcome: 'SENT', providerRef: '<abc@smtp.test>' });
|
||||
expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '<p>x</p>', text: 'x' });
|
||||
});
|
||||
|
||||
it('sets In-Reply-To + References headers for a reply', async () => {
|
||||
const t = stub();
|
||||
await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 're', inReplyTo: '<parent@smtp.test>' }));
|
||||
expect(t.calls[0].mail).toMatchObject({ inReplyTo: '<parent@smtp.test>', references: '<parent@smtp.test>' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SmtpProvider.send — failure + fallback', () => {
|
||||
it('retries via the fallback identity on a pre-acceptance failure (SENT via fallback)', async () => {
|
||||
// primary throws ECONNREFUSED (server never accepted) → fallback used.
|
||||
const primaryThrows = { make: (id: SmtpIdentity): MailTransport => ({
|
||||
async sendMail(mail) {
|
||||
if (id.user === ID.user) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
|
||||
return { messageId: '<viaFallback@smtp.test>' };
|
||||
},
|
||||
}) };
|
||||
const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' }));
|
||||
expect(res.outcome).toBe('SENT');
|
||||
expect(res.providerRef).toBe('fallback:<viaFallback@smtp.test>');
|
||||
});
|
||||
|
||||
it('does NOT retry a post-acceptance failure (avoids double delivery) → FAILED', async () => {
|
||||
// responseCode present = the server already spoke; retrying could double-send.
|
||||
const t = stub({ throwErr: Object.assign(new Error('rejected after data'), { responseCode: 550 }) });
|
||||
const res = await new SmtpProvider(ID, FB, t.make).send(req({ subject: 'x' }));
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(t.calls).toHaveLength(1); // primary only — no fallback attempt
|
||||
});
|
||||
|
||||
it('with no fallback, a failure is FAILED and never throws', async () => {
|
||||
const t = stub({ throwErr: Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) });
|
||||
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'x' }));
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('ETIMEDOUT');
|
||||
});
|
||||
});
|
||||
@@ -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