Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions apps/api/src/config/app-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export const APP_CONFIGURATION_ENVIRONMENT_KEYS: Readonly<Record<string, string>
'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',
Expand Down
38 changes: 34 additions & 4 deletions apps/api/src/contact-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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
Expand Down
20 changes: 14 additions & 6 deletions apps/api/src/functions/send.security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,23 @@ function fakeRequest(options: {
headers?: Record<string, string>;
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;
}
Expand Down Expand Up @@ -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]> = [
Expand Down
118 changes: 117 additions & 1 deletion apps/api/src/functions/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -40,10 +42,13 @@ const COMPILED: CompiledTemplate = {

function fakeRequest(options: { headers?: Record<string, string>; 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;
}

Expand Down Expand Up @@ -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<string, string>)['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({
Expand Down
61 changes: 57 additions & 4 deletions apps/api/src/functions/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -117,6 +119,7 @@ export function createSendHandler(deps: SendHandlerDependencies) {
failureCategory?: string;
providerMessageId?: string;
providerRequestId?: string;
retryAfterSec?: number;
},
): HttpResponseInit => {
const durationMs = Date.now() - startMs;
Expand All @@ -131,7 +134,11 @@ export function createSendHandler(deps: SendHandlerDependencies) {
...logContext(),
});
const body: PostKitErrorResponse = { error, code, correlationId };
return { status, headers, jsonBody: body };
const responseHeaders: Record<string, string> = { ...headers };
if (extra?.retryAfterSec !== undefined) {
responseHeaders['Retry-After'] = String(extra.retryAfterSec);
}
return { status, headers: responseHeaders, jsonBody: body };
};

try {
Expand All @@ -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;
Expand Down Expand Up @@ -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:
Expand All @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading