From 4585c192cb676d33ffbe9f1e8215e06b4f20dcbb Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Tue, 25 Aug 2026 00:45:03 +1000 Subject: [PATCH 1/2] feat(api): add POST /emails/send template endpoint Wire TenantResolver, TemplateStore, Handlebars rendering, and EmailProvider with injectable deps and PostKitErrorResponse telemetry. Co-authored-by: Cursor --- apps/api/package.json | 3 +- apps/api/src/functions/send.spec.ts | 256 +++++++++++++++++++++++ apps/api/src/functions/send.ts | 312 ++++++++++++++++++++++++++++ apps/api/src/index.ts | 1 + pnpm-lock.yaml | 3 + 5 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/functions/send.spec.ts create mode 100644 apps/api/src/functions/send.ts diff --git a/apps/api/package.json b/apps/api/package.json index aa000bc..1b47c1e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,7 +16,8 @@ "@azure/keyvault-secrets": "^4.11.2", "@azure/storage-blob": "^12.33.0", "@singleton-sd/post-kit-email": "workspace:*", - "@singleton-sd/post-kit-types": "workspace:^" + "@singleton-sd/post-kit-types": "workspace:^", + "handlebars": "^4.7.8" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/apps/api/src/functions/send.spec.ts b/apps/api/src/functions/send.spec.ts new file mode 100644 index 0000000..36ad799 --- /dev/null +++ b/apps/api/src/functions/send.spec.ts @@ -0,0 +1,256 @@ +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 { TenantResolverError, type TenantResolver } from '../tenant'; +import { TemplateStoreError, type TemplateStore } from '../templates'; +import { createSendHandler } from './send'; + +const TENANT: TenantContext = { tenantId: 'inkads', environment: 'development' }; + +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(options: { headers?: Record; json?: unknown }): HttpRequest { + const headers = new Headers(options.headers); + return { + method: 'POST', + headers: { get: (name: string) => headers.get(name) }, + json: async () => options.json ?? null, + } as unknown as HttpRequest; +} + +function fakeContext(): InvocationContext { + return { error: () => undefined } as unknown as InvocationContext; +} + +function fakeResolver(ok = true): TenantResolver { + return { + resolve: async () => { + if (!ok) { + throw new TenantResolverError('missing', PostKitErrorCode.UNAUTHENTICATED); + } + return TENANT; + }, + }; +} + +function fakeStore(result: CompiledTemplate | TemplateStoreError): TemplateStore { + return { + load: async () => { + if (result instanceof TemplateStoreError) throw result; + return result; + }, + }; +} + +function fakeProvider(capture?: EmailSendRequest[]): EmailProvider { + return { + name: 'development', + isConfigured: () => true, + send: async (request): Promise => { + capture?.push(request); + return { providerMessageId: 'msg-1', accepted: true }; + }, + }; +} + +describe('sendHandler', () => { + it('sends a template email and returns SendResponse', async () => { + const sent: EmailSendRequest[] = []; + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(sent), + fromAddress: () => 'noreply@example.com', + fromName: () => 'PostKit', + }); + + const response = await handler( + fakeRequest({ + headers: { authorization: 'Bearer tok', 'x-correlation-id': 'corr-send-01' }, + json: { + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: 'Ada' }, + }, + }), + fakeContext(), + ); + + assert.equal(response.status, 200); + assert.deepEqual(response.jsonBody, { id: 'corr-send-01', status: 'sent' }); + assert.equal(sent[0]?.subject, 'Hi Ada'); + assert.equal(sent[0]?.html, '

Hello Ada

'); + }); + + it('HTML-escapes variables in the body', async () => { + const sent: EmailSendRequest[] = []; + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(sent), + fromAddress: () => 'noreply@example.com', + }); + + await handler( + fakeRequest({ + json: { + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: '' }, + }, + }), + fakeContext(), + ); + + assert.ok(sent[0]?.html?.includes('<script>')); + assert.ok(!sent[0]?.html?.includes('