From 48158afdeb36353e556ef5bae6c037161e7b048e Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 16:53:12 +1000 Subject: [PATCH 1/2] feat(examples): add marketing waitlist signup example Cover epic #7 scenario 3 with a server-only confirmation handler that sends to the signup address, plus template seed and public-forms links. Closes #136 Closes #7 Co-authored-by: Cursor --- docs/guides/public-forms.md | 11 +- examples/marketing-contact-us/README.md | 10 +- examples/marketing-waitlist/README.md | 66 +++++++++ .../marketing.waitlist-confirm/metadata.json | 7 + .../marketing.waitlist-confirm/preview.json | 4 + .../marketing.waitlist-confirm/template.json | 36 +++++ examples/marketing-waitlist/package.json | 21 +++ .../src/waitlist-handler.spec.ts | 138 ++++++++++++++++++ .../src/waitlist-handler.ts | 110 ++++++++++++++ examples/marketing-waitlist/tsconfig.json | 15 ++ .../marketing-waitlist/tsconfig.spec.json | 8 + pnpm-lock.yaml | 16 ++ 12 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 examples/marketing-waitlist/README.md create mode 100644 examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/metadata.json create mode 100644 examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/preview.json create mode 100644 examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/template.json create mode 100644 examples/marketing-waitlist/package.json create mode 100644 examples/marketing-waitlist/src/waitlist-handler.spec.ts create mode 100644 examples/marketing-waitlist/src/waitlist-handler.ts create mode 100644 examples/marketing-waitlist/tsconfig.json create mode 100644 examples/marketing-waitlist/tsconfig.spec.json diff --git a/docs/guides/public-forms.md b/docs/guides/public-forms.md index 20caa9e..5ea43c6 100644 --- a/docs/guides/public-forms.md +++ b/docs/guides/public-forms.md @@ -9,7 +9,12 @@ This is the reference pattern for every public-form integration: marketing-site double-opt-in. They are all the same shape — an anonymous visitor submits data, and a trusted server component turns that into a templated email. -Worked example: [`examples/marketing-contact-us/`](../../examples/marketing-contact-us/). +Worked examples: + +- Contact Us (inbox recipient): + [`examples/marketing-contact-us/`](../../examples/marketing-contact-us/) +- Waitlist signup (confirmation to the submitted address): + [`examples/marketing-waitlist/`](../../examples/marketing-waitlist/) ## The required topology @@ -254,4 +259,6 @@ which template keys exist and whether your credential is still valid. - [`docs/email-forward-email.md`](../email-forward-email.md) — provider runtime, DNS, and the existing `POST /contact` Function. - [`examples/marketing-contact-us/`](../../examples/marketing-contact-us/) — - runnable handler and specs for this pattern. + runnable Contact Us handler and specs. +- [`examples/marketing-waitlist/`](../../examples/marketing-waitlist/) — + waitlist confirmation handler (recipient = signup email). diff --git a/examples/marketing-contact-us/README.md b/examples/marketing-contact-us/README.md index 30b457e..7024057 100644 --- a/examples/marketing-contact-us/README.md +++ b/examples/marketing-contact-us/README.md @@ -33,12 +33,10 @@ The security properties it enforces: ## Waitlist / whitelist signup -The same handler shape covers waitlist signup — swap the template key and the -variables. The one difference is that the confirmation goes *to the submitted -address*, so `to` comes from user input. That is the only case where it should, -and it needs the extra controls listed in the guide (strict validation, per -address rate limiting, one send per submission, a dedicated template). Never -accept a list of recipients. +See the dedicated example +[`examples/marketing-waitlist/`](../marketing-waitlist/) — same topology, but +confirmation goes *to the submitted address*. Never accept a list of +recipients. ## Run the specs diff --git a/examples/marketing-waitlist/README.md b/examples/marketing-waitlist/README.md new file mode 100644 index 0000000..9b146f1 --- /dev/null +++ b/examples/marketing-waitlist/README.md @@ -0,0 +1,66 @@ +# Example: marketing-site waitlist signup + +A public form collects an email (optional name) and sends a **confirmation to +the signup address** through PostKit — without any PostKit credential in +browser code. + +Full rationale: [`docs/guides/public-forms.md`](../../docs/guides/public-forms.md). +Contact Us (inbox recipient) is +[`examples/marketing-contact-us/`](../marketing-contact-us/). + +This package is private and is never published. + +## The pattern + +```text +browser form ──POST email (+ name)──▶ YOUR server endpoint ──PostKitClient──▶ PostKit API +(no credential) (holds POSTKIT_API_KEY; to = signup email) +``` + +`src/waitlist-handler.ts` is the middle box, minus the framework. + +| Property | How | +| --- | --- | +| Credential stays server-side | Injected `PostKitClient` only | +| Template key is server-owned | `WAITLIST_TEMPLATE_KEY` (`marketing.waitlist-confirm`) | +| Recipient is the signup email | Validated `email` becomes `to` — the **only** case where user input reaches `to` | +| Caller `template` / `to` / `from` / `subject` ignored | Built from scratch | +| Name optional | Empty → display fallback `"there"` | + +### Host duties (not implemented here) + +Production must still: captcha / abuse mitigation, rate-limit per IP **and** +per address, one confirmation per submission, dedicated published template. + +## Run the specs + +```bash +pnpm --filter @singleton-sd/example-marketing-waitlist test +``` + +## Wiring sketch + +```ts +import { PostKitClient } from '@singleton-sd/post-kit-client'; +import { handleWaitlistSignup } from './waitlist-handler'; + +const client = new PostKitClient({ + endpoint: process.env.POSTKIT_ENDPOINT!, + apiKey: process.env.POSTKIT_API_KEY!, +}); + +export async function POST(request: Request): Promise { + // captcha + rate limits first + const submission = await request.json().catch(() => null); + const result = await handleWaitlistSignup(submission, { + client, + logError: (event, detail) => console.error(event, detail), + }); + return Response.json(result.body, { status: result.status }); +} +``` + +## Template source + +`content/email-templates/marketing.waitlist-confirm/` — publish with +`post-kit-publish` before sends succeed. diff --git a/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/metadata.json b/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/metadata.json new file mode 100644 index 0000000..e857b05 --- /dev/null +++ b/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/metadata.json @@ -0,0 +1,7 @@ +{ + "key": "marketing.waitlist-confirm", + "name": "Waitlist confirmation", + "subject": "You're on the list, {{name}}", + "variables": ["name", "email"], + "schemaVersion": "1" +} diff --git a/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/preview.json b/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/preview.json new file mode 100644 index 0000000..3813566 --- /dev/null +++ b/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/preview.json @@ -0,0 +1,4 @@ +{ + "name": "Jane Doe", + "email": "jane@example.com" +} diff --git a/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/template.json b/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/template.json new file mode 100644 index 0000000..e0b944f --- /dev/null +++ b/examples/marketing-waitlist/content/email-templates/marketing.waitlist-confirm/template.json @@ -0,0 +1,36 @@ +{ + "root": { + "type": "EmailLayout", + "data": { + "backdropColor": "#F8F8F8", + "canvasColor": "#FFFFFF", + "textColor": "#242424", + "fontFamily": "MODERN_SANS", + "childrenIds": ["block-heading", "block-body"] + } + }, + "block-heading": { + "type": "Text", + "data": { + "style": { + "fontWeight": "bold", + "padding": { "top": 24, "bottom": 8, "right": 24, "left": 24 } + }, + "props": { + "text": "You're on the waitlist" + } + } + }, + "block-body": { + "type": "Text", + "data": { + "style": { + "fontWeight": "normal", + "padding": { "top": 0, "bottom": 24, "right": 24, "left": 24 } + }, + "props": { + "text": "Hi {{name}}, we saved {{email}} and will email you when a spot opens." + } + } + } +} diff --git a/examples/marketing-waitlist/package.json b/examples/marketing-waitlist/package.json new file mode 100644 index 0000000..4b67fcf --- /dev/null +++ b/examples/marketing-waitlist/package.json @@ -0,0 +1,21 @@ +{ + "name": "@singleton-sd/example-marketing-waitlist", + "version": "0.0.0", + "private": true, + "description": "Example: public waitlist signup confirmation through a trusted server endpoint", + "license": "MIT", + "scripts": { + "test": "pnpm --filter @singleton-sd/post-kit-client run build && tsc -p tsconfig.spec.json && node --import tsx --test src/waitlist-handler.spec.ts" + }, + "devDependencies": { + "@types/node": "^20.17.9", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "dependencies": { + "@singleton-sd/post-kit-client": "workspace:*" + }, + "engines": { + "node": ">=20.18.1" + } +} diff --git a/examples/marketing-waitlist/src/waitlist-handler.spec.ts b/examples/marketing-waitlist/src/waitlist-handler.spec.ts new file mode 100644 index 0000000..3973059 --- /dev/null +++ b/examples/marketing-waitlist/src/waitlist-handler.spec.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PostKitClient } from '@singleton-sd/post-kit-client'; +import { WAITLIST_TEMPLATE_KEY, handleWaitlistSignup, LIMITS } from './waitlist-handler'; + +interface RecordedCall { + url: string; + headers: Record; + body: Record; +} + +function createHarness( + respond: (call: RecordedCall) => Response = () => + new Response(JSON.stringify({ id: 'corr-1', status: 'sent' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), +): { client: PostKitClient; calls: RecordedCall[] } { + const calls: RecordedCall[] = []; + const fetchMock: typeof globalThis.fetch = async (input, init) => { + const call: RecordedCall = { + url: String(input), + headers: (init?.headers ?? {}) as Record, + body: JSON.parse(String(init?.body ?? '{}')) as Record, + }; + calls.push(call); + return respond(call); + }; + + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'test-key-not-a-real-credential', + fetch: fetchMock, + }); + + return { client, calls }; +} + +const validSubmission = { + name: 'Jane Doe', + email: 'jane@example.com', +}; + +test('valid signup sends confirmation to the submitted address', async () => { + const { client, calls } = createHarness(); + + const result = await handleWaitlistSignup(validSubmission, { client }); + + assert.deepEqual(result, { status: 202, body: { status: 'accepted' } }); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.url, 'https://postkit.example.com/emails/send'); + assert.deepEqual(calls[0]?.body, { + template: WAITLIST_TEMPLATE_KEY, + to: 'jane@example.com', + variables: { email: 'jane@example.com', name: 'Jane Doe' }, + }); +}); + +test('caller-supplied template/to/from/subject are ignored; to stays the signup email', async () => { + const { client, calls } = createHarness(); + + const result = await handleWaitlistSignup( + { + ...validSubmission, + template: 'billing.invoice-paid', + to: 'attacker@example.com', + cc: 'attacker@example.com', + from: 'spoofed@example.com', + subject: 'Spoofed', + }, + { client }, + ); + + assert.equal(result.status, 202); + assert.deepEqual(calls[0]?.body, { + template: WAITLIST_TEMPLATE_KEY, + to: 'jane@example.com', + variables: { email: 'jane@example.com', name: 'Jane Doe' }, + }); +}); + +test('omitted name uses a safe display fallback', async () => { + const { client, calls } = createHarness(); + + const result = await handleWaitlistSignup({ email: 'solo@example.com' }, { client }); + + assert.equal(result.status, 202); + assert.deepEqual(calls[0]?.body, { + template: WAITLIST_TEMPLATE_KEY, + to: 'solo@example.com', + variables: { email: 'solo@example.com', name: 'there' }, + }); +}); + +test('invalid submissions are rejected before any send', async () => { + const cases: Array<{ label: string; input: unknown; field: string }> = [ + { label: 'not an object', input: 'email=x', field: 'body' }, + { label: 'array body', input: [validSubmission], field: 'body' }, + { label: 'missing email', input: { name: 'Jane' }, field: 'email' }, + { + label: 'malformed email', + input: { email: 'jane(at)example' }, + field: 'email', + }, + { + label: 'oversized name', + input: { email: 'jane@example.com', name: 'a'.repeat(LIMITS.nameMax + 1) }, + field: 'name', + }, + ]; + + for (const c of cases) { + const { client, calls } = createHarness(); + const result = await handleWaitlistSignup(c.input, { client }); + assert.equal(result.status, 400, c.label); + if (result.status === 400) { + assert.equal(result.body.field, c.field, c.label); + } + assert.equal(calls.length, 0, c.label); + } +}); + +test('PostKit failures become a generic 502', async () => { + const logs: Array<{ event: string; detail: Record }> = []; + const { client } = createHarness(() => new Response('nope', { status: 503 })); + + const result = await handleWaitlistSignup(validSubmission, { + client, + logError: (event, detail) => logs.push({ event, detail }), + }); + + assert.equal(result.status, 502); + if (result.status === 502) { + assert.equal(result.body.error, 'We could not complete your signup. Please try again shortly.'); + } + assert.equal(logs.length, 1); + assert.equal(logs[0]?.event, 'waitlist.send.failed'); +}); diff --git a/examples/marketing-waitlist/src/waitlist-handler.ts b/examples/marketing-waitlist/src/waitlist-handler.ts new file mode 100644 index 0000000..556e396 --- /dev/null +++ b/examples/marketing-waitlist/src/waitlist-handler.ts @@ -0,0 +1,110 @@ +import { PostKitClient, PostKitRequestError } from '@singleton-sd/post-kit-client'; + +/** + * Framework-agnostic waitlist / whitelist signup handler. + * + * Same public-form topology as Contact Us (`docs/guides/public-forms.md`), with + * one intentional difference: the confirmation email is sent **to the submitted + * address**. Template key stays server-owned. Caller-supplied `template` / + * `to` / `from` / `subject` are ignored. + * + * Production hosts must still add captcha, per-IP **and** per-address rate + * limits, and exactly one confirmation per submission — PostKit does not. + */ + +/** Server-owned template key. Never read from the submission. */ +export const WAITLIST_TEMPLATE_KEY = 'marketing.waitlist-confirm'; + +export const LIMITS = { + nameMax: 120, + emailMax: 254, +} as const; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const CONTROLS_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/; +const NAME_CONTROLS_RE = /[\u0000-\u001f\u007f]/; + +export interface WaitlistDependencies { + client: PostKitClient; + /** Structured server-side log sink. Never write this detail to the response. */ + logError?: (event: string, detail: Record) => void; +} + +export type WaitlistResult = + | { status: 202; body: { status: 'accepted' } } + | { status: 400; body: { error: string; field: string } } + | { status: 502; body: { error: string } }; + +interface ValidSubmission { + email: string; + name: string; +} + +type ValidationOutcome = + { ok: true; value: ValidSubmission } | { ok: false; field: string; error: string }; + +/** + * Validate a waitlist signup and send a confirmation to the submitted email. + */ +export async function handleWaitlistSignup( + submission: unknown, + deps: WaitlistDependencies, +): Promise { + const validated = validateSubmission(submission); + if (!validated.ok) { + return { status: 400, body: { error: validated.error, field: validated.field } }; + } + + const { email, name } = validated.value; + + try { + await deps.client.send({ + template: WAITLIST_TEMPLATE_KEY, + to: email, + variables: { email, name }, + }); + } catch (err) { + deps.logError?.('waitlist.send.failed', { + code: err instanceof PostKitRequestError ? err.code : 'UNKNOWN', + status: err instanceof PostKitRequestError ? err.status : undefined, + correlationId: err instanceof PostKitRequestError ? err.correlationId : undefined, + }); + return { + status: 502, + body: { error: 'We could not complete your signup. Please try again shortly.' }, + }; + } + + return { status: 202, body: { status: 'accepted' } }; +} + +export function validateSubmission(submission: unknown): ValidationOutcome { + if (submission === null || typeof submission !== 'object' || Array.isArray(submission)) { + return { ok: false, field: 'body', error: 'Submission must be a JSON object.' }; + } + + const raw = submission as Record; + + const email = readString(raw['email']).trim(); + if (!EMAIL_RE.test(email) || email.length > LIMITS.emailMax || CONTROLS_RE.test(email)) { + return { ok: false, field: 'email', error: 'A valid email address is required.' }; + } + + // Name is optional; empty becomes a safe display fallback in the template. + let name = readString(raw['name']).trim(); + if (!name) { + name = 'there'; + } else if (name.length > LIMITS.nameMax || NAME_CONTROLS_RE.test(name)) { + return { + ok: false, + field: 'name', + error: `Name must be at most ${LIMITS.nameMax} characters.`, + }; + } + + return { ok: true, value: { email, name } }; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} diff --git a/examples/marketing-waitlist/tsconfig.json b/examples/marketing-waitlist/tsconfig.json new file mode 100644 index 0000000..33d6628 --- /dev/null +++ b/examples/marketing-waitlist/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true + }, + "include": ["src/**/*"] +} diff --git a/examples/marketing-waitlist/tsconfig.spec.json b/examples/marketing-waitlist/tsconfig.spec.json new file mode 100644 index 0000000..64d39f3 --- /dev/null +++ b/examples/marketing-waitlist/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 435e95a..887c58f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,22 @@ importers: specifier: ^5.7.2 version: 5.9.3 + examples/marketing-waitlist: + dependencies: + '@singleton-sd/post-kit-client': + specifier: workspace:* + version: link:../../packages/post-kit-client + devDependencies: + '@types/node': + specifier: ^20.17.9 + version: 20.19.43 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages/post-kit-client: dependencies: '@singleton-sd/post-kit-types': From bfb632308f25aaab6a418a68e638c16de13a33b9 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 17:01:43 +1000 Subject: [PATCH 2/2] docs(examples): document Key Vault source for waitlist API key Align the waitlist README with public-forms guidance: POSTKIT_API_KEY from ssd-postkit-kv-prod-ae (Key Vault reference on Function App), never in browser code. Co-authored-by: Cursor --- examples/marketing-waitlist/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/marketing-waitlist/README.md b/examples/marketing-waitlist/README.md index 9b146f1..c280741 100644 --- a/examples/marketing-waitlist/README.md +++ b/examples/marketing-waitlist/README.md @@ -60,6 +60,18 @@ export async function POST(request: Request): Promise { } ``` +### Environment variables + +Server-side only. None of these may be exposed to the browser — in particular +never under a `NEXT_PUBLIC_*`, `VITE_*`, or `PUBLIC_*` prefix. + +| Variable | Purpose | +| --- | --- | +| `POSTKIT_ENDPOINT` | Base URL of the PostKit API. | +| `POSTKIT_API_KEY` | Tenant Bearer credential. In production source it **only** from Azure Key Vault `ssd-postkit-kv-prod-ae`; any Function App setting must be a Key Vault reference. Never ship it in browser code. | + +The specs need none of them. + ## Template source `content/email-templates/marketing.waitlist-confirm/` — publish with