diff --git a/.env.example b/.env.example index 96537c5..55bec8e 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,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 d23a07e..6b50922 100644 --- a/apps/api/src/config/app-configuration.ts +++ b/apps/api/src/config/app-configuration.ts @@ -14,6 +14,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 a855e3d..2e9412c 100644 --- a/apps/api/src/functions/send.security.spec.ts +++ b/apps/api/src/functions/send.security.spec.ts @@ -53,14 +53,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; } @@ -472,22 +481,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 adcfe28..c173e2c 100644 --- a/apps/api/src/functions/send.spec.ts +++ b/apps/api/src/functions/send.spec.ts @@ -14,6 +14,8 @@ import { } from '@singleton-sd/post-kit-types'; import { TenantResolverError, type TenantResolver } from '../tenant'; 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'; @@ -40,10 +42,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; } @@ -414,6 +419,117 @@ 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(), + fromAddress: () => 'noreply@example.com', + }); + + 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(), + fromAddress: () => 'noreply@example.com', + }); + const handlerB = createSendHandler({ + tenantResolver: tenantB, + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(), + fromAddress: () => 'noreply@example.com', + }); + + 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(), + fromAddress: () => 'noreply@example.com', + }); + + 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(), + fromAddress: () => 'noreply@example.com', + }); + + 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 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.ts b/apps/api/src/functions/send.ts index 8d5c339..9495009 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, @@ -117,6 +119,7 @@ export function createSendHandler(deps: SendHandlerDependencies) { failureCategory?: string; providerMessageId?: string; providerRequestId?: string; + retryAfterSec?: number; }, ): HttpResponseInit => { const durationMs = Date.now() - startMs; @@ -131,7 +134,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 { @@ -152,8 +159,42 @@ 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(); + const rawBody = await request.text().catch(() => ''); + 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; @@ -298,6 +339,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: @@ -320,7 +365,10 @@ function isSafeTemplateKey(templateKey: string): boolean { ); } -function parseSendRequest(body: unknown): +function parseSendRequest( + body: unknown, + sizeLimits = getSendSizeLimits(), +): | { ok: true; value: SendRequest } | { ok: false; @@ -384,6 +432,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..b85d6e9 --- /dev/null +++ b/apps/api/src/send-limits.spec.ts @@ -0,0 +1,115 @@ +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, () => { + 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 ec8a633..fda2f58 100644 --- a/packages/post-kit-types/src/index.spec.ts +++ b/packages/post-kit-types/src/index.spec.ts @@ -172,15 +172,17 @@ 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'); }); - it('has exactly 8 codes', () => { + it('has exactly 10 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, 10); }); }); diff --git a/packages/post-kit-types/src/send.ts b/packages/post-kit-types/src/send.ts index 920ae09..1a90838 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. */