diff --git a/.env.example b/.env.example index 13b593f..33319ab 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,13 @@ CONTACT_INBOX_ADDRESS= # Allowed origins for the Functions API (comma-separated). Empty = unset. ORIGINS= +# POST /emails/send limits (defaults shown). Not settable from request bodies. +# SEND_MAX_BODY_BYTES=262144 +# SEND_MAX_VARIABLES_BYTES=131072 +# SEND_MAX_VARIABLE_VALUE_BYTES=32768 +# SEND_RATE_LIMIT_PER_MIN=60 +# SEND_RATE_LIMIT_WINDOW_MS=60000 + TENANT_KEY_MAP= TEMPLATE_STORAGE_ACCOUNT= TEMPLATE_STORAGE_CONTAINER=templates diff --git a/apps/api/src/config/app-configuration.ts b/apps/api/src/config/app-configuration.ts index 7ca58a7..484ec9c 100644 --- a/apps/api/src/config/app-configuration.ts +++ b/apps/api/src/config/app-configuration.ts @@ -16,6 +16,11 @@ export const APP_CONFIGURATION_ENVIRONMENT_KEYS: Readonly 'app:email:contactInboxAddress': 'CONTACT_INBOX_ADDRESS', 'app:email:profilesByHost': 'CONTACT_EMAIL_PROFILES_BY_HOST', 'app:email:rateLimitPerMin': 'CONTACT_RATE_LIMIT_PER_MIN', + 'app:email:sendRateLimitPerMin': 'SEND_RATE_LIMIT_PER_MIN', + 'app:email:sendRateLimitWindowMs': 'SEND_RATE_LIMIT_WINDOW_MS', + 'app:email:sendMaxBodyBytes': 'SEND_MAX_BODY_BYTES', + 'app:email:sendMaxVariablesBytes': 'SEND_MAX_VARIABLES_BYTES', + 'app:email:sendMaxVariableValueBytes': 'SEND_MAX_VARIABLE_VALUE_BYTES', 'app:email:forwardEmailBaseUrl': 'FORWARD_EMAIL_BASE_URL', 'app:email:validation:domain': 'EMAIL_VALIDATION_DOMAIN', 'app:email:validation:dkimSelector': 'EMAIL_VALIDATION_DKIM_SELECTOR', diff --git a/apps/api/src/contact-rate-limit.ts b/apps/api/src/contact-rate-limit.ts index b5367ae..50a7502 100644 --- a/apps/api/src/contact-rate-limit.ts +++ b/apps/api/src/contact-rate-limit.ts @@ -1,6 +1,7 @@ import { isIP } from 'node:net'; +import type { TenantContext } from '@singleton-sd/post-kit-types'; -/** In-memory sliding-window limiter for anonymous Contact (PoC). */ +/** In-memory sliding-window limiter for Contact (PoC) and Send endpoints. */ export interface RateLimitResult { allowed: boolean; @@ -56,7 +57,8 @@ export class SlidingWindowRateLimiter { } } -const DEFAULT_MAX = 5; +const DEFAULT_CONTACT_MAX = 5; +const DEFAULT_SEND_MAX = 60; const DEFAULT_WINDOW_MS = 60_000; function parsePositiveInt(raw: string | undefined, fallback: number): number { @@ -68,13 +70,17 @@ function parsePositiveInt(raw: string | undefined, fallback: number): number { /** * Process-local limiter (resets on cold start / scale-out). A shared store * is out of scope for this Y1 PoC; CONTACT_RATE_LIMIT_PER_MIN is best-effort. - * Constructed lazily so App Configuration can populate env first. + * + * A durable limiter would need a shared counter store (e.g. Redis or Azure + * Cache) with atomic increment per tenant key, consistent across scale-out + * instances and cold starts. Constructed lazily so App Configuration can + * populate env first. */ let contactRateLimiter: SlidingWindowRateLimiter | undefined; export function getContactRateLimiter(): SlidingWindowRateLimiter { contactRateLimiter ??= new SlidingWindowRateLimiter( - parsePositiveInt(process.env.CONTACT_RATE_LIMIT_PER_MIN, DEFAULT_MAX), + parsePositiveInt(process.env.CONTACT_RATE_LIMIT_PER_MIN, DEFAULT_CONTACT_MAX), parsePositiveInt(process.env.CONTACT_RATE_LIMIT_WINDOW_MS, DEFAULT_WINDOW_MS), ); return contactRateLimiter; @@ -84,6 +90,30 @@ export function resetContactRateLimiter(): void { contactRateLimiter = undefined; } +/** + * Per-tenant send limiter keyed on `{tenantId}:{environment}`. + * + * Same in-memory / per-instance best-effort semantics as the contact limiter. + * SEND_RATE_LIMIT_PER_MIN defaults to 60 (server-to-server traffic). + */ +let sendRateLimiter: SlidingWindowRateLimiter | undefined; + +export function sendRateLimitKey(tenant: TenantContext): string { + return `${tenant.tenantId}:${tenant.environment}`; +} + +export function getSendRateLimiter(): SlidingWindowRateLimiter { + sendRateLimiter ??= new SlidingWindowRateLimiter( + parsePositiveInt(process.env.SEND_RATE_LIMIT_PER_MIN, DEFAULT_SEND_MAX), + parsePositiveInt(process.env.SEND_RATE_LIMIT_WINDOW_MS, DEFAULT_WINDOW_MS), + ); + return sendRateLimiter; +} + +export function resetSendRateLimiter(): void { + sendRateLimiter = undefined; +} + /** * Host from a forwarded hop. App Service often appends `ipv4:port`; IPv6 * ports use `[addr]:port`. Do not strip the last `:digits` group from bare diff --git a/apps/api/src/functions/send.security.spec.ts b/apps/api/src/functions/send.security.spec.ts index 334de8f..6db2d83 100644 --- a/apps/api/src/functions/send.security.spec.ts +++ b/apps/api/src/functions/send.security.spec.ts @@ -54,14 +54,23 @@ function fakeRequest(options: { headers?: Record; json?: unknown; jsonError?: Error; + text?: string; + textError?: Error; }): HttpRequest { const headers = new Headers(options.headers); + const jsonBody = options.json ?? null; + const textBody = + options.text !== undefined ? options.text : jsonBody === null ? '' : JSON.stringify(jsonBody); return { method: 'POST', headers: { get: (name: string) => headers.get(name) }, json: async () => { if (options.jsonError) throw options.jsonError; - return options.json ?? null; + return jsonBody; + }, + text: async () => { + if (options.textError) throw options.textError; + return textBody; }, } as unknown as HttpRequest; } @@ -484,22 +493,21 @@ describe('sendHandler — malformed and oversized bodies produce stable typed er }); it('returns a typed 400 when the request body exceeds the payload limit', async () => { - // Azure Functions surfaces an oversized body as a rejected `request.json()`. const { response } = await respondTo({ headers: auth, - jsonError: Object.assign(new Error('request entity too large'), { statusCode: 413 }), + text: 'x'.repeat(300_000), }); assert.equal(response.status, 400); - assert.equal(errorCode(response), PostKitErrorCode.INVALID_RECIPIENT); + assert.equal(errorCode(response), PostKitErrorCode.PAYLOAD_TOO_LARGE); }); it('returns a typed 400 for an oversized but well-formed variable payload', async () => { const { response } = await respondTo({ headers: auth, - json: body({ variables: { name: 'a'.repeat(2_000_000), extra: 12 } }), + json: body({ variables: { name: 'a'.repeat(200_000) } }), }); assert.equal(response.status, 400); - assert.equal(errorCode(response), PostKitErrorCode.MISSING_VARIABLES); + assert.equal(errorCode(response), PostKitErrorCode.PAYLOAD_TOO_LARGE); }); const malformedBodies: Array<[label: string, value: unknown, code: PostKitErrorCode]> = [ diff --git a/apps/api/src/functions/send.spec.ts b/apps/api/src/functions/send.spec.ts index 202cacc..f502c5f 100644 --- a/apps/api/src/functions/send.spec.ts +++ b/apps/api/src/functions/send.spec.ts @@ -15,6 +15,8 @@ import { import { TenantResolverError, type TenantResolver } from '../tenant'; import type { ResolvedTenantEmailConfig } from '../tenant/tenant-email-config'; import { TemplateStoreError, type TemplateStore } from '../templates'; +import { resetSendRateLimiter } from '../contact-rate-limit'; +import { resetSendSizeLimitsCache } from '../send-limits'; import { createLogger } from '../telemetry'; import { createSendHandler } from './send'; @@ -41,10 +43,13 @@ const COMPILED: CompiledTemplate = { function fakeRequest(options: { headers?: Record; json?: unknown }): HttpRequest { const headers = new Headers(options.headers); + const jsonBody = options.json ?? null; + const textBody = jsonBody === null ? '' : JSON.stringify(jsonBody); return { method: 'POST', headers: { get: (name: string) => headers.get(name) }, - json: async () => options.json ?? null, + json: async () => jsonBody, + text: async () => textBody, } as unknown as HttpRequest; } @@ -425,6 +430,139 @@ describe('sendHandler', () => { assert.ok(!JSON.stringify(failed).includes('Ada')); }); + it('returns 429 RATE_LIMITED with Retry-After when the tenant exceeds the send limit', async () => { + resetSendRateLimiter(); + process.env.SEND_RATE_LIMIT_PER_MIN = '1'; + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(), + ...stubTenantSender(), + }); + + const body = validBody(); + const first = await handler(fakeRequest({ json: body }), fakeContext()); + assert.equal(first.status, 200); + + const second = await handler(fakeRequest({ json: body }), fakeContext()); + assert.equal(second.status, 429); + assert.equal((second.jsonBody as { code: string }).code, PostKitErrorCode.RATE_LIMITED); + assert.ok(Number((second.headers as Record)['Retry-After']) >= 1); + delete process.env.SEND_RATE_LIMIT_PER_MIN; + resetSendRateLimiter(); + }); + + it('isolates rate limits per tenant', async () => { + resetSendRateLimiter(); + process.env.SEND_RATE_LIMIT_PER_MIN = '1'; + const tenantB: TenantResolver = { + resolve: async () => ({ tenantId: 'other', environment: 'development' }), + }; + const handlerA = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(), + ...stubTenantSender(), + }); + const handlerB = createSendHandler({ + tenantResolver: tenantB, + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(), + ...stubTenantSender(), + }); + + assert.equal((await handlerA(fakeRequest({ json: validBody() }), fakeContext())).status, 200); + assert.equal((await handlerB(fakeRequest({ json: validBody() }), fakeContext())).status, 200); + assert.equal((await handlerA(fakeRequest({ json: validBody() }), fakeContext())).status, 429); + delete process.env.SEND_RATE_LIMIT_PER_MIN; + resetSendRateLimiter(); + }); + + it('rejects oversized request bodies before template load', async () => { + resetSendSizeLimitsCache(); + process.env.SEND_MAX_BODY_BYTES = '50'; + let loaded = false; + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: { + load: async () => { + loaded = true; + return COMPILED; + }, + }, + emailProvider: fakeProvider(), + ...stubTenantSender(), + }); + + const response = await handler( + fakeRequest({ + json: { + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: 'a'.repeat(100) }, + }, + }), + fakeContext(), + ); + + assert.equal(response.status, 400); + assert.equal((response.jsonBody as { code: string }).code, PostKitErrorCode.PAYLOAD_TOO_LARGE); + assert.match((response.jsonBody as { error: string }).error, /50/); + assert.equal(loaded, false); + delete process.env.SEND_MAX_BODY_BYTES; + resetSendSizeLimitsCache(); + }); + + it('rejects oversized variable values with PAYLOAD_TOO_LARGE', async () => { + resetSendSizeLimitsCache(); + process.env.SEND_MAX_VARIABLE_VALUE_BYTES = '10'; + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(), + ...stubTenantSender(), + }); + + const response = await handler( + fakeRequest({ + json: { + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: '12345678901' }, + }, + }), + fakeContext(), + ); + + assert.equal(response.status, 400); + assert.equal((response.jsonBody as { code: string }).code, PostKitErrorCode.PAYLOAD_TOO_LARGE); + assert.match((response.jsonBody as { error: string }).error, /name/); + delete process.env.SEND_MAX_VARIABLE_VALUE_BYTES; + resetSendSizeLimitsCache(); + }); + + it('returns 400 when request.text() fails instead of treating it as an empty body', async () => { + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(), + ...stubTenantSender(), + }); + + const request = { + method: 'POST', + headers: { get: () => null }, + json: async () => validBody(), + text: async () => { + throw new Error('stream failed'); + }, + } as unknown as HttpRequest; + + const response = await handler(request, fakeContext()); + assert.equal(response.status, 400); + assert.match((response.jsonBody as { error: string }).error, /could not be read/i); + }); + it('returns PROVIDER_FAILURE when the provider throws', async () => { const { EmailProviderError } = await import('@singleton-sd/post-kit-email'); const handler = createSendHandler({ diff --git a/apps/api/src/functions/send.tenant-sender.spec.ts b/apps/api/src/functions/send.tenant-sender.spec.ts index cb3d83b..a542676 100644 --- a/apps/api/src/functions/send.tenant-sender.spec.ts +++ b/apps/api/src/functions/send.tenant-sender.spec.ts @@ -9,7 +9,6 @@ import { 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'; @@ -35,10 +34,12 @@ const COMPILED: CompiledTemplate = { }; function fakeRequest(json: unknown): HttpRequest { + const textBody = JSON.stringify(json); return { method: 'POST', headers: { get: () => null }, json: async () => json, + text: async () => textBody, } as unknown as HttpRequest; } @@ -63,6 +64,7 @@ describe('sendHandler — tenant-scoped sender configuration', () => { 'TENANT_PROVIDER_ACCOUNT_SECRETS', 'EMAIL_FROM_ADDRESS', 'EMAIL_FROM_NAME', + 'FORWARD_EMAIL_TOKEN_INKADS', ]; const prior = new Map(); @@ -217,6 +219,7 @@ describe('sendHandler — tenant-scoped sender configuration', () => { }); it('does not expose provider credentials in error responses or logs', async () => { + const { EmailProviderError } = await import('@singleton-sd/post-kit-email'); process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ inkads: { production: { @@ -233,12 +236,18 @@ describe('sendHandler — tenant-scoped sender configuration', () => { const lines: string[] = []; const handler = createSendHandler({ tenantResolver: { resolve: async () => TENANT }, - templateStore: { - load: async () => { - throw new TemplateStoreError('missing', PostKitErrorCode.TEMPLATE_NOT_FOUND); + templateStore: { load: async () => COMPILED }, + emailProvider: { + name: 'development', + isConfigured: () => true, + send: async () => { + throw new EmailProviderError({ + message: 'boom', + kind: 'permanent', + provider: 'development', + }); }, }, - emailProvider: fakeProvider(), createLogger: (correlationId) => createLogger(correlationId, (line) => lines.push(line)), }); @@ -251,6 +260,7 @@ describe('sendHandler — tenant-scoped sender configuration', () => { fakeContext(), ); + assert.equal(response.status, 502); const serialized = JSON.stringify({ body: response.jsonBody, logs: lines }); assert.ok(!serialized.includes('super-secret-token')); assert.ok(!serialized.includes('FORWARD_EMAIL_TOKEN_INKADS')); @@ -259,7 +269,10 @@ describe('sendHandler — tenant-scoped sender configuration', () => { it('does not expose provider account identifiers when provider secret map is malformed', async () => { process.env.TENANT_EMAIL_CONFIG_BY_ID = JSON.stringify({ inkads: { - production: { fromAddress: 'noreply@inkads.example.com' }, + production: { + fromAddress: 'noreply@inkads.example.com', + providerAccount: 'configured-account-id', + }, }, }); process.env.TENANT_PROVIDER_ACCOUNT_SECRETS = JSON.stringify({ diff --git a/apps/api/src/functions/send.ts b/apps/api/src/functions/send.ts index 20ce57a..6bf3f5f 100644 --- a/apps/api/src/functions/send.ts +++ b/apps/api/src/functions/send.ts @@ -16,6 +16,8 @@ import { type TemplateVariables, } from '@singleton-sd/post-kit-types'; import { ensureAppConfiguration } from '../config/app-configuration'; +import { getSendRateLimiter, sendRateLimitKey } from '../contact-rate-limit'; +import { getSendSizeLimits, validateRequestBodySize, validateVariablesSize } from '../send-limits'; import { createLogger, hashRecipient, resolveCorrelationId, type Logger } from '../telemetry'; import { ApiKeyTenantResolver, @@ -124,6 +126,7 @@ export function createSendHandler(deps: SendHandlerDependencies) { failureCategory?: string; providerMessageId?: string; providerRequestId?: string; + retryAfterSec?: number; }, ): HttpResponseInit => { const durationMs = Date.now() - startMs; @@ -138,7 +141,11 @@ export function createSendHandler(deps: SendHandlerDependencies) { ...logContext(), }); const body: PostKitErrorResponse = { error, code, correlationId }; - return { status, headers, jsonBody: body }; + const responseHeaders: Record = { ...headers }; + if (extra?.retryAfterSec !== undefined) { + responseHeaders['Retry-After'] = String(extra.retryAfterSec); + } + return { status, headers: responseHeaders, jsonBody: body }; }; try { @@ -159,8 +166,52 @@ export function createSendHandler(deps: SendHandlerDependencies) { tenantId = tenant.tenantId; environment = tenant.environment; - const body = await request.json().catch(() => null); - const parsed = parseSendRequest(body); + const limit = getSendRateLimiter().tryConsume(sendRateLimitKey(tenant)); + if (!limit.allowed) { + return errorResponse( + 429, + PostKitErrorCode.RATE_LIMITED, + 'Too many send requests for this tenant. Please wait and try again.', + 'failed', + { failureCategory: 'rate_limited', retryAfterSec: limit.retryAfterSec }, + ); + } + + const sizeLimits = getSendSizeLimits(); + let rawBody: string; + try { + rawBody = await request.text(); + } catch { + return errorResponse( + 400, + PostKitErrorCode.INVALID_RECIPIENT, + 'Request body could not be read.', + 'validation_error', + ); + } + const bodySize = validateRequestBodySize(rawBody, sizeLimits); + if (!bodySize.ok) { + return errorResponse( + 400, + PostKitErrorCode.PAYLOAD_TOO_LARGE, + bodySize.error, + 'validation_error', + ); + } + + let body: unknown; + try { + body = rawBody.trim() ? JSON.parse(rawBody) : null; + } catch { + return errorResponse( + 400, + PostKitErrorCode.INVALID_RECIPIENT, + 'Request body must be valid JSON.', + 'validation_error', + ); + } + + const parsed = parseSendRequest(body, sizeLimits); if (!parsed.ok) { if (parsed.templateKey) { templateKey = parsed.templateKey; @@ -309,6 +360,10 @@ function failureCategoryFromErrorCode( return 'invalid_template'; case PostKitErrorCode.INVALID_RECIPIENT: return 'invalid_recipient'; + case PostKitErrorCode.PAYLOAD_TOO_LARGE: + return 'payload_too_large'; + case PostKitErrorCode.RATE_LIMITED: + return 'rate_limited'; case PostKitErrorCode.MISSING_VARIABLES: return 'missing_variables'; case PostKitErrorCode.TEMPLATE_NOT_FOUND: @@ -333,7 +388,10 @@ function isSafeTemplateKey(templateKey: string): boolean { ); } -function parseSendRequest(body: unknown): +function parseSendRequest( + body: unknown, + sizeLimits = getSendSizeLimits(), +): | { ok: true; value: SendRequest } | { ok: false; @@ -397,6 +455,11 @@ function parseSendRequest(body: unknown): variables[key] = value; } + const variablesSize = validateVariablesSize(variables, sizeLimits); + if (!variablesSize.ok) { + return fail(PostKitErrorCode.PAYLOAD_TOO_LARGE, variablesSize.error); + } + return { ok: true, value: { diff --git a/apps/api/src/send-limits.spec.ts b/apps/api/src/send-limits.spec.ts new file mode 100644 index 0000000..a12af20 --- /dev/null +++ b/apps/api/src/send-limits.spec.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { + DEFAULT_SEND_MAX_BODY_BYTES, + DEFAULT_SEND_MAX_VARIABLE_VALUE_BYTES, + DEFAULT_SEND_MAX_VARIABLES_BYTES, + getSendSizeLimits, + resetSendSizeLimitsCache, + validateRequestBodySize, + validateVariablesSize, +} from './send-limits'; + +const touched = [ + 'SEND_MAX_BODY_BYTES', + 'SEND_MAX_VARIABLES_BYTES', + 'SEND_MAX_VARIABLE_VALUE_BYTES', +]; + +function withEnv(keys: string[], run: () => void | Promise): Promise { + const prior = new Map(); + for (const key of keys) { + prior.set(key, process.env[key]); + } + return Promise.resolve() + .then(run) + .finally(() => { + for (const [key, value] of prior) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetSendSizeLimitsCache(); + }); +} + +describe('validateRequestBodySize', () => { + it('accepts a body within the default limit', () => { + const limits = getSendSizeLimits(); + const body = JSON.stringify({ template: 't', to: 'a@b.com', variables: {} }); + const result = validateRequestBodySize(body, limits); + assert.equal(result.ok, true); + }); + + it('rejects a body above the configured maximum with the limit in the message', () => { + const limits = { maxBodyBytes: 10, maxVariablesBytes: 1000, maxVariableValueBytes: 100 }; + const body = 'x'.repeat(11); + const result = validateRequestBodySize(body, limits); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /10/); + } + }); +}); + +describe('validateVariablesSize', () => { + it('accepts variables within default limits', () => { + const limits = getSendSizeLimits(); + const result = validateVariablesSize({ name: 'Ada' }, limits); + assert.equal(result.ok, true); + }); + + it('rejects when total serialized variables exceed the limit', () => { + const limits = { + maxBodyBytes: 1_000_000, + maxVariablesBytes: 20, + maxVariableValueBytes: 1_000, + }; + const result = validateVariablesSize({ name: 'a'.repeat(30) }, limits); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /variables/); + assert.match(result.error, /20/); + } + }); + + it('rejects when a single variable value exceeds the per-value limit', () => { + const limits = { + maxBodyBytes: 1_000_000, + maxVariablesBytes: 1_000_000, + maxVariableValueBytes: 5, + }; + const result = validateVariablesSize({ message: '123456' }, limits); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /message/); + assert.match(result.error, /5/); + } + }); +}); + +describe('getSendSizeLimits', () => { + beforeEach(() => resetSendSizeLimitsCache()); + afterEach(() => resetSendSizeLimitsCache()); + + it('uses documented defaults when env is unset', async () => { + await withEnv(touched, () => { + for (const key of touched) delete process.env[key]; + resetSendSizeLimitsCache(); + const limits = getSendSizeLimits(); + assert.equal(limits.maxBodyBytes, DEFAULT_SEND_MAX_BODY_BYTES); + assert.equal(limits.maxVariablesBytes, DEFAULT_SEND_MAX_VARIABLES_BYTES); + assert.equal(limits.maxVariableValueBytes, DEFAULT_SEND_MAX_VARIABLE_VALUE_BYTES); + }); + }); + + it('reads overrides from environment variables', async () => { + await withEnv(touched, () => { + process.env.SEND_MAX_BODY_BYTES = '4096'; + process.env.SEND_MAX_VARIABLES_BYTES = '2048'; + process.env.SEND_MAX_VARIABLE_VALUE_BYTES = '512'; + resetSendSizeLimitsCache(); + const limits = getSendSizeLimits(); + assert.equal(limits.maxBodyBytes, 4096); + assert.equal(limits.maxVariablesBytes, 2048); + assert.equal(limits.maxVariableValueBytes, 512); + }); + }); +}); diff --git a/apps/api/src/send-limits.ts b/apps/api/src/send-limits.ts new file mode 100644 index 0000000..c84ef54 --- /dev/null +++ b/apps/api/src/send-limits.ts @@ -0,0 +1,89 @@ +/** + * Request size limits for `POST /emails/send`. + * + * Defaults (override via SEND_MAX_* env vars / App Configuration): + * SEND_MAX_BODY_BYTES — 256 KiB (262_144) + * SEND_MAX_VARIABLES_BYTES — 128 KiB (131_072) total JSON size of `variables` + * SEND_MAX_VARIABLE_VALUE_BYTES — 32 KiB (32_768) per variable value + */ + +export const DEFAULT_SEND_MAX_BODY_BYTES = 262_144; +export const DEFAULT_SEND_MAX_VARIABLES_BYTES = 131_072; +export const DEFAULT_SEND_MAX_VARIABLE_VALUE_BYTES = 32_768; + +export interface SendSizeLimits { + maxBodyBytes: number; + maxVariablesBytes: number; + maxVariableValueBytes: number; +} + +function parsePositiveInt(raw: string | undefined, fallback: number): number { + if (!raw?.trim()) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function utf8ByteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +let cachedLimits: SendSizeLimits | undefined; + +export function getSendSizeLimits(): SendSizeLimits { + cachedLimits ??= { + maxBodyBytes: parsePositiveInt(process.env.SEND_MAX_BODY_BYTES, DEFAULT_SEND_MAX_BODY_BYTES), + maxVariablesBytes: parsePositiveInt( + process.env.SEND_MAX_VARIABLES_BYTES, + DEFAULT_SEND_MAX_VARIABLES_BYTES, + ), + maxVariableValueBytes: parsePositiveInt( + process.env.SEND_MAX_VARIABLE_VALUE_BYTES, + DEFAULT_SEND_MAX_VARIABLE_VALUE_BYTES, + ), + }; + return cachedLimits; +} + +export function resetSendSizeLimitsCache(): void { + cachedLimits = undefined; +} + +export function validateRequestBodySize( + rawBody: string, + limits: SendSizeLimits = getSendSizeLimits(), +): { ok: true } | { ok: false; error: string } { + const bytes = utf8ByteLength(rawBody); + if (bytes > limits.maxBodyBytes) { + return { + ok: false, + error: `Request body exceeds the maximum size of ${limits.maxBodyBytes} bytes.`, + }; + } + return { ok: true }; +} + +export function validateVariablesSize( + variables: Record, + limits: SendSizeLimits = getSendSizeLimits(), +): { ok: true } | { ok: false; error: string } { + for (const [key, value] of Object.entries(variables)) { + const valueBytes = utf8ByteLength(value); + if (valueBytes > limits.maxVariableValueBytes) { + return { + ok: false, + error: `variables.${key} exceeds the maximum value length of ${limits.maxVariableValueBytes} bytes.`, + }; + } + } + + const serialized = JSON.stringify(variables); + const totalBytes = utf8ByteLength(serialized); + if (totalBytes > limits.maxVariablesBytes) { + return { + ok: false, + error: `variables exceed the maximum total size of ${limits.maxVariablesBytes} bytes.`, + }; + } + + return { ok: true }; +} diff --git a/packages/post-kit-types/src/index.spec.ts b/packages/post-kit-types/src/index.spec.ts index f1a7176..45c9ea1 100644 --- a/packages/post-kit-types/src/index.spec.ts +++ b/packages/post-kit-types/src/index.spec.ts @@ -172,16 +172,18 @@ describe('PostKitErrorCode', () => { assert.equal(PostKitErrorCode.INVALID_TEMPLATE, 'INVALID_TEMPLATE'); assert.equal(PostKitErrorCode.MISSING_VARIABLES, 'MISSING_VARIABLES'); assert.equal(PostKitErrorCode.INVALID_RECIPIENT, 'INVALID_RECIPIENT'); + assert.equal(PostKitErrorCode.PAYLOAD_TOO_LARGE, 'PAYLOAD_TOO_LARGE'); + assert.equal(PostKitErrorCode.RATE_LIMITED, 'RATE_LIMITED'); 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 9 codes', () => { + it('has exactly 11 codes', () => { const codes = Object.keys(PostKitErrorCode).filter( (k) => typeof PostKitErrorCode[k as keyof typeof PostKitErrorCode] === 'string', ); - assert.equal(codes.length, 9); + assert.equal(codes.length, 11); }); }); diff --git a/packages/post-kit-types/src/send.ts b/packages/post-kit-types/src/send.ts index a1e0dfe..969387a 100644 --- a/packages/post-kit-types/src/send.ts +++ b/packages/post-kit-types/src/send.ts @@ -26,6 +26,10 @@ export enum PostKitErrorCode { MISSING_VARIABLES = 'MISSING_VARIABLES', /** The `to` recipient address failed basic validation. */ INVALID_RECIPIENT = 'INVALID_RECIPIENT', + /** The request body or variables exceed configured size limits. */ + PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE', + /** The authenticated tenant exceeded the send rate limit. */ + RATE_LIMITED = 'RATE_LIMITED', /** The email provider accepted the request but returned an error or unexpected response. */ PROVIDER_FAILURE = 'PROVIDER_FAILURE', /** The template storage backend returned an error. */