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 };