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,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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user