diff --git a/.env.example b/.env.example index 96537c5..13b593f 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ EMAIL_PROVIDER=development EMAIL_ALLOW_PRODUCTION_SEND= EMAIL_FROM_ADDRESS=noreply@mail.example.com EMAIL_FROM_NAME=PostKit +# Per-tenant sender overrides (JSON). Keys are tenantId -> environment -> sender fields. +# TENANT_EMAIL_CONFIG_BY_ID={"inkads":{"production":{"fromAddress":"noreply@mail.inkads.example.com","fromDisplayName":"InkAds","replyTo":"support@inkads.example.com"}}} +# Maps provider account ids to env var names whose values are Key Vault-resolved tokens. +# TENANT_PROVIDER_ACCOUNT_SECRETS={"inkads":"FORWARD_EMAIL_TOKEN_INKADS"} CONTACT_INBOX_ADDRESS= # Optional per-host contact profiles (JSON object). # CONTACT_EMAIL_PROFILES_BY_HOST={"example.com":{"fromAddress":"noreply@mail.example.com","fromName":"Example","contactInboxAddress":"hello@example.com"}} diff --git a/apps/api/src/config/app-configuration.ts b/apps/api/src/config/app-configuration.ts index d23a07e..7ca58a7 100644 --- a/apps/api/src/config/app-configuration.ts +++ b/apps/api/src/config/app-configuration.ts @@ -11,6 +11,8 @@ export const APP_CONFIGURATION_ENVIRONMENT_KEYS: Readonly 'app:email:allowProductionSend': 'EMAIL_ALLOW_PRODUCTION_SEND', 'app:email:fromAddress': 'EMAIL_FROM_ADDRESS', 'app:email:fromName': 'EMAIL_FROM_NAME', + 'app:email:tenantConfigById': 'TENANT_EMAIL_CONFIG_BY_ID', + 'app:email:tenantProviderAccountSecrets': 'TENANT_PROVIDER_ACCOUNT_SECRETS', 'app:email:contactInboxAddress': 'CONTACT_INBOX_ADDRESS', 'app:email:profilesByHost': 'CONTACT_EMAIL_PROFILES_BY_HOST', 'app:email:rateLimitPerMin': 'CONTACT_RATE_LIMIT_PER_MIN', diff --git a/apps/api/src/functions/send.security.spec.ts b/apps/api/src/functions/send.security.spec.ts index a855e3d..334de8f 100644 --- a/apps/api/src/functions/send.security.spec.ts +++ b/apps/api/src/functions/send.security.spec.ts @@ -9,6 +9,7 @@ import { type TenantContext, } from '@singleton-sd/post-kit-types'; import { ApiKeyTenantResolver, type TenantKeyMap } from '../tenant'; +import type { ResolvedTenantEmailConfig } from '../tenant/tenant-email-config'; import type { TemplateStore } from '../templates'; import { createSendHandler } from './send'; @@ -97,6 +98,17 @@ function tenantScopedStore( }; } +function stubTenantSender( + config: Partial = {}, +): Pick[0], 'resolveTenantEmailConfig'> { + return { + resolveTenantEmailConfig: async () => ({ + fromAddress: 'noreply@example.com', + ...config, + }), + }; +} + function fakeProvider(capture?: EmailSendRequest[]): EmailProvider { return { name: 'development', @@ -134,7 +146,7 @@ describe('sendHandler — cross-tenant isolation', () => { tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( @@ -160,7 +172,7 @@ describe('sendHandler — cross-tenant isolation', () => { tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( @@ -183,7 +195,7 @@ describe('sendHandler — cross-tenant isolation', () => { tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( @@ -239,7 +251,7 @@ describe('sendHandler — tenant spoofing has no effect', () => { tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler(fakeRequest(options), fakeContext()); @@ -261,7 +273,7 @@ describe('sendHandler — tenant spoofing has no effect', () => { tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( @@ -291,7 +303,7 @@ describe('sendHandler — environment isolation', () => { tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( @@ -342,7 +354,7 @@ describe('sendHandler — unsafe template keys are rejected before storage acces tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( @@ -384,7 +396,7 @@ describe('sendHandler — hostile variable values cannot inject markup', () => { tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( @@ -424,7 +436,7 @@ describe('sendHandler — hostile variable values cannot inject markup', () => { tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); await handler( @@ -453,7 +465,7 @@ describe('sendHandler — malformed and oversized bodies produce stable typed er tenantResolver: new ApiKeyTenantResolver(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler(fakeRequest(options), fakeContext()); return { response, calls }; diff --git a/apps/api/src/functions/send.spec.ts b/apps/api/src/functions/send.spec.ts index adcfe28..202cacc 100644 --- a/apps/api/src/functions/send.spec.ts +++ b/apps/api/src/functions/send.spec.ts @@ -13,6 +13,7 @@ import { type TenantContext, } from '@singleton-sd/post-kit-types'; import { TenantResolverError, type TenantResolver } from '../tenant'; +import type { ResolvedTenantEmailConfig } from '../tenant/tenant-email-config'; import { TemplateStoreError, type TemplateStore } from '../templates'; import { createLogger } from '../telemetry'; import { createSendHandler } from './send'; @@ -71,6 +72,17 @@ function fakeStore(result: CompiledTemplate | TemplateStoreError): TemplateStore }; } +function stubTenantSender( + config: Partial = {}, +): Pick[0], 'resolveTenantEmailConfig'> { + return { + resolveTenantEmailConfig: async () => ({ + fromAddress: 'noreply@example.com', + fromDisplayName: 'PostKit', + ...config, + }), + }; +} function fakeProvider(capture?: EmailSendRequest[]): EmailProvider { return { name: 'development', @@ -89,8 +101,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(sent), - fromAddress: () => 'noreply@example.com', - fromName: () => 'PostKit', + ...stubTenantSender(), }); const response = await handler( @@ -117,7 +128,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(sent), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); await handler( @@ -140,7 +151,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(false), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler(fakeRequest({ json: {} }), fakeContext()); assert.equal(response.status, 401); @@ -157,7 +168,7 @@ describe('sendHandler', () => { }, templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler(fakeRequest({ json: validBody() }), fakeContext()); assert.equal(response.status, 403); @@ -171,7 +182,7 @@ describe('sendHandler', () => { new TemplateStoreError('missing', PostKitErrorCode.TEMPLATE_NOT_FOUND), ), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler(fakeRequest({ json: validBody() }), fakeContext()); assert.equal(response.status, 404); @@ -183,7 +194,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(), templateStore: fakeStore(new TemplateStoreError('bad', PostKitErrorCode.INVALID_TEMPLATE)), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler(fakeRequest({ json: validBody() }), fakeContext()); assert.equal(response.status, 400); @@ -195,7 +206,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( fakeRequest({ @@ -212,7 +223,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( fakeRequest({ @@ -235,7 +246,7 @@ describe('sendHandler', () => { }, }, emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( fakeRequest({ @@ -265,7 +276,7 @@ describe('sendHandler', () => { templateStore: fakeStore(withBrandingVar), emailProvider: fakeProvider(sent), resolveBranding: async () => ({ companyName: 'InkAds' }), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler( @@ -290,7 +301,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)), }); @@ -326,7 +337,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)), }); @@ -358,7 +369,7 @@ describe('sendHandler', () => { tenantResolver: fakeResolver(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)), }); @@ -397,7 +408,7 @@ describe('sendHandler', () => { }); }, }, - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)), }); @@ -430,7 +441,7 @@ describe('sendHandler', () => { }); }, }, - fromAddress: () => 'noreply@example.com', + ...stubTenantSender(), }); const response = await handler(fakeRequest({ json: validBody() }), fakeContext()); assert.equal(response.status, 502); diff --git a/apps/api/src/functions/send.tenant-sender.spec.ts b/apps/api/src/functions/send.tenant-sender.spec.ts new file mode 100644 index 0000000..abc3e34 --- /dev/null +++ b/apps/api/src/functions/send.tenant-sender.spec.ts @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import type { HttpRequest, InvocationContext } from '@azure/functions'; +import type { EmailProvider, EmailSendRequest } from '@singleton-sd/post-kit-email'; +import { + PostKitErrorCode, + TEMPLATE_SCHEMA_VERSION, + type CompiledTemplate, + type TenantContext, +} from '@singleton-sd/post-kit-types'; +import { clearTenantEmailConfigCache } from '../tenant'; +import { TemplateStoreError, type TemplateStore } from '../templates'; +import { createLogger } from '../telemetry'; +import { createSendHandler } from './send'; + +const TENANT: TenantContext = { tenantId: 'inkads', environment: 'production' }; + +const COMPILED: CompiledTemplate = { + templateHtml: '

Hello {{name}}

', + metadata: { + key: 'marketing.contact-us', + name: 'Contact Us', + subject: 'Hi {{name}}', + variables: ['name'], + schemaVersion: TEMPLATE_SCHEMA_VERSION, + }, + manifest: { + key: 'marketing.contact-us', + schemaVersion: TEMPLATE_SCHEMA_VERSION, + compiledAt: '2026-01-01T00:00:00.000Z', + sourceCommit: '', + variables: ['name'], + contentHash: 'abc', + }, +}; + +function fakeRequest(json: unknown): HttpRequest { + return { + method: 'POST', + headers: { get: () => null }, + json: async () => json, + } as unknown as HttpRequest; +} + +function fakeContext(): InvocationContext { + return { error: () => undefined } as unknown as InvocationContext; +} + +function fakeProvider(capture?: EmailSendRequest[]): EmailProvider { + return { + name: 'development', + isConfigured: () => true, + send: async (request) => { + capture?.push(request); + return { providerMessageId: 'msg-1', accepted: true }; + }, + }; +} + +describe('sendHandler — tenant-scoped sender configuration', () => { + const touched = [ + 'TENANT_EMAIL_CONFIG_BY_ID', + 'TENANT_PROVIDER_ACCOUNT_SECRETS', + 'EMAIL_FROM_ADDRESS', + 'EMAIL_FROM_NAME', + ]; + const prior = new Map(); + + beforeEach(() => { + for (const key of touched) { + prior.set(key, process.env[key]); + delete process.env[key]; + } + clearTenantEmailConfigCache(); + }); + + afterEach(() => { + for (const [key, value] of prior) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearTenantEmailConfigCache(); + }); + + it('applies tenant fromAddress, fromDisplayName, and replyTo to the outgoing message', async () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { + fromAddress: 'noreply@inkads.example.com', + fromDisplayName: 'InkAds', + replyTo: 'support@inkads.example.com', + }, + }, + }); + + const sent: EmailSendRequest[] = []; + const handler = createSendHandler({ + tenantResolver: { resolve: async () => TENANT }, + templateStore: { load: async () => COMPILED }, + emailProvider: fakeProvider(sent), + }); + + const response = await handler( + fakeRequest({ + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: 'Ada' }, + }), + fakeContext(), + ); + + assert.equal(response.status, 200); + assert.equal(sent[0]?.from, 'noreply@inkads.example.com'); + assert.equal(sent[0]?.fromName, 'InkAds'); + assert.equal(sent[0]?.replyTo, 'support@inkads.example.com'); + }); + + it('ignores sender and reply-to fields in the request body', async () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { + fromAddress: 'noreply@inkads.example.com', + fromDisplayName: 'InkAds', + replyTo: 'support@inkads.example.com', + }, + }, + }); + + const sent: EmailSendRequest[] = []; + const handler = createSendHandler({ + tenantResolver: { resolve: async () => TENANT }, + templateStore: { load: async () => COMPILED }, + emailProvider: fakeProvider(sent), + }); + + await handler( + fakeRequest({ + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: 'Ada' }, + from: 'attacker@evil.example.com', + fromAddress: 'attacker@evil.example.com', + fromDisplayName: 'Evil', + fromName: 'Evil', + replyTo: 'attacker@evil.example.com', + sender: 'attacker@evil.example.com', + }), + fakeContext(), + ); + + assert.equal(sent[0]?.from, 'noreply@inkads.example.com'); + assert.equal(sent[0]?.fromName, 'InkAds'); + assert.equal(sent[0]?.replyTo, 'support@inkads.example.com'); + }); + + it('returns TENANT_CONFIG_NOT_FOUND when the tenant has no email configuration', async () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + other: { production: { fromAddress: 'noreply@other.example.com' } }, + }); + + const handler = createSendHandler({ + tenantResolver: { resolve: async () => TENANT }, + templateStore: { load: async () => COMPILED }, + emailProvider: fakeProvider(), + }); + + const response = await handler( + fakeRequest({ + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: 'Ada' }, + }), + fakeContext(), + ); + + assert.equal(response.status, 503); + assert.equal( + (response.jsonBody as { code: string }).code, + PostKitErrorCode.TENANT_CONFIG_NOT_FOUND, + ); + assert.ok( + !JSON.stringify(response.jsonBody).includes('noreply@other.example.com'), + 'must not leak another tenant sender', + ); + }); + + it('merges platform defaults only for fields the tenant has not overridden', async () => { + process.env.EMAIL_FROM_ADDRESS = 'platform@example.com'; + process.env.EMAIL_FROM_NAME = 'Platform'; + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { + fromDisplayName: 'InkAds', + }, + }, + }); + + const sent: EmailSendRequest[] = []; + const handler = createSendHandler({ + tenantResolver: { resolve: async () => TENANT }, + templateStore: { load: async () => COMPILED }, + emailProvider: fakeProvider(sent), + }); + + await handler( + fakeRequest({ + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: 'Ada' }, + }), + fakeContext(), + ); + + assert.equal(sent[0]?.from, 'platform@example.com'); + assert.equal(sent[0]?.fromName, 'InkAds'); + assert.equal(sent[0]?.replyTo, undefined); + }); + + it('does not expose provider credentials in error responses or logs', async () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { + fromAddress: 'noreply@inkads.example.com', + providerAccount: 'inkads', + }, + }, + }); + process.env.TENANT_PROVIDER_ACCOUNT_SECRETS = JSON.stringify({ + inkads: 'FORWARD_EMAIL_TOKEN_INKADS', + }); + process.env.FORWARD_EMAIL_TOKEN_INKADS = 'super-secret-token'; + + const lines: string[] = []; + const handler = createSendHandler({ + tenantResolver: { resolve: async () => TENANT }, + templateStore: { + load: async () => { + throw new TemplateStoreError('missing', PostKitErrorCode.TEMPLATE_NOT_FOUND); + }, + }, + emailProvider: fakeProvider(), + createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)), + }); + + const response = await handler( + fakeRequest({ + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: 'Ada' }, + }), + fakeContext(), + ); + + const serialized = JSON.stringify({ body: response.jsonBody, logs: lines }); + assert.ok(!serialized.includes('super-secret-token')); + assert.ok(!serialized.includes('FORWARD_EMAIL_TOKEN_INKADS')); + }); +}); diff --git a/apps/api/src/functions/send.ts b/apps/api/src/functions/send.ts index 8d5c339..20ce57a 100644 --- a/apps/api/src/functions/send.ts +++ b/apps/api/src/functions/send.ts @@ -20,6 +20,9 @@ import { createLogger, hashRecipient, resolveCorrelationId, type Logger } from ' import { ApiKeyTenantResolver, TenantResolverError, + resolveTenantEmailConfig, + TenantEmailConfigError, + type ResolvedTenantEmailConfig, type TenantKeyMap, type TenantResolver, } from '../tenant'; @@ -33,7 +36,7 @@ export interface SendHandlerDependencies { tenantResolver: TenantResolver; templateStore: TemplateStore; /** Prefer injecting a factory so App Configuration can populate env first. */ - createEmailProvider?: () => EmailProvider; + createEmailProvider?: (options?: { apiToken?: string }) => EmailProvider; /** Direct provider injection for unit tests. */ emailProvider?: EmailProvider; /** @@ -43,9 +46,14 @@ export interface SendHandlerDependencies { resolveBranding?: (tenant: TenantContext) => Promise | TenantBranding; /** Static branding for tests (applied after resolveBranding). */ branding?: TenantBranding; + /** + * Resolve tenant sender identity after auth. Merged with platform defaults in + * `resolveTenantEmailConfig`; request bodies cannot override sender fields. + */ + resolveTenantEmailConfig?: ( + tenant: TenantContext, + ) => Promise | ResolvedTenantEmailConfig; createLogger?: typeof createLogger; - fromAddress?: () => string; - fromName?: () => string | undefined; } function parseTenantKeyMap(raw: string | undefined): TenantKeyMap { @@ -66,10 +74,9 @@ export function createDefaultSendDependencies( return new ApiKeyTenantResolver(parseTenantKeyMap(process.env.TENANT_KEY_MAP)); }, templateStore, - createEmailProvider: () => createEmailProvider(process.env), + createEmailProvider: (options) => createEmailProvider(process.env, options), resolveBranding: async () => ({}), - fromAddress: () => process.env.EMAIL_FROM_ADDRESS ?? '', - fromName: () => process.env.EMAIL_FROM_NAME, + resolveTenantEmailConfig: (tenant) => resolveTenantEmailConfig(tenant), }; } @@ -205,24 +212,22 @@ export function createSendHandler(deps: SendHandlerDependencies) { const subject = Handlebars.compile(compiled.metadata.subject, { noEscape: false })(variables); const html = Handlebars.compile(compiled.templateHtml, { noEscape: false })(variables); - const fromAddress = (deps.fromAddress ?? (() => process.env.EMAIL_FROM_ADDRESS ?? ''))(); - if (!fromAddress) { - return errorResponse( - 503, - PostKitErrorCode.PROVIDER_FAILURE, - 'Email sender is not configured.', - 'failed', - { failureCategory: 'provider_not_configured' }, - ); - } + const resolveTenantEmailConfigFn = + deps.resolveTenantEmailConfig ?? ((tenant) => resolveTenantEmailConfig(tenant)); + const tenantEmailConfig = await resolveTenantEmailConfigFn(tenant); const provider = deps.emailProvider ?? - (deps.createEmailProvider ?? (() => createEmailProvider(process.env)))(); + (deps.createEmailProvider ?? ((options) => createEmailProvider(process.env, options)))( + tenantEmailConfig.providerApiToken + ? { apiToken: tenantEmailConfig.providerApiToken } + : undefined, + ); const result = await provider.send({ to: sendRequest.to, - from: fromAddress, - fromName: (deps.fromName ?? (() => process.env.EMAIL_FROM_NAME))(), + from: tenantEmailConfig.fromAddress, + fromName: tenantEmailConfig.fromDisplayName, + replyTo: tenantEmailConfig.replyTo, subject, html, correlationId, @@ -239,6 +244,12 @@ export function createSendHandler(deps: SendHandlerDependencies) { const response: SendResponse = { id: correlationId, status: 'sent' }; return { status: 200, headers, jsonBody: response }; } catch (error) { + if (error instanceof TenantEmailConfigError) { + return errorResponse(503, error.code, error.message, 'failed', { + failureCategory: 'tenant_config_not_found', + }); + } + if (error instanceof TenantResolverError) { const status = error.code === PostKitErrorCode.UNAUTHENTICATED @@ -304,6 +315,8 @@ function failureCategoryFromErrorCode( return 'template_not_found'; case PostKitErrorCode.STORAGE_FAILURE: return 'storage_failure'; + case PostKitErrorCode.TENANT_CONFIG_NOT_FOUND: + return 'tenant_config_not_found'; case PostKitErrorCode.PROVIDER_FAILURE: return 'provider_failure'; default: diff --git a/apps/api/src/tenant/index.ts b/apps/api/src/tenant/index.ts index 4b2a8ac..423423f 100644 --- a/apps/api/src/tenant/index.ts +++ b/apps/api/src/tenant/index.ts @@ -4,3 +4,11 @@ export { TenantResolverError, type TenantKeyMap, } from './api-key-tenant-resolver'; +export { + clearTenantEmailConfigCache, + resolveTenantEmailConfig, + TenantEmailConfigError, + type ResolvedTenantEmailConfig, + type TenantEmailConfig, + type TenantEmailConfigOverride, +} from './tenant-email-config'; diff --git a/apps/api/src/tenant/tenant-email-config.spec.ts b/apps/api/src/tenant/tenant-email-config.spec.ts new file mode 100644 index 0000000..39c6856 --- /dev/null +++ b/apps/api/src/tenant/tenant-email-config.spec.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { PostKitErrorCode, type TenantContext } from '@singleton-sd/post-kit-types'; +import { + clearTenantEmailConfigCache, + resolveTenantEmailConfig, + TenantEmailConfigError, +} from './tenant-email-config'; + +const INKADS_PROD: TenantContext = { tenantId: 'inkads', environment: 'production' }; +const INKADS_DEV: TenantContext = { tenantId: 'inkads', environment: 'development' }; + +describe('resolveTenantEmailConfig', () => { + const touched = [ + 'TENANT_EMAIL_CONFIG_BY_ID', + 'TENANT_PROVIDER_ACCOUNT_SECRETS', + 'EMAIL_FROM_ADDRESS', + 'EMAIL_FROM_NAME', + 'FORWARD_EMAIL_TOKEN_INKADS', + ]; + const prior = new Map(); + + beforeEach(() => { + for (const key of touched) { + prior.set(key, process.env[key]); + delete process.env[key]; + } + clearTenantEmailConfigCache(); + }); + + afterEach(() => { + for (const [key, value] of prior) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearTenantEmailConfigCache(); + }); + + it('throws TENANT_CONFIG_NOT_FOUND when the tenant is absent from the map', () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + other: { production: { fromAddress: 'noreply@other.example.com' } }, + }); + + assert.throws( + () => resolveTenantEmailConfig(INKADS_PROD), + (error: unknown) => { + assert.ok(error instanceof TenantEmailConfigError); + assert.equal(error.code, PostKitErrorCode.TENANT_CONFIG_NOT_FOUND); + assert.match(error.message, /inkads/); + assert.match(error.message, /production/); + return true; + }, + ); + }); + + it('throws TENANT_CONFIG_NOT_FOUND when the environment is absent for a known tenant', () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { development: { fromAddress: 'dev@inkads.example.com' } }, + }); + + assert.throws( + () => resolveTenantEmailConfig(INKADS_PROD), + (error: unknown) => { + assert.ok(error instanceof TenantEmailConfigError); + assert.equal(error.code, PostKitErrorCode.TENANT_CONFIG_NOT_FOUND); + return true; + }, + ); + }); + + it('merges tenant overrides with platform defaults for unset fields', () => { + process.env.EMAIL_FROM_ADDRESS = 'platform@example.com'; + process.env.EMAIL_FROM_NAME = 'Platform'; + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { + fromDisplayName: 'InkAds', + replyTo: 'support@inkads.example.com', + }, + }, + }); + + const resolved = resolveTenantEmailConfig(INKADS_PROD); + assert.equal(resolved.fromAddress, 'platform@example.com'); + assert.equal(resolved.fromDisplayName, 'InkAds'); + assert.equal(resolved.replyTo, 'support@inkads.example.com'); + }); + + it('uses tenant fromAddress when provided', () => { + process.env.EMAIL_FROM_ADDRESS = 'platform@example.com'; + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { fromAddress: 'noreply@inkads.example.com' }, + }, + }); + + const resolved = resolveTenantEmailConfig(INKADS_PROD); + assert.equal(resolved.fromAddress, 'noreply@inkads.example.com'); + }); + + it('allows an empty tenant entry to inherit all platform defaults', () => { + process.env.EMAIL_FROM_ADDRESS = 'platform@example.com'; + process.env.EMAIL_FROM_NAME = 'Platform'; + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { production: {} }, + }); + + const resolved = resolveTenantEmailConfig(INKADS_PROD); + assert.equal(resolved.fromAddress, 'platform@example.com'); + assert.equal(resolved.fromDisplayName, 'Platform'); + assert.equal(resolved.replyTo, undefined); + }); + + it('throws TENANT_CONFIG_NOT_FOUND when fromAddress is missing after merge', () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { production: { fromDisplayName: 'InkAds' } }, + }); + + assert.throws( + () => resolveTenantEmailConfig(INKADS_PROD), + (error: unknown) => { + assert.ok(error instanceof TenantEmailConfigError); + assert.equal(error.code, PostKitErrorCode.TENANT_CONFIG_NOT_FOUND); + return true; + }, + ); + }); + + it('resolves provider account tokens from referenced env vars without exposing values', () => { + process.env.EMAIL_FROM_ADDRESS = 'platform@example.com'; + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { + fromAddress: 'noreply@inkads.example.com', + providerAccount: 'inkads', + }, + }, + }); + process.env.TENANT_PROVIDER_ACCOUNT_SECRETS = JSON.stringify({ + inkads: 'FORWARD_EMAIL_TOKEN_INKADS', + }); + process.env.FORWARD_EMAIL_TOKEN_INKADS = 'secret-token-value'; + + const resolved = resolveTenantEmailConfig(INKADS_PROD); + assert.equal(resolved.providerApiToken, 'secret-token-value'); + assert.equal(resolved.providerAccount, 'inkads'); + }); + + it('does not leak provider account identifiers in error messages', () => { + process.env.EMAIL_FROM_ADDRESS = 'platform@example.com'; + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { + fromAddress: 'noreply@inkads.example.com', + providerAccount: 'inkads', + }, + }, + }); + process.env.TENANT_PROVIDER_ACCOUNT_SECRETS = JSON.stringify({ + inkads: 'FORWARD_EMAIL_TOKEN_INKADS', + }); + + assert.throws( + () => resolveTenantEmailConfig(INKADS_PROD), + (error: unknown) => { + assert.ok(error instanceof TenantEmailConfigError); + assert.equal(error.code, PostKitErrorCode.TENANT_CONFIG_NOT_FOUND); + assert.ok(!error.message.includes('inkads')); + assert.ok(!error.message.includes('FORWARD_EMAIL_TOKEN_INKADS')); + return true; + }, + ); + }); + + it('rejects malformed TENANT_EMAIL_CONFIG_BY_ID JSON', () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = 'not-json'; + + assert.throws( + () => resolveTenantEmailConfig(INKADS_PROD), + (error: unknown) => { + assert.ok(error instanceof TenantEmailConfigError); + assert.equal(error.code, PostKitErrorCode.TENANT_CONFIG_NOT_FOUND); + return true; + }, + ); + }); + + it('scopes configuration per environment', () => { + process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ + inkads: { + production: { fromAddress: 'prod@inkads.example.com' }, + development: { fromAddress: 'dev@inkads.example.com' }, + }, + }); + + assert.equal(resolveTenantEmailConfig(INKADS_PROD).fromAddress, 'prod@inkads.example.com'); + assert.equal(resolveTenantEmailConfig(INKADS_DEV).fromAddress, 'dev@inkads.example.com'); + }); +}); diff --git a/apps/api/src/tenant/tenant-email-config.ts b/apps/api/src/tenant/tenant-email-config.ts new file mode 100644 index 0000000..9a7b884 --- /dev/null +++ b/apps/api/src/tenant/tenant-email-config.ts @@ -0,0 +1,270 @@ +import { + PostKitErrorCode, + type TenantContext, + type TenantEnvironment, +} from '@singleton-sd/post-kit-types'; + +/** Tenant-scoped sender and provider settings resolved server-side. */ +export interface TenantEmailConfig { + fromAddress: string; + fromDisplayName?: string; + replyTo?: string; + providerAccount?: string; +} + +/** Partial overrides stored in configuration for a tenant/environment. */ +export type TenantEmailConfigOverride = Partial< + Omit & { fromAddress?: string } +>; + +/** Fully resolved sender identity used when dispatching email. */ +export type ResolvedTenantEmailConfig = TenantEmailConfig & { + /** Resolved provider API token when providerAccount is configured. Never log. */ + providerApiToken?: string; +}; + +type TenantEnvironmentMap = Partial>; +type TenantEmailConfigMap = Record; + +type ProviderAccountSecretMap = Record; + +type ConfigCache = { + tenantConfigRaw: string; + providerSecretsRaw: string; + tenantConfig: TenantEmailConfigMap; + providerSecrets: ProviderAccountSecretMap; +}; + +let cache: ConfigCache | null = null; + +const EMAIL_RE = /^[^\s@<>\r\n]+@[^\s@<>\r\n]+\.[^\s@<>\r\n]+$/; + +/** + * Error thrown when tenant email configuration is missing or incomplete. + * Never include provider credentials or account identifiers in messages. + */ +export class TenantEmailConfigError extends Error { + readonly code: PostKitErrorCode; + + constructor(message: string, code: PostKitErrorCode = PostKitErrorCode.TENANT_CONFIG_NOT_FOUND) { + super(message); + this.name = 'TenantEmailConfigError'; + this.code = code; + } +} + +/** Test helper: drop memoized parsed configuration. */ +export function clearTenantEmailConfigCache(): void { + cache = null; +} + +/** + * Resolve tenant-scoped sender identity merged with platform defaults. + * + * Precedence (lowest first): platform `EMAIL_FROM_*` env vars, then the tenant's + * configured override for the authenticated environment. + */ +export function resolveTenantEmailConfig( + tenant: TenantContext, + env: NodeJS.ProcessEnv = process.env, +): ResolvedTenantEmailConfig { + const { tenantConfig, providerSecrets } = loadConfigMaps(env); + const tenantEntry = tenantConfig[tenant.tenantId]; + if (!tenantEntry) { + throw new TenantEmailConfigError( + `Email configuration is not defined for tenant "${tenant.tenantId}" in environment "${tenant.environment}".`, + ); + } + + const override = tenantEntry[tenant.environment]; + if (!override) { + throw new TenantEmailConfigError( + `Email configuration is not defined for tenant "${tenant.tenantId}" in environment "${tenant.environment}".`, + ); + } + + const fromAddress = (override.fromAddress ?? env.EMAIL_FROM_ADDRESS ?? '').trim(); + if (!fromAddress) { + throw new TenantEmailConfigError( + `Email sender is not configured for tenant "${tenant.tenantId}" in environment "${tenant.environment}".`, + ); + } + + if (!EMAIL_RE.test(fromAddress)) { + throw new TenantEmailConfigError( + `Email sender address is invalid for tenant "${tenant.tenantId}" in environment "${tenant.environment}".`, + ); + } + + const fromDisplayName = (override.fromDisplayName ?? env.EMAIL_FROM_NAME)?.trim() || undefined; + const replyTo = override.replyTo?.trim() || undefined; + if (replyTo && !EMAIL_RE.test(replyTo)) { + throw new TenantEmailConfigError( + `Reply-to address is invalid for tenant "${tenant.tenantId}" in environment "${tenant.environment}".`, + ); + } + + const providerAccount = override.providerAccount?.trim() || undefined; + const providerApiToken = providerAccount + ? resolveProviderApiToken(providerAccount, providerSecrets, env) + : undefined; + + return { + fromAddress, + fromDisplayName, + replyTo, + providerAccount, + providerApiToken, + }; +} + +function loadConfigMaps(env: NodeJS.ProcessEnv): { + tenantConfig: TenantEmailConfigMap; + providerSecrets: ProviderAccountSecretMap; +} { + const tenantConfigRaw = env.TENANT_EMAIL_CONFIG_BY_ID ?? ''; + const providerSecretsRaw = env.TENANT_PROVIDER_ACCOUNT_SECRETS ?? ''; + + if ( + cache && + cache.tenantConfigRaw === tenantConfigRaw && + cache.providerSecretsRaw === providerSecretsRaw + ) { + return { + tenantConfig: cache.tenantConfig, + providerSecrets: cache.providerSecrets, + }; + } + + const tenantConfig = parseTenantConfigMap(tenantConfigRaw); + const providerSecrets = parseProviderAccountSecretMap(providerSecretsRaw); + cache = { tenantConfigRaw, providerSecretsRaw, tenantConfig, providerSecrets }; + return { tenantConfig, providerSecrets }; +} + +function parseTenantConfigMap(raw: string): TenantEmailConfigMap { + if (!raw.trim()) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new TenantEmailConfigError('Tenant email configuration is not valid JSON.'); + } + if (!isRecord(parsed)) { + throw new TenantEmailConfigError('Tenant email configuration must be a JSON object.'); + } + + const out: TenantEmailConfigMap = {}; + for (const [tenantId, environments] of Object.entries(parsed)) { + if (!isRecord(environments)) { + throw new TenantEmailConfigError( + `Tenant email configuration for "${tenantId}" must be an object keyed by environment.`, + ); + } + const envMap: TenantEnvironmentMap = {}; + for (const [environment, config] of Object.entries(environments)) { + if (!isTenantEnvironment(environment)) { + throw new TenantEmailConfigError( + `Tenant email configuration for "${tenantId}" contains an unknown environment "${environment}".`, + ); + } + if (!isRecord(config)) { + throw new TenantEmailConfigError( + `Tenant email configuration for "${tenantId}" / "${environment}" must be an object.`, + ); + } + envMap[environment] = parseOverride(config, tenantId, environment); + } + out[tenantId] = envMap; + } + return out; +} + +function parseOverride( + config: Record, + tenantId: string, + environment: string, +): TenantEmailConfigOverride { + const prefix = `Tenant email configuration for "${tenantId}" / "${environment}"`; + const override: TenantEmailConfigOverride = {}; + + if (config.fromAddress !== undefined) { + override.fromAddress = parseOptionalString(config.fromAddress, `${prefix}.fromAddress`); + } + if (config.fromDisplayName !== undefined) { + override.fromDisplayName = parseOptionalString( + config.fromDisplayName, + `${prefix}.fromDisplayName`, + ); + } + if (config.replyTo !== undefined) { + override.replyTo = parseOptionalString(config.replyTo, `${prefix}.replyTo`); + } + if (config.providerAccount !== undefined) { + override.providerAccount = parseOptionalString( + config.providerAccount, + `${prefix}.providerAccount`, + ); + } + + return override; +} + +function parseProviderAccountSecretMap(raw: string): ProviderAccountSecretMap { + if (!raw.trim()) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new TenantEmailConfigError('Tenant provider account configuration is not valid JSON.'); + } + if (!isRecord(parsed)) { + throw new TenantEmailConfigError( + 'Tenant provider account configuration must be a JSON object.', + ); + } + + const out: ProviderAccountSecretMap = {}; + for (const [accountId, envVarName] of Object.entries(parsed)) { + out[accountId] = parseOptionalString( + envVarName, + `Tenant provider account configuration["${accountId}"]`, + ); + } + return out; +} + +function resolveProviderApiToken( + providerAccount: string, + providerSecrets: ProviderAccountSecretMap, + env: NodeJS.ProcessEnv, +): string | undefined { + const envVarName = providerSecrets[providerAccount]; + if (!envVarName) { + throw new TenantEmailConfigError('Tenant provider account is not configured.'); + } + const token = env[envVarName]?.trim(); + if (!token) { + throw new TenantEmailConfigError('Tenant provider account is not configured.'); + } + return token; +} + +function parseOptionalString(value: unknown, fieldPath: string): string { + if (typeof value !== 'string') { + throw new TenantEmailConfigError(`${fieldPath} must be a string when provided.`); + } + const trimmed = value.trim(); + if (!trimmed) { + throw new TenantEmailConfigError(`${fieldPath} must be a non-empty string when provided.`); + } + return trimmed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isTenantEnvironment(value: string): value is TenantEnvironment { + return value === 'development' || value === 'staging' || value === 'production'; +} diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index 01aa9d8..e099cb8 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -44,6 +44,7 @@ ones. | 400 | `INVALID_RECIPIENT` | Rejected at body validation, `outcome=validation_error` | Request body is not a JSON object (null, array, or unparseable), or `to` is not a string matching basic `local@domain.tld` validation | The caller's request shape and `Content-Type`. Do not paste the body into a ticket — it contains a recipient address | No — fix the request | | 400 | `MISSING_VARIABLES` | Rejected at validation, message lists the offending names | `variables` is absent/not an object, one of its values is not a string, or a variable declared in the template metadata is not supplied (after tenant branding defaults are merged in) | The `variables` declared in the template's `metadata.json` against the caller's keys. Values are never logged | No — fix the request or the template metadata | | 404 | `TEMPLATE_NOT_FOUND` | Valid key, `outcome=failed`, `templateKey` present | No blob at `tenants/{tenantId}/{environment}/templates/{templateKey}/template.html` or `/metadata.json` | See [Runbook 1](#runbook-1--template-not-found) | No — until the template is published | +| 503 | `TENANT_CONFIG_NOT_FOUND` | Fails after template compilation, before the provider is constructed; `failureCategory=tenant_config_not_found` | The authenticated tenant/environment has no entry in `TENANT_EMAIL_CONFIG_BY_ID`, the entry is incomplete (no resolvable `fromAddress` after merge), or a configured `providerAccount` cannot be resolved from `TENANT_PROVIDER_ACCOUNT_SECRETS` | `TENANT_EMAIL_CONFIG_BY_ID` for the `tenantId` and `environment` from the credential; platform `EMAIL_FROM_ADDRESS` when the tenant entry omits `fromAddress`; Key Vault-backed env vars referenced by `TENANT_PROVIDER_ACCOUNT_SECRETS` | Only after configuration is fixed | | 503 | `PROVIDER_FAILURE` | Message `Email sender is not configured.`; fails after template compilation, before the provider is constructed | `EMAIL_FROM_ADDRESS` is unset in Function App settings and in App Configuration (`app:email:fromAddress`) | The Function App application settings and App Configuration key | Only after configuration is fixed | | 503 | `PROVIDER_FAILURE` | Provider rejected the send; `send provider failed` is logged with `kind=configuration` | Provider credential missing — e.g. `FORWARD_EMAIL_TOKEN` not resolved from Key Vault via App Configuration, or a malformed contact profile configuration | Key Vault reference resolution for `secret:forwardemail-api-key`; the Function App's managed identity access to Key Vault | Only after configuration is fixed | | 503 | `PROVIDER_FAILURE` | Intermittent; `kind=transient`, often with `statusCode` 5xx/408/409, or a request timeout (15 s) or transport failure | Provider outage, network failure, or timeout. The provider already retried internally (up to 2 retries with backoff) before surfacing this | Provider status; whether `durationMs` is near the timeout ceiling | Yes — retry with backoff | @@ -62,10 +63,10 @@ Notes on reading this table: `kind`. - `STORAGE_FAILURE` is currently returned **only** for App Configuration load failure. Blob Storage failures other than "not found" surface as `500`. -- All eight `PostKitErrorCode` values are reachable from this endpoint and all - eight appear above: `UNAUTHENTICATED`, `UNAUTHORIZED`, `INVALID_TEMPLATE`, +- All nine `PostKitErrorCode` values are reachable from this endpoint and all + nine appear above: `UNAUTHENTICATED`, `UNAUTHORIZED`, `INVALID_TEMPLATE`, `INVALID_RECIPIENT`, `MISSING_VARIABLES`, `TEMPLATE_NOT_FOUND`, - `PROVIDER_FAILURE`, `STORAGE_FAILURE`. + `TENANT_CONFIG_NOT_FOUND`, `PROVIDER_FAILURE`, `STORAGE_FAILURE`. ## Correlation IDs and log fields diff --git a/packages/post-kit-email/src/providers/create-email-provider.ts b/packages/post-kit-email/src/providers/create-email-provider.ts index ea0f162..160a43e 100644 --- a/packages/post-kit-email/src/providers/create-email-provider.ts +++ b/packages/post-kit-email/src/providers/create-email-provider.ts @@ -77,11 +77,14 @@ export function loadEmailRuntimeConfig(env: NodeJS.ProcessEnv = process.env): Em }; } -export function createEmailProvider(env: NodeJS.ProcessEnv = process.env): EmailProvider { +export function createEmailProvider( + env: NodeJS.ProcessEnv = process.env, + options: { apiToken?: string } = {}, +): EmailProvider { const config = loadEmailRuntimeConfig(env); if (config.provider === 'forward-email') { return new ForwardEmailProvider({ - apiToken: env.FORWARD_EMAIL_TOKEN ?? env.FORWARDEMAIL_API_KEY, + apiToken: options.apiToken ?? env.FORWARD_EMAIL_TOKEN ?? env.FORWARDEMAIL_API_KEY, baseUrl: env.FORWARD_EMAIL_BASE_URL ?? env.FORWARDEMAIL_BASE_URL, }); } diff --git a/packages/post-kit-types/src/index.spec.ts b/packages/post-kit-types/src/index.spec.ts index ec8a633..f1a7176 100644 --- a/packages/post-kit-types/src/index.spec.ts +++ b/packages/post-kit-types/src/index.spec.ts @@ -174,13 +174,14 @@ describe('PostKitErrorCode', () => { assert.equal(PostKitErrorCode.INVALID_RECIPIENT, 'INVALID_RECIPIENT'); assert.equal(PostKitErrorCode.PROVIDER_FAILURE, 'PROVIDER_FAILURE'); assert.equal(PostKitErrorCode.STORAGE_FAILURE, 'STORAGE_FAILURE'); + assert.equal(PostKitErrorCode.TENANT_CONFIG_NOT_FOUND, 'TENANT_CONFIG_NOT_FOUND'); }); - it('has exactly 8 codes', () => { + it('has exactly 9 codes', () => { const codes = Object.keys(PostKitErrorCode).filter( (k) => typeof PostKitErrorCode[k as keyof typeof PostKitErrorCode] === 'string', ); - assert.equal(codes.length, 8); + assert.equal(codes.length, 9); }); }); diff --git a/packages/post-kit-types/src/send.ts b/packages/post-kit-types/src/send.ts index 920ae09..a1e0dfe 100644 --- a/packages/post-kit-types/src/send.ts +++ b/packages/post-kit-types/src/send.ts @@ -30,6 +30,8 @@ export enum PostKitErrorCode { PROVIDER_FAILURE = 'PROVIDER_FAILURE', /** The template storage backend returned an error. */ STORAGE_FAILURE = 'STORAGE_FAILURE', + /** The authenticated tenant has no email sender configuration for this environment. */ + TENANT_CONFIG_NOT_FOUND = 'TENANT_CONFIG_NOT_FOUND', } /**