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
11 changes: 9 additions & 2 deletions docs/guides/public-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
10 changes: 4 additions & 6 deletions examples/marketing-contact-us/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
78 changes: 78 additions & 0 deletions examples/marketing-waitlist/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 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!,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

export async function POST(request: Request): Promise<Response> {
// 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 });
}
```

### 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
`post-kit-publish` before sends succeed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"key": "marketing.waitlist-confirm",
"name": "Waitlist confirmation",
"subject": "You're on the list, {{name}}",
"variables": ["name", "email"],
"schemaVersion": "1"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Jane Doe",
"email": "jane@example.com"
}
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
}
21 changes: 21 additions & 0 deletions examples/marketing-waitlist/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
138 changes: 138 additions & 0 deletions examples/marketing-waitlist/src/waitlist-handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
body: Record<string, unknown>;
}

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<string, string>,
body: JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>,
};
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<string, unknown> }> = [];
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');
});
Loading
Loading