diff --git a/packages/iios-service/package.json b/packages/iios-service/package.json index 1113aec..2d11fc5 100644 --- a/packages/iios-service/package.json +++ b/packages/iios-service/package.json @@ -28,6 +28,7 @@ "ioredis": "^5.11.1", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^4.1.0", + "nodemailer": "^9.0.3", "prisma": "^6.2.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", @@ -41,6 +42,7 @@ "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^26.0.1", + "@types/nodemailer": "^8.0.1", "@types/web-push": "^3.6.4", "socket.io-client": "^4.8.3", "typescript": "^5.7.3" diff --git a/packages/iios-service/src/capability/capability.registry.spec.ts b/packages/iios-service/src/capability/capability.registry.spec.ts new file mode 100644 index 0000000..7f5f1f8 --- /dev/null +++ b/packages/iios-service/src/capability/capability.registry.spec.ts @@ -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 = {}; +function set(env: Record) { + 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'); + }); +}); diff --git a/packages/iios-service/src/capability/capability.registry.ts b/packages/iios-service/src/capability/capability.registry.ts index c055fc0..fa5cd98 100644 --- a/packages/iios-service/src/capability/capability.registry.ts +++ b/packages/iios-service/src/capability/capability.registry.ts @@ -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_` 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 { diff --git a/packages/iios-service/src/capability/smtp.provider.spec.ts b/packages/iios-service/src/capability/smtp.provider.spec.ts new file mode 100644 index 0000000..eb99c47 --- /dev/null +++ b/packages/iios-service/src/capability/smtp.provider.spec.ts @@ -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 ' }; + +const req = (payload: Record): 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[0] }> = []; + const make = (id: SmtpIdentity): MailTransport => ({ + async sendMail(mail) { + calls.push({ id, mail }); + if (opts.throwErr) throw opts.throwErr; + return { messageId: opts.messageId ?? '' }; + }, + }); + 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 ' } as NodeJS.ProcessEnv); + expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO ' }); + }); + + 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: '' }); + const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '

x

', text: 'x' })); + expect(res).toMatchObject({ outcome: 'SENT', providerRef: '' }); + expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '

x

', 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: '' })); + expect(t.calls[0].mail).toMatchObject({ inReplyTo: '', references: '' }); + }); +}); + +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: '' }; + }, + }) }; + const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' })); + expect(res.outcome).toBe('SENT'); + expect(res.providerRef).toBe('fallback:'); + }); + + 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'); + }); +}); diff --git a/packages/iios-service/src/capability/smtp.provider.ts b/packages/iios-service/src/capability/smtp.provider.ts new file mode 100644 index 0000000..675a5c6 --- /dev/null +++ b/packages/iios-service/src/capability/smtp.provider.ts @@ -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 { + 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32beda2..bab7336 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,6 +376,9 @@ importers: jwks-rsa: specifier: ^4.1.0 version: 4.1.0 + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 prisma: specifier: ^6.2.1 version: 6.19.3(typescript@5.9.3) @@ -410,6 +413,9 @@ importers: '@types/node': specifier: ^26.0.1 version: 26.0.1 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/web-push': specifier: ^3.6.4 version: 3.6.4 @@ -1520,6 +1526,9 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -2545,6 +2554,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + notepack.io@3.0.1: resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} @@ -4195,6 +4208,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 26.0.1 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -5317,6 +5334,8 @@ snapshots: node-releases@2.0.50: {} + nodemailer@9.0.3: {} + notepack.io@3.0.1: {} nypm@0.6.8: