23c43acf8f
SmtpProvider is now scope-aware: it resolves the tenant's own SMTP identity from the IiosProviderCredential store (providerType SMTP) and sends from it, falling back to the platform env identity when unset (env fallback applies only to the platform identity, never a tenant's server). Registered whenever env SMTP OR the credential store is present; fails closed as NOT_CONFIGURED when neither exists. Credential endpoints accept SMTP with per-type validation. 18 SMTP tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
9.1 KiB
TypeScript
175 lines
9.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, 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');
|
|
});
|
|
});
|
|
|
|
describe('SmtpProvider.send — attachments', () => {
|
|
const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
|
|
async (ref: string) => map[ref] ?? null;
|
|
|
|
it('resolves attachment refs to bytes and attaches them', async () => {
|
|
const t = stub();
|
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } }))
|
|
.send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] }));
|
|
expect(res.outcome).toBe('SENT');
|
|
expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]);
|
|
});
|
|
|
|
it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => {
|
|
const t = stub();
|
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({}))
|
|
.send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] }));
|
|
expect(res.outcome).toBe('FAILED');
|
|
expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED');
|
|
expect(t.calls).toHaveLength(0); // nothing sent
|
|
});
|
|
|
|
it('FAILS when attachments are requested but no resolver is wired', async () => {
|
|
const t = stub();
|
|
const res = await new SmtpProvider(ID, undefined, t.make) // no resolver
|
|
.send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] }));
|
|
expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' });
|
|
});
|
|
|
|
it('a plain send with no attachments is unaffected', async () => {
|
|
const t = stub();
|
|
const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' }));
|
|
expect(res.outcome).toBe('SENT');
|
|
expect(t.calls[0].mail.attachments).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
|
|
it('maps storage bytes into an attachment; missing ref → null', async () => {
|
|
const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) };
|
|
const r = storageResolver(storage);
|
|
expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' });
|
|
expect(await r('nope')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('BYO SMTP (scope-aware)', () => {
|
|
const TENANT: SmtpIdentity = { host: 'smtp.acme.com', port: 465, secure: true, user: 'apikey', pass: 't', from: 'Acme <no-reply@acme.com>' };
|
|
const scoped = (payload: Record<string, unknown>, scopeId?: string): CapabilityRequest => ({
|
|
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1', ...(scopeId ? { scopeId } : {}),
|
|
});
|
|
|
|
it('sends from the tenant identity when a scope has its own SMTP', async () => {
|
|
const s = stub();
|
|
const p = new SmtpProvider(ID, FB, s.make, undefined, async (sid) => (sid === 'scope_1' ? TENANT : null));
|
|
const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_1'));
|
|
expect(res.outcome).toBe('SENT');
|
|
expect(s.calls[0]!.id).toEqual(TENANT);
|
|
expect(s.calls[0]!.mail.from).toBe('Acme <no-reply@acme.com>');
|
|
});
|
|
|
|
it('falls back to the platform identity when the scope has no SMTP', async () => {
|
|
const s = stub();
|
|
const p = new SmtpProvider(ID, FB, s.make, undefined, async () => null);
|
|
await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_2'));
|
|
expect(s.calls[0]!.id).toEqual(ID);
|
|
});
|
|
|
|
it('fails closed (NOT_CONFIGURED) when there is neither a tenant nor a platform identity', async () => {
|
|
const s = stub();
|
|
const p = new SmtpProvider(undefined, undefined, s.make, undefined, async () => null);
|
|
const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_3'));
|
|
expect(res.outcome).toBe('FAILED');
|
|
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
|
expect(s.calls).toHaveLength(0);
|
|
});
|
|
|
|
it('smtpIdentityFromConfig maps a stored config (fromName + email → from)', async () => {
|
|
const { smtpIdentityFromConfig } = await import('./smtp.provider');
|
|
expect(smtpIdentityFromConfig({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', fromEmail: 'a@b.com', fromName: 'Acme' }))
|
|
.toEqual({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', from: 'Acme <a@b.com>' });
|
|
expect(smtpIdentityFromConfig({ host: 'h', user: 'u', pass: 'p' })).toBeNull();
|
|
});
|
|
});
|