Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ ORIGINS=
TENANT_KEY_MAP=
TEMPLATE_STORAGE_ACCOUNT=
TEMPLATE_STORAGE_CONTAINER=templates
# Send idempotency ledger (defaults shown). Same account as templates is fine.
# IDEMPOTENCY_STORAGE_ACCOUNT= # falls back to TEMPLATE_STORAGE_ACCOUNT
# IDEMPOTENCY_STORAGE_CONTAINER=idempotency
# IDEMPOTENCY_TTL_MS=86400000
3 changes: 3 additions & 0 deletions apps/api/src/config/app-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export const APP_CONFIGURATION_ENVIRONMENT_KEYS: Readonly<Record<string, string>
'app:email:validation:requireBimiSvg': 'EMAIL_VALIDATION_REQUIRE_BIMI_SVG',
'app:templates:storageAccount': 'TEMPLATE_STORAGE_ACCOUNT',
'app:templates:storageContainer': 'TEMPLATE_STORAGE_CONTAINER',
'app:idempotency:storageAccount': 'IDEMPOTENCY_STORAGE_ACCOUNT',
'app:idempotency:storageContainer': 'IDEMPOTENCY_STORAGE_CONTAINER',
'app:idempotency:ttlMs': 'IDEMPOTENCY_TTL_MS',
'secret:forwardemail-api-key': 'FORWARD_EMAIL_TOKEN',
};

Expand Down
319 changes: 319 additions & 0 deletions apps/api/src/functions/send.idempotency.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { HttpRequest, InvocationContext } from '@azure/functions';
import type {
EmailProvider,
EmailSendRequest,
EmailSendResult,
} from '@singleton-sd/post-kit-email';
import {
PostKitErrorCode,
TEMPLATE_SCHEMA_VERSION,
type CompiledTemplate,
type TenantContext,
} from '@singleton-sd/post-kit-types';
import { MemoryIdempotencyStore } from '../idempotency';
import { resetSendRateLimiter } from '../contact-rate-limit';
import type { TenantResolver } from '../tenant';
import type { TemplateStore } from '../templates';
import { createSendHandler } from './send';

const TENANT: TenantContext = { tenantId: 'inkads', environment: 'development' };

const COMPILED: CompiledTemplate = {
templateHtml: '<p>Hello {{name}}</p>',
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(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 () => jsonBody,
text: async () => textBody,
} as unknown as HttpRequest;
}

function fakeContext(): InvocationContext {
return { error: () => undefined } as unknown as InvocationContext;
}

function fakeResolver(tenant: TenantContext = TENANT): TenantResolver {
return { resolve: async () => tenant };
}

function fakeStore(): TemplateStore {
return { load: async () => COMPILED };
}

function fakeProvider(capture?: EmailSendRequest[]): EmailProvider {
return {
name: 'development',
isConfigured: () => true,
send: async (request): Promise<EmailSendResult> => {
capture?.push(request);
return { providerMessageId: 'msg-1', accepted: true };
},
};
}

function stubSender() {
return {
resolveTenantEmailConfig: async () => ({
fromAddress: 'noreply@example.com',
fromDisplayName: 'PostKit',
}),
};
}

function validBody() {
return {
template: 'marketing.contact-us',
to: 'user@example.com',
variables: { name: 'Ada' },
};
}

describe('sendHandler idempotency', () => {
it('without Idempotency-Key keeps at-least-once behaviour (provider called each time)', async () => {
resetSendRateLimiter();
const sent: EmailSendRequest[] = [];
const handler = createSendHandler({
tenantResolver: fakeResolver(),
templateStore: fakeStore(),
emailProvider: fakeProvider(sent),
idempotencyStore: new MemoryIdempotencyStore(),
...stubSender(),
});

assert.equal((await handler(fakeRequest({ json: validBody() }), fakeContext())).status, 200);
assert.equal((await handler(fakeRequest({ json: validBody() }), fakeContext())).status, 200);
assert.equal(sent.length, 2);
});

it('replays a completed request without a second provider call', async () => {
resetSendRateLimiter();
const sent: EmailSendRequest[] = [];
const store = new MemoryIdempotencyStore();
const handler = createSendHandler({
tenantResolver: fakeResolver(),
templateStore: fakeStore(),
emailProvider: fakeProvider(sent),
idempotencyStore: store,
...stubSender(),
});

const first = await handler(
fakeRequest({
headers: {
authorization: 'Bearer tok',
'idempotency-key': 'retry-abc',
'x-correlation-id': 'corr-first-01',
},
json: validBody(),
}),
fakeContext(),
);
assert.equal(first.status, 200);
assert.deepEqual(first.jsonBody, { id: 'corr-first-01', status: 'sent' });
assert.equal(first.headers?.['X-Correlation-Id'], 'corr-first-01');
assert.equal(sent.length, 1);

const second = await handler(
fakeRequest({
headers: {
authorization: 'Bearer tok',
'idempotency-key': 'retry-abc',
'x-correlation-id': 'corr-second-02',
},
json: validBody(),
}),
fakeContext(),
);
assert.equal(second.status, 200);
assert.deepEqual(second.jsonBody, { id: 'corr-first-01', status: 'sent' });
assert.equal(second.headers?.['X-Correlation-Id'], 'corr-second-02');
assert.equal(sent.length, 1);
});

it('returns IDEMPOTENCY_IN_PROGRESS while the first request is in flight', async () => {
resetSendRateLimiter();
const store = new MemoryIdempotencyStore();
let releaseSend!: () => void;
const sendGate = new Promise<void>((resolve) => {
releaseSend = resolve;
});
let sendEntered!: () => void;
const sendStarted = new Promise<void>((resolve) => {
sendEntered = resolve;
});

const handler = createSendHandler({
tenantResolver: fakeResolver(),
templateStore: fakeStore(),
emailProvider: {
name: 'development',
isConfigured: () => true,
send: async () => {
sendEntered();
await sendGate;
return { providerMessageId: 'msg-1', accepted: true };
},
},
idempotencyStore: store,
...stubSender(),
});

const firstPromise = handler(
fakeRequest({
headers: { 'idempotency-key': 'inflight-1', 'x-correlation-id': 'corr-inflight-a' },
json: validBody(),
}),
fakeContext(),
);

// The first handler has claimed the key once provider.send is entered.
await sendStarted;

const concurrent = await handler(
fakeRequest({
headers: { 'idempotency-key': 'inflight-1', 'x-correlation-id': 'corr-inflight-b' },
json: validBody(),
}),
fakeContext(),
);

assert.equal(concurrent.status, 409);
assert.equal(
(concurrent.jsonBody as { code: string }).code,
PostKitErrorCode.IDEMPOTENCY_IN_PROGRESS,
);

releaseSend();
const first = await firstPromise;
assert.equal(first.status, 200);
});

it('returns 503 when completion fails after a successful provider send', async () => {
resetSendRateLimiter();
const sent: EmailSendRequest[] = [];
const handler = createSendHandler({
tenantResolver: fakeResolver(),
templateStore: fakeStore(),
emailProvider: fakeProvider(sent),
idempotencyStore: {
begin: async () => ({ outcome: 'claimed', claimToken: 'tok-1' }),
complete: async () => {
throw new Error('blob write failed');
},
release: async () => undefined,
},
...stubSender(),
});

const response = await handler(
fakeRequest({
headers: { 'idempotency-key': 'complete-fail', 'x-correlation-id': 'corr-complete-fail' },
json: validBody(),
}),
fakeContext(),
);

assert.equal(response.status, 503);
assert.equal(sent.length, 1);
assert.match(
(response.jsonBody as { error: string }).error,
/idempotency record could not be saved/i,
);
});

it('treats the same key from a different tenant as a distinct request', async () => {
resetSendRateLimiter();
const sent: EmailSendRequest[] = [];
const store = new MemoryIdempotencyStore();
const other: TenantContext = { tenantId: 'other', environment: 'development' };

const handlerA = createSendHandler({
tenantResolver: fakeResolver(TENANT),
templateStore: fakeStore(),
emailProvider: fakeProvider(sent),
idempotencyStore: store,
...stubSender(),
});
const handlerB = createSendHandler({
tenantResolver: fakeResolver(other),
templateStore: fakeStore(),
emailProvider: fakeProvider(sent),
idempotencyStore: store,
...stubSender(),
});

assert.equal(
(
await handlerA(
fakeRequest({ headers: { 'idempotency-key': 'shared-key' }, json: validBody() }),
fakeContext(),
)
).status,
200,
);
assert.equal(
(
await handlerB(
fakeRequest({ headers: { 'idempotency-key': 'shared-key' }, json: validBody() }),
fakeContext(),
)
).status,
200,
);
assert.equal(sent.length, 2);
});

it('rejects unsafe Idempotency-Key values before touching the store', async () => {
resetSendRateLimiter();
let beginCalled = false;
const handler = createSendHandler({
tenantResolver: fakeResolver(),
templateStore: fakeStore(),
emailProvider: fakeProvider(),
idempotencyStore: {
begin: async () => {
beginCalled = true;
return { outcome: 'claimed', claimToken: 'tok' };
},
complete: async () => undefined,
release: async () => undefined,
},
...stubSender(),
});

const response = await handler(
fakeRequest({
headers: { 'idempotency-key': 'bad key with spaces' },
json: validBody(),
}),
fakeContext(),
);

assert.equal(response.status, 400);
assert.match((response.jsonBody as { error: string }).error, /invalid characters/i);
assert.equal(beginCalled, false);
});
});
Loading
Loading