From 23c43acf8f94fc92d56443fa8852a1b15f93a34a Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 23 Jul 2026 13:37:50 +0530 Subject: [PATCH] =?UTF-8?q?feat(capability):=20BYO=20SMTP=20=E2=80=94=20pe?= =?UTF-8?q?r-tenant=20email=20server=20via=20the=20credential=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/capability/capability.registry.ts | 12 ++++-- .../provider-credential.controller.ts | 38 ++++++++++++++---- .../src/capability/provider-credential.dto.ts | 11 ----- .../capability/provider-credential.service.ts | 3 ++ .../src/capability/smtp.provider.spec.ts | 39 ++++++++++++++++++ .../src/capability/smtp.provider.ts | 40 +++++++++++++++++-- 6 files changed, 116 insertions(+), 27 deletions(-) delete mode 100644 packages/iios-service/src/capability/provider-credential.dto.ts diff --git a/packages/iios-service/src/capability/capability.registry.ts b/packages/iios-service/src/capability/capability.registry.ts index 76b32a0..dd896b3 100644 --- a/packages/iios-service/src/capability/capability.registry.ts +++ b/packages/iios-service/src/capability/capability.registry.ts @@ -3,7 +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, storageResolver } from './smtp.provider'; +import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromConfig, smtpIdentityFromEnv, storageResolver } from './smtp.provider'; import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider'; import { credKeyFromEnv } from './secret-crypto'; import { ProviderCredentialService } from './provider-credential.service'; @@ -36,11 +36,15 @@ export class CapabilityProviderRegistry { this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url)); } // Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments - // resolve through the media StoragePort when one is bound (else attachments FAIL closed). + // resolve through the media StoragePort when one is bound (else attachments FAIL closed). A + // tenant's own SMTP (BYO) is resolved per scope at send time and takes precedence over the env + // identity; register the provider whenever EITHER the env SMTP or the credential store exists. const smtp = smtpIdentityFromEnv(); - if (smtp) { + const smtpCreds = credKeyFromEnv() && this.credentials ? this.credentials : undefined; + if (smtp || smtpCreds) { const resolver = this.storage ? storageResolver(this.storage) : undefined; - this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver)); + const credResolver = smtpCreds ? (scopeId: string) => smtpCreds.resolve(scopeId, 'SMTP').then(smtpIdentityFromConfig) : undefined; + this.register(new SmtpProvider(smtp ?? undefined, smtpFallbackFromEnv() ?? undefined, undefined, resolver, credResolver)); } // BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered // only when the platform key + credential store are present — else SMS stays on the sandbox. diff --git a/packages/iios-service/src/capability/provider-credential.controller.ts b/packages/iios-service/src/capability/provider-credential.controller.ts index 8b69fb1..4dd5fa3 100644 --- a/packages/iios-service/src/capability/provider-credential.controller.ts +++ b/packages/iios-service/src/capability/provider-credential.controller.ts @@ -2,9 +2,34 @@ import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from import { SessionVerifier } from '../platform/session.verifier'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { ProviderCredentialService } from './provider-credential.service'; -import { TwilioCredentialsDto } from './provider-credential.dto'; -const SUPPORTED = new Set(['TWILIO_SMS']); +const SUPPORTED = new Set(['TWILIO_SMS', 'SMTP']); + +const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : ''); +const isEmail = (v: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v); + +/** Validate + shape the credential config for a provider type. Throws 400 on bad input. + * be-crm does the strict client-facing validation; this is IIOS's own guard. */ +function buildConfig(providerType: string, body: Record): Record { + if (providerType === 'TWILIO_SMS') { + const accountSid = str(body.accountSid); + const authToken = str(body.authToken); + const fromNumber = str(body.fromNumber); + if (!accountSid || !authToken || !fromNumber) throw new BadRequestException('accountSid, authToken and fromNumber are required'); + return { accountSid, authToken, fromNumber }; + } + if (providerType === 'SMTP') { + const host = str(body.host); + const user = str(body.user); + const pass = str(body.pass); + const fromEmail = str(body.fromEmail); + const fromName = str(body.fromName); + if (!host || !user || !pass || !fromEmail) throw new BadRequestException('host, user, pass and fromEmail are required'); + if (!isEmail(fromEmail)) throw new BadRequestException('fromEmail must be a valid email'); + return { host, port: Number(body.port) || 587, secure: body.secure === true || body.secure === 'true', user, pass, fromEmail, ...(fromName ? { fromName } : {}) }; + } + throw new BadRequestException(`unsupported providerType: ${providerType}`); +} /** * Per-scope BYO integration credentials. The caller's attested session decides the scope, so a @@ -22,16 +47,13 @@ export class ProviderCredentialController { @Put(':providerType/credentials') async put( @Param('providerType') providerType: string, - @Body() body: TwilioCredentialsDto, + @Body() body: Record, @Headers('authorization') authorization?: string, ) { this.assertSupported(providerType); + const config = buildConfig(providerType, body ?? {}); const scope = await this.actors.resolveScope(this.principal(authorization)); - await this.credentials.upsert(scope.id, providerType, { - accountSid: body.accountSid, - authToken: body.authToken, - fromNumber: body.fromNumber, - }); + await this.credentials.upsert(scope.id, providerType, config); return this.credentials.status(scope.id, providerType); } diff --git a/packages/iios-service/src/capability/provider-credential.dto.ts b/packages/iios-service/src/capability/provider-credential.dto.ts deleted file mode 100644 index 75c88e9..0000000 --- a/packages/iios-service/src/capability/provider-credential.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsNotEmpty, IsString } from 'class-validator'; - -/** - * PUT /v1/providers/TWILIO_SMS/credentials — a tenant's own Twilio credentials. Step 1 supports - * TWILIO_SMS only; when a second provider (SMTP, …) is added, switch to a per-type validated body. - */ -export class TwilioCredentialsDto { - @IsString() @IsNotEmpty() accountSid!: string; - @IsString() @IsNotEmpty() authToken!: string; - @IsString() @IsNotEmpty() fromNumber!: string; -} diff --git a/packages/iios-service/src/capability/provider-credential.service.ts b/packages/iios-service/src/capability/provider-credential.service.ts index 574913b..3963bd4 100644 --- a/packages/iios-service/src/capability/provider-credential.service.ts +++ b/packages/iios-service/src/capability/provider-credential.service.ts @@ -18,6 +18,9 @@ function hintsFor(providerType: string, config: ProviderConfig): Record { 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 ' }; + const scoped = (payload: Record, 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 '); + }); + + 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 ' }); + expect(smtpIdentityFromConfig({ host: 'h', user: 'u', pass: 'p' })).toBeNull(); + }); +}); diff --git a/packages/iios-service/src/capability/smtp.provider.ts b/packages/iios-service/src/capability/smtp.provider.ts index 2485b3a..cebd86c 100644 --- a/packages/iios-service/src/capability/smtp.provider.ts +++ b/packages/iios-service/src/capability/smtp.provider.ts @@ -84,6 +84,29 @@ function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | nu }; } +/** Map a tenant's stored SMTP config (BYO) to a sending identity, or null if incomplete. */ +export function smtpIdentityFromConfig(c: Record | null | undefined): SmtpIdentity | null { + if (!c) return null; + const s = (v: unknown): string => (typeof v === 'string' ? v.trim() : ''); + const host = s(c.host); + const user = s(c.user); + const pass = s(c.pass); + const fromEmail = s(c.fromEmail); + if (!host || !user || !pass || !fromEmail) return null; + const fromName = s(c.fromName); + return { + host, + port: Number(c.port) || 587, + secure: c.secure === true || c.secure === 'true', + user, + pass, + from: fromName ? `${fromName} <${fromEmail}>` : fromEmail, + }; +} + +/** Resolve a scope's own SMTP identity (BYO), decrypting the stored credential. Null when unset. */ +export type SmtpCredResolver = (scopeId: string) => Promise; + interface EmailPayload { subject?: string; text?: string; @@ -107,10 +130,12 @@ export class SmtpProvider implements CapabilityProvider { private readonly makeTransport: (id: SmtpIdentity) => MailTransport; constructor( - private readonly primary: SmtpIdentity, + private readonly primary: SmtpIdentity | undefined, private readonly fallback?: SmtpIdentity, makeTransport?: (id: SmtpIdentity) => MailTransport, private readonly resolveAttachment?: AttachmentResolver, + /** BYO: resolve the tenant's own SMTP identity per scope; used before the env identity. */ + private readonly credResolver?: SmtpCredResolver, ) { this.makeTransport = makeTransport ?? defaultTransport; } @@ -119,6 +144,13 @@ export class SmtpProvider implements CapabilityProvider { const started = Date.now(); const p = (req.payload ?? {}) as EmailPayload; + // BYO SMTP: a tenant's own identity wins over the platform env identity. The env fallback + // (accounts@ → ceo@) applies ONLY to the platform identity, never to a tenant's own server. + const tenant = req.scopeId && this.credResolver ? await this.credResolver(req.scopeId).catch(() => null) : null; + const identity = tenant ?? this.primary; + if (!identity) return this.failed('NOT_CONFIGURED', started); + const fallback = tenant ? undefined : this.fallback; + // Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing // its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch. let attachments: MailAttachment[] | undefined; @@ -145,13 +177,13 @@ export class SmtpProvider implements CapabilityProvider { }); try { - const info = await attempt(this.primary); + const info = await attempt(identity); 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)) { + if (fallback && isPreAcceptanceFailure(err)) { try { - const info = await attempt(this.fallback); + const info = await attempt(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 };