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>
This commit is contained in:
2026-07-23 13:37:50 +05:30
parent e503ed8904
commit 23c43acf8f
6 changed files with 116 additions and 27 deletions
@@ -3,7 +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, storageResolver } from './smtp.provider'; import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromConfig, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider'; import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
import { credKeyFromEnv } from './secret-crypto'; import { credKeyFromEnv } from './secret-crypto';
import { ProviderCredentialService } from './provider-credential.service'; import { ProviderCredentialService } from './provider-credential.service';
@@ -36,11 +36,15 @@ export class CapabilityProviderRegistry {
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). Attachments // 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(); const smtp = smtpIdentityFromEnv();
if (smtp) { const smtpCreds = credKeyFromEnv() && this.credentials ? this.credentials : undefined;
if (smtp || smtpCreds) {
const resolver = this.storage ? storageResolver(this.storage) : undefined; 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 // 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. // only when the platform key + credential store are present — else SMS stays on the sandbox.
@@ -2,9 +2,34 @@ import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { ProviderCredentialService } from './provider-credential.service'; 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<string, unknown>): Record<string, unknown> {
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 * 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') @Put(':providerType/credentials')
async put( async put(
@Param('providerType') providerType: string, @Param('providerType') providerType: string,
@Body() body: TwilioCredentialsDto, @Body() body: Record<string, unknown>,
@Headers('authorization') authorization?: string, @Headers('authorization') authorization?: string,
) { ) {
this.assertSupported(providerType); this.assertSupported(providerType);
const config = buildConfig(providerType, body ?? {});
const scope = await this.actors.resolveScope(this.principal(authorization)); const scope = await this.actors.resolveScope(this.principal(authorization));
await this.credentials.upsert(scope.id, providerType, { await this.credentials.upsert(scope.id, providerType, config);
accountSid: body.accountSid,
authToken: body.authToken,
fromNumber: body.fromNumber,
});
return this.credentials.status(scope.id, providerType); return this.credentials.status(scope.id, providerType);
} }
@@ -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;
}
@@ -18,6 +18,9 @@ function hintsFor(providerType: string, config: ProviderConfig): Record<string,
const sid = String(config.accountSid ?? ''); const sid = String(config.accountSid ?? '');
return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) }; return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) };
} }
if (providerType === 'SMTP') {
return { host: config.host ?? null, port: config.port ?? null, user: config.user ?? null, fromEmail: config.fromEmail ?? null, fromName: config.fromName ?? null };
}
return {}; return {};
} }
@@ -133,3 +133,42 @@ describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
expect(await r('nope')).toBeNull(); 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 <no-reply@acme.com>' };
const scoped = (payload: Record<string, unknown>, 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 <no-reply@acme.com>');
});
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 <a@b.com>' });
expect(smtpIdentityFromConfig({ host: 'h', user: 'u', pass: 'p' })).toBeNull();
});
});
@@ -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<string, unknown> | 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<SmtpIdentity | null>;
interface EmailPayload { interface EmailPayload {
subject?: string; subject?: string;
text?: string; text?: string;
@@ -107,10 +130,12 @@ export class SmtpProvider implements CapabilityProvider {
private readonly makeTransport: (id: SmtpIdentity) => MailTransport; private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
constructor( constructor(
private readonly primary: SmtpIdentity, private readonly primary: SmtpIdentity | undefined,
private readonly fallback?: SmtpIdentity, private readonly fallback?: SmtpIdentity,
makeTransport?: (id: SmtpIdentity) => MailTransport, makeTransport?: (id: SmtpIdentity) => MailTransport,
private readonly resolveAttachment?: AttachmentResolver, 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; this.makeTransport = makeTransport ?? defaultTransport;
} }
@@ -119,6 +144,13 @@ export class SmtpProvider implements CapabilityProvider {
const started = Date.now(); const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload; 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 // 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. // its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
let attachments: MailAttachment[] | undefined; let attachments: MailAttachment[] | undefined;
@@ -145,13 +177,13 @@ export class SmtpProvider implements CapabilityProvider {
}); });
try { try {
const info = await attempt(this.primary); const info = await attempt(identity);
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started }; return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err) { } catch (err) {
// Retry via the fallback identity ONLY if the primary never got the message accepted. // Retry via the fallback identity ONLY if the primary never got the message accepted.
if (this.fallback && isPreAcceptanceFailure(err)) { if (fallback && isPreAcceptanceFailure(err)) {
try { try {
const info = await attempt(this.fallback); const info = await attempt(fallback);
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started }; return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err2) { } 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(err2), latencyMs: Date.now() - started };