Feat/s3 storage #2

Closed
maaz519 wants to merge 30 commits from feat/s3-storage into main
6 changed files with 297 additions and 0 deletions
Showing only changes of commit 74cca2d534 - Show all commits
+2
View File
@@ -28,6 +28,7 @@
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0", "jwks-rsa": "^4.1.0",
"nodemailer": "^9.0.3",
"prisma": "^6.2.1", "prisma": "^6.2.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
@@ -41,6 +42,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.0.1", "@types/node": "^26.0.1",
"@types/nodemailer": "^8.0.1",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"typescript": "^5.7.3" "typescript": "^5.7.3"
@@ -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 { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider'; import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider'; import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv } from './smtp.provider';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; 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 * default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown * overrides the sandbox for that channel (the "flip the binding" swap). Unknown
* channels fail closed — no silent egress path. * 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() @Injectable()
export class CapabilityProviderRegistry { export class CapabilityProviderRegistry {
@@ -24,6 +28,9 @@ export class CapabilityProviderRegistry {
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one. // 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)); 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 { 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;
}
+19
View File
@@ -376,6 +376,9 @@ importers:
jwks-rsa: jwks-rsa:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
nodemailer:
specifier: ^9.0.3
version: 9.0.3
prisma: prisma:
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.19.3(typescript@5.9.3) version: 6.19.3(typescript@5.9.3)
@@ -410,6 +413,9 @@ importers:
'@types/node': '@types/node':
specifier: ^26.0.1 specifier: ^26.0.1
version: 26.0.1 version: 26.0.1
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/web-push': '@types/web-push':
specifier: ^3.6.4 specifier: ^3.6.4
version: 3.6.4 version: 3.6.4
@@ -1520,6 +1526,9 @@ packages:
'@types/node@26.0.1': '@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/qs@6.15.1': '@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
@@ -2545,6 +2554,10 @@ packages:
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
nodemailer@9.0.3:
resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
engines: {node: '>=6.0.0'}
notepack.io@3.0.1: notepack.io@3.0.1:
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
@@ -4195,6 +4208,10 @@ snapshots:
dependencies: dependencies:
undici-types: 8.3.0 undici-types: 8.3.0
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 26.0.1
'@types/qs@6.15.1': {} '@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {} '@types/range-parser@1.2.7': {}
@@ -5317,6 +5334,8 @@ snapshots:
node-releases@2.0.50: {} node-releases@2.0.50: {}
nodemailer@9.0.3: {}
notepack.io@3.0.1: {} notepack.io@3.0.1: {}
nypm@0.6.8: nypm@0.6.8: