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
53 changes: 34 additions & 19 deletions docs/guides/api-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,21 +121,26 @@ const postKit = new PostKitClient({
apiKey: process.env.POSTKIT_API_KEY!,
});

const result = await postKit.send({
template: 'auth.password-reset',
to: 'jane@example.com',
variables: {
name: 'Jane Doe',
resetUrl: 'https://app.example.com/reset?token=REPLACE_ME',
const result = await postKit.send(
{
template: 'auth.password-reset',
to: 'jane@example.com',
variables: {
name: 'Jane Doe',
resetUrl: 'https://app.example.com/reset?token=REPLACE_ME',
},
},
});
{ correlationId: 'my-trace-01' },
);

console.log(result.id, result.status); // "<correlation-id> sent"
console.log(result.id, result.status); // "my-trace-01 sent"
```

That produces byte-for-byte the request the `curl` above does (minus
`x-correlation-id`, which the client does not currently set — pass your trace
id via raw HTTP if you need to control it).
Pass `correlationId` on `send()` (or as a client constructor default) to send
`x-correlation-id`. Omit it and the API generates one. `result.id` on success
contains the server correlation ID. On HTTP failures,
`PostKitRequestError.correlationId` contains it when the error body or
`X-Correlation-Id` response header provides it.

### Constructor options

Expand All @@ -144,21 +149,29 @@ id via raw HTTP if you need to control it).
| `endpoint` | — | Required. Base URL; a trailing slash is stripped. `send()` appends `/emails/send` |
| `apiKey` | — | Required. Sent as `Authorization: Bearer` |
| `timeout` | `30_000` | Milliseconds. `0` disables the client timeout — the request then runs until your own `AbortSignal` fires, or indefinitely |
| `correlationId` | — | Optional default `x-correlation-id` for every `send()` |
| `fetch` | `globalThis.fetch` | Injectable `fetch`. Use it in tests so no socket is opened |

Both `endpoint` and `apiKey` throw synchronously from the constructor if
empty.

### `send(request, options?)`

`send(request: SendRequest, options?: { signal?: AbortSignal })` resolves to
`SendResponse` or throws `PostKitRequestError`. A per-call `signal` is combined
with the client timeout, so whichever fires first wins:
`send(request: SendRequest, options?: { signal?: AbortSignal; correlationId?: string })`
resolves to `SendResponse` or throws `PostKitRequestError`. A per-call `signal`
is combined with the client timeout, so whichever fires first wins:

```ts
await postKit.send(request, { signal: AbortSignal.timeout(5_000) });
await postKit.send(request, {
signal: AbortSignal.timeout(5_000),
correlationId: 'my-trace-01',
});
```

Invalid `correlationId` values (empty, over 128 characters, or characters
other than alphanumeric / hyphen / underscore) throw `PostKitRequestError` with
code `INVALID_CORRELATION_ID` before any network call.

If **your** signal aborts, the original `AbortError` propagates unchanged; if
the client's own timeout fires, you get a `PostKitRequestError` with code
`TIMEOUT`.
Expand All @@ -167,7 +180,7 @@ the client's own timeout fires, you get a `PostKitRequestError` with code

| Property | Meaning |
| --- | --- |
| `code` | A `PostKitErrorCode` from the API body, or `'TIMEOUT'` / `'NETWORK_ERROR'`, or `HTTP_<status>` when the error body was not JSON |
| `code` | A `PostKitErrorCode` from the API body, or `'TIMEOUT'` / `'NETWORK_ERROR'` / `'INVALID_CORRELATION_ID'`, or `HTTP_<status>` when the error body was not JSON |
| `status` | HTTP status; `undefined` for timeouts and network failures |
| `correlationId` | From the error body when present — log it |
| `message` | The API's `error` text, or a generated fallback |
Expand All @@ -187,6 +200,7 @@ the client's own timeout fires, you get a `PostKitRequestError` with code
| `PROVIDER_FAILURE` | 502 / 500 | no | Provider rejected the message permanently, or an unexpected server error — investigate with the correlation id |
| `TIMEOUT` | — | yes (idempotent) | Client-side timeout; delivery status is unknown — the send may still have happened |
| `NETWORK_ERROR` | — | yes (idempotent) | Transport failure or a response-body read failure after headers arrived; delivery status is unknown — the API may have accepted the send before the body failed |
| `INVALID_CORRELATION_ID` | — | no | Fix the `correlationId` on `send()` or the client constructor — must be 8–128 alphanumeric / hyphen / underscore characters |

Retry the "yes" rows with capped exponential backoff and jitter. Treat both
`TIMEOUT` and `NETWORK_ERROR` as **delivery unknown** — make retries idempotent
Expand Down Expand Up @@ -225,9 +239,10 @@ on 502 or 500.

### Logging for support

Log `code`, `status`, and `correlationId` on every failure, and `id` on every
success — both are the same correlation id the API logs, and it is the only
handle that ties your request to the server-side trace. Never log the API key,
Log `code`, `status`, and `correlationId` (when present) on HTTP failures, and
`id` on every success — both are the same correlation id the API logs when the
server handled the request, and it is the only handle that ties your request to
the server-side trace. Never log the API key,
the rendered email, or a reset/verification URL.

## Where to go next
Expand Down
17 changes: 16 additions & 1 deletion packages/post-kit-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ await postKit.send({
to: 'hello@example.com',
variables: { name, email, message },
});

// Optional: pass your own trace id (8–128 alphanumeric / hyphen / underscore)
await postKit.send(request, { correlationId: 'my-trace-01' });
```

### Options
Expand All @@ -41,17 +44,29 @@ await postKit.send({
| `endpoint` | Base URL of the PostKit API (trailing slash stripped) |
| `apiKey` | Bearer token for `Authorization` |
| `timeout` | Request timeout ms (default `30_000`). Pass `0` to disable; with no per-call `AbortSignal`, the request then runs indefinitely |
| `correlationId` | Default `x-correlation-id` for every `send()`; per-call `SendOptions.correlationId` overrides |
| `fetch` | Injectable `fetch` (for tests); defaults to `globalThis.fetch` |

Auth lives on the constructor so the strategy can evolve without changing
`send()`.

### `send(request, options?)`

| Option | Description |
| --- | --- |
| `signal` | Optional `AbortSignal` combined with the client timeout |
| `correlationId` | Per-request trace id sent as `x-correlation-id`; overrides a client default |

On success, `SendResponse.id` is the correlation id the API used (yours if
supplied and valid, or server-generated). Invalid values are rejected before
the request with `PostKitRequestError` code `INVALID_CORRELATION_ID`.

### Errors

Non-2xx responses and client-side failures throw `PostKitRequestError`:

- `status` — HTTP status when available
- `code` — API `PostKitErrorCode`, or `'TIMEOUT'` / `'NETWORK_ERROR'`
- `code` — API `PostKitErrorCode`, or `'TIMEOUT'` / `'NETWORK_ERROR'` / `'INVALID_CORRELATION_ID'`
- `correlationId` — from the error body when present

```ts
Expand Down
135 changes: 135 additions & 0 deletions packages/post-kit-client/src/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,141 @@ describe('PostKitClient', () => {
);
});

it('sends x-correlation-id when correlationId is supplied on send()', async () => {
let capturedHeaders: Record<string, string> | undefined;

const client = new PostKitClient({
endpoint: 'https://postkit.example.com',
apiKey: 'pk_test_key',
fetch: async (_input, init) => {
capturedHeaders = init?.headers as Record<string, string>;
return jsonResponse(200, { id: 'my-trace-01', status: 'sent' });
},
});

const result = await client.send(SEND_REQUEST, { correlationId: 'my-trace-01' });

assert.equal(capturedHeaders?.['x-correlation-id'], 'my-trace-01');
assert.equal(result.id, 'my-trace-01');
assert.equal(result.status, 'sent');
});

it('does not send x-correlation-id when correlationId is omitted', async () => {
let capturedHeaders: Record<string, string> | undefined;

const client = new PostKitClient({
endpoint: 'https://postkit.example.com',
apiKey: 'pk_test_key',
fetch: async (_input, init) => {
capturedHeaders = init?.headers as Record<string, string>;
return jsonResponse(200, SEND_RESPONSE);
},
});

await client.send(SEND_REQUEST);

assert.equal(capturedHeaders?.['x-correlation-id'], undefined);
});

it('uses a client-level default correlationId when send() does not override it', async () => {
let capturedHeaders: Record<string, string> | undefined;

const client = new PostKitClient({
endpoint: 'https://postkit.example.com',
apiKey: 'pk_test_key',
correlationId: 'client-default',
fetch: async (_input, init) => {
capturedHeaders = init?.headers as Record<string, string>;
return jsonResponse(200, { id: 'client-default', status: 'sent' });
},
});

await client.send(SEND_REQUEST);

assert.equal(capturedHeaders?.['x-correlation-id'], 'client-default');
});

it('prefers per-request correlationId over the client default', async () => {
let capturedHeaders: Record<string, string> | undefined;

const client = new PostKitClient({
endpoint: 'https://postkit.example.com',
apiKey: 'pk_test_key',
correlationId: 'client-default',
fetch: async (_input, init) => {
capturedHeaders = init?.headers as Record<string, string>;
return jsonResponse(200, { id: 'request-trace', status: 'sent' });
},
});

await client.send(SEND_REQUEST, { correlationId: 'request-trace' });

assert.equal(capturedHeaders?.['x-correlation-id'], 'request-trace');
});

it('rejects invalid correlationId values before the request is sent', async () => {
let fetchCalled = false;
const client = new PostKitClient({
endpoint: 'https://postkit.example.com',
apiKey: 'pk_test_key',
fetch: async () => {
fetchCalled = true;
return jsonResponse(200, SEND_RESPONSE);
},
});

const invalidValues = [
'',
'short',
'a'.repeat(129),
'has spaces',
'has/slash',
'has:newline\n',
];

for (const correlationId of invalidValues) {
fetchCalled = false;
await assert.rejects(
() => client.send(SEND_REQUEST, { correlationId }),
(err: unknown) => {
assert.ok(err instanceof PostKitRequestError);
assert.equal(err.code, 'INVALID_CORRELATION_ID');
assert.equal(err.status, undefined);
assert.equal(err.correlationId, undefined);
return true;
},
);
assert.equal(fetchCalled, false, `fetch must not run for ${JSON.stringify(correlationId)}`);
}
});

it('surfaces correlationId from the X-Correlation-Id header when the error body omits it', async () => {
const client = new PostKitClient({
endpoint: 'https://postkit.example.com',
apiKey: 'pk_test_key',
fetch: async () =>
new Response(
JSON.stringify({ error: 'Bad gateway', code: PostKitErrorCode.PROVIDER_FAILURE }),
{
status: 502,
headers: {
'Content-Type': 'application/json',
'X-Correlation-Id': 'header-corr-1',
},
},
),
});

await assert.rejects(
() => client.send(SEND_REQUEST),
(err: unknown) => {
assert.ok(err instanceof PostKitRequestError);
assert.equal(err.correlationId, 'header-corr-1');
return true;
},
);
});

it('rethrows when the caller AbortSignal aborts the request', async () => {
const controller = new AbortController();
const client = new PostKitClient({
Expand Down
42 changes: 37 additions & 5 deletions packages/post-kit-client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PostKitErrorResponse, SendRequest, SendResponse } from '@singleton-sd/post-kit-types';
import { isValidCorrelationId } from './correlation';
import { PostKitRequestError } from './errors';

const DEFAULT_TIMEOUT_MS = 30_000;
Expand All @@ -20,13 +21,23 @@ export interface PostKitClientOptions {
* optional per-call `AbortSignal` fires, or indefinitely if none is given).
*/
timeout?: number;
/**
* Default correlation ID sent as `x-correlation-id` on every `send()` call.
* Per-request `SendOptions.correlationId` overrides this value.
*/
correlationId?: string;
/** Injectable `fetch` implementation (defaults to `globalThis.fetch`). */
fetch?: typeof globalThis.fetch;
}

export interface SendOptions {
/** Optional abort signal threaded through to `fetch`. */
signal?: AbortSignal;
/**
* Correlation ID for this request, sent as `x-correlation-id`.
* Overrides a client-level default when both are set.
*/
correlationId?: string;
}

/**
Expand All @@ -39,6 +50,7 @@ export class PostKitClient {
private readonly endpoint: string;
private readonly apiKey: string;
private readonly timeoutMs: number;
private readonly defaultCorrelationId: string | undefined;
private readonly fetchImpl: typeof globalThis.fetch;

constructor(options: PostKitClientOptions) {
Expand All @@ -51,6 +63,7 @@ export class PostKitClient {
this.endpoint = options.endpoint.replace(/\/+$/, '');
this.apiKey = options.apiKey;
this.timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
this.defaultCorrelationId = options.correlationId;
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
}

Expand All @@ -60,14 +73,24 @@ export class PostKitClient {
async send(request: SendRequest, options?: SendOptions): Promise<SendResponse> {
const url = `${this.endpoint}/emails/send`;
const callerSignal = options?.signal;
const correlationId = options?.correlationId ?? this.defaultCorrelationId;

if (correlationId !== undefined) {
this.assertValidCorrelationId(correlationId);
}

try {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
};
if (correlationId !== undefined) {
headers['x-correlation-id'] = correlationId;
}

const response = await this.fetchImpl(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
headers,
body: JSON.stringify(request),
signal: this.combineSignals(callerSignal),
});
Expand Down Expand Up @@ -150,9 +173,18 @@ export class PostKitClient {
message,
code: body.code ?? `HTTP_${response.status}`,
status: response.status,
correlationId: body.correlationId,
correlationId: body.correlationId ?? response.headers.get('X-Correlation-Id') ?? undefined,
});
}

private assertValidCorrelationId(correlationId: string): void {
if (!isValidCorrelationId(correlationId)) {
throw new PostKitRequestError({
message: 'Invalid correlation ID',
code: 'INVALID_CORRELATION_ID',
});
}
}
}

function isAbortError(err: unknown): boolean {
Expand Down
12 changes: 12 additions & 0 deletions packages/post-kit-client/src/correlation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Valid correlation ID: 8–128 alphanumeric / hyphen / underscore characters. */
const VALID_CORRELATION_ID = /^[a-zA-Z0-9_-]{8,128}$/;

/**
* Validate a caller-supplied correlation ID before it is sent as `x-correlation-id`.
*
* Rules match the API's {@link resolveCorrelationId}: 8–128 chars, alphanumeric,
* hyphen, and underscore only.
*/
export function isValidCorrelationId(value: string): boolean {
return VALID_CORRELATION_ID.test(value);
}
Loading
Loading