d28c873d40
Email can now carry attachments (e.g. an invoice PDF). The kernel already stored
attachments as message parts + the media StoragePort holds the bytes; the gap was
the email envelope.
- EMAIL payload carries attachment REFS ({filename, contentRef, mimeType}), not
bytes — the ledger + T8 PII redaction stay small; bytes are fetched at send time.
- SmtpProvider takes an AttachmentResolver; resolves each ref via storage and attaches
(nodemailer). FAILS CLOSED if a declared attachment can't be resolved (or no resolver
is wired) — never send a receipt/invoice missing its file; a FAILED command retries.
- CapabilityProviderRegistry injects the resolver from STORAGE_PORT (@Optional);
MediaModule exports STORAGE_PORT, CapabilityModule imports MediaModule. No cycle.
- TemplatedSender + MailService + the /v1/mail/send DTO pass attachments through.
Verified: 6 new unit tests (attach, fail-closed x2, plain-unaffected, resolver,
pass-through) + a REAL Ethereal SMTP send WITH a PDF attachment (SENT). Full suite
294/294, boundary + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
7.0 KiB
TypeScript
136 lines
7.0 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();
|
|
});
|
|
});
|