Files
iios/packages/iios-service/src/capability/capability.registry.ts
T
maaz519 23c43acf8f feat(capability): BYO SMTP — per-tenant email server via the credential registry
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 <noreply@anthropic.com>
2026-07-23 14:52:15 +05:30

72 lines
3.8 KiB
TypeScript

import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
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, 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';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
/**
* Maps a channelType to the provider that executes egress for it. Sandbox by
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` 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 {
private readonly byChannel = new Map<string, CapabilityProvider>();
constructor(
@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort,
@Optional() private readonly credentials?: ProviderCredentialService,
) {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
if (!url) continue;
// 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). Attachments
// 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();
const smtpCreds = credKeyFromEnv() && this.credentials ? this.credentials : undefined;
if (smtp || smtpCreds) {
const resolver = this.storage ? storageResolver(this.storage) : undefined;
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.
if (credKeyFromEnv() && this.credentials) {
const creds = this.credentials;
this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise<TwilioCreds | null>));
}
}
register(provider: CapabilityProvider): void {
for (const ch of provider.channelTypes) this.byChannel.set(ch, provider);
}
forChannel(channelType: string): CapabilityProvider {
const p = this.byChannel.get(channelType);
if (!p) throw new NotFoundException(`no egress provider registered for channel: ${channelType}`);
return p;
}
/** Ops view: which provider backs each channel. */
bindings(): Record<string, string> {
return Object.fromEntries([...this.byChannel.entries()].map(([ch, p]) => [ch, p.name]));
}
}