Feat/messaging ui foundation #10
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user