@posthaste/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The wire types.
3
+ *
4
+ * Every shape here was written from the handler that produces it — see
5
+ * `apps/api/src/app.ts`, `billing-routes.ts`, `dashboard-routes.ts` and
6
+ * `cloudflare-routes.ts` — rather than from the documentation, so a field
7
+ * present here is a field the server actually sends.
8
+ *
9
+ * Timestamps are ISO-8601 strings. They are `timestamptz` in Postgres and are
10
+ * JSON-serialised on the way out, so they arrive as strings however they were
11
+ * stored; typing them as `Date` would be a lie that only shows up at runtime.
12
+ */
13
+ // ---------------------------------------------------------------------------
14
+ // Enumerations
15
+ // ---------------------------------------------------------------------------
16
+ /** `message_status` in the database. The lifecycle a message can be in. */
17
+ export const MESSAGE_STATUSES = [
18
+ 'queued',
19
+ 'sending',
20
+ 'delivered',
21
+ 'bounced',
22
+ 'complained',
23
+ 'failed',
24
+ 'rejected',
25
+ ];
26
+ /**
27
+ * The ten webhook event types (`event_type` in the database).
28
+ *
29
+ * Note that these are NOT the same set as `MessageStatus`: `accepted`,
30
+ * `attempted`, `deferred` and `suppressed` are things that happen to a message
31
+ * without changing the status it rests in.
32
+ */
33
+ export const EVENT_TYPES = [
34
+ 'accepted',
35
+ 'queued',
36
+ 'attempted',
37
+ 'delivered',
38
+ 'deferred',
39
+ 'bounced',
40
+ 'complained',
41
+ 'failed',
42
+ 'rejected',
43
+ 'suppressed',
44
+ ];
45
+ /**
46
+ * `suppression_reason` in the database.
47
+ *
48
+ * Spelled exactly as the server spells it. `hard_bounce` is by far the most
49
+ * common one, and guessing at `bounce` produces a filter that silently matches
50
+ * nothing rather than an error.
51
+ */
52
+ export const SUPPRESSION_REASONS = [
53
+ 'hard_bounce',
54
+ 'complaint',
55
+ 'manual',
56
+ 'unsubscribe',
57
+ 'spam_trap',
58
+ ];
59
+ /**
60
+ * Every scope an API key can be granted.
61
+ *
62
+ * `billing:read` is absent on purpose: it is not in the API's grantable set —
63
+ * a session carries it, and the billing reads also accept `account:read`,
64
+ * which is what an API key uses to reach them.
65
+ */
66
+ export const SCOPES = [
67
+ 'account:read',
68
+ 'domains:read',
69
+ 'domains:write',
70
+ 'emails:send',
71
+ 'messages:read',
72
+ 'suppressions:read',
73
+ 'suppressions:write',
74
+ 'webhooks:read',
75
+ 'webhooks:write',
76
+ ];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Webhook signature verification.
3
+ *
4
+ * A webhook endpoint is a URL on the public internet. Anyone who learns it can
5
+ * POST to it, so without a signature you cannot tell our delivery from a
6
+ * forgery — and a forged `bounced` event would make you suppress an address
7
+ * that is perfectly fine, while a forged `delivered` would hide a failure.
8
+ *
9
+ * The scheme:
10
+ *
11
+ * posthaste-signature: t=<unix seconds>,v1=<hex>
12
+ * signed payload = `${t}.${rawBody}`
13
+ * signature = HMAC-SHA256(secret, signed payload), lower-case hex
14
+ *
15
+ * Signing the body alone would be replayable for ever. Binding the timestamp is
16
+ * what lets a receiver reject something stale, and `v1=` is a version tag so
17
+ * the scheme can change without breaking every receiver on the same day.
18
+ *
19
+ * This is a REIMPLEMENTATION of `packages/core/src/webhook-signature.ts`, not
20
+ * an import of it: `@email/core` is a private workspace package that could
21
+ * never resolve for somebody who installed this SDK from npm. The two are held
22
+ * together by a test that signs with core and verifies with this file, so they
23
+ * cannot drift apart silently.
24
+ */
25
+ /** The header the signature arrives in. Lower-cased; HTTP headers are not case-sensitive. */
26
+ export declare const SIGNATURE_HEADER = "posthaste-signature";
27
+ /** Stable across every retry of the same event. Deduplicate on it. */
28
+ export declare const DELIVERY_ID_HEADER = "posthaste-delivery-id";
29
+ /** Which attempt this is, starting at 1. */
30
+ export declare const ATTEMPT_HEADER = "posthaste-attempt";
31
+ export type WebhookVerifyFailure = 'malformed_header' | 'unsupported_version' | 'timestamp_too_old' | 'timestamp_in_future' | 'signature_mismatch';
32
+ /**
33
+ * Discriminated on `valid`, so a `if (!result.valid)` branch has the reason and
34
+ * a valid one carries no reason to accidentally log.
35
+ */
36
+ export type WebhookVerifyResult = {
37
+ valid: true;
38
+ } | {
39
+ valid: false;
40
+ reason: WebhookVerifyFailure;
41
+ };
42
+ export interface VerifyWebhookOptions {
43
+ /** How far in either direction a timestamp may be. Default 300 seconds. */
44
+ toleranceSeconds?: number;
45
+ /** Injectable clock, so the replay window can actually be tested. */
46
+ nowSeconds?: number;
47
+ }
48
+ /**
49
+ * Verify a webhook delivery.
50
+ *
51
+ * `rawBody` MUST be the exact bytes we sent — `express.raw()`, `await
52
+ * req.text()`, `request.get_data()`. A parsed-then-re-serialised object will
53
+ * NEVER verify: `JSON.stringify` is free to reorder keys, drop insignificant
54
+ * whitespace and re-escape strings, and the signature covers the bytes, not the
55
+ * object they decode to. This is the single most common reason verification
56
+ * "mysteriously" fails, and no amount of correct key handling rescues it.
57
+ *
58
+ * @param rawBody the request body, as bytes or as the exact string they decode to
59
+ * @param signatureHeader the `posthaste-signature` header value
60
+ * @param secret the `signingSecret` returned once when the webhook was created
61
+ */
62
+ export declare function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string | null | undefined, secret: string, options?: VerifyWebhookOptions): WebhookVerifyResult;
63
+ /**
64
+ * Verify and parse in one step.
65
+ *
66
+ * Returns the decoded event on success and `null` on any failure, for the
67
+ * common case where the only thing a handler does with a bad delivery is
68
+ * answer 400. Use `verifyWebhook` directly when the reason matters.
69
+ */
70
+ export declare function parseWebhookEvent<T = unknown>(rawBody: string | Uint8Array, signatureHeader: string | null | undefined, secret: string, options?: VerifyWebhookOptions): T | null;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Webhook signature verification.
3
+ *
4
+ * A webhook endpoint is a URL on the public internet. Anyone who learns it can
5
+ * POST to it, so without a signature you cannot tell our delivery from a
6
+ * forgery — and a forged `bounced` event would make you suppress an address
7
+ * that is perfectly fine, while a forged `delivered` would hide a failure.
8
+ *
9
+ * The scheme:
10
+ *
11
+ * posthaste-signature: t=<unix seconds>,v1=<hex>
12
+ * signed payload = `${t}.${rawBody}`
13
+ * signature = HMAC-SHA256(secret, signed payload), lower-case hex
14
+ *
15
+ * Signing the body alone would be replayable for ever. Binding the timestamp is
16
+ * what lets a receiver reject something stale, and `v1=` is a version tag so
17
+ * the scheme can change without breaking every receiver on the same day.
18
+ *
19
+ * This is a REIMPLEMENTATION of `packages/core/src/webhook-signature.ts`, not
20
+ * an import of it: `@email/core` is a private workspace package that could
21
+ * never resolve for somebody who installed this SDK from npm. The two are held
22
+ * together by a test that signs with core and verifies with this file, so they
23
+ * cannot drift apart silently.
24
+ */
25
+ import { createHmac, timingSafeEqual } from 'node:crypto';
26
+ /** The header the signature arrives in. Lower-cased; HTTP headers are not case-sensitive. */
27
+ export const SIGNATURE_HEADER = 'posthaste-signature';
28
+ /** Stable across every retry of the same event. Deduplicate on it. */
29
+ export const DELIVERY_ID_HEADER = 'posthaste-delivery-id';
30
+ /** Which attempt this is, starting at 1. */
31
+ export const ATTEMPT_HEADER = 'posthaste-attempt';
32
+ const SCHEME_VERSION = 'v1';
33
+ const DEFAULT_TOLERANCE_SECONDS = 300;
34
+ /**
35
+ * Verify a webhook delivery.
36
+ *
37
+ * `rawBody` MUST be the exact bytes we sent — `express.raw()`, `await
38
+ * req.text()`, `request.get_data()`. A parsed-then-re-serialised object will
39
+ * NEVER verify: `JSON.stringify` is free to reorder keys, drop insignificant
40
+ * whitespace and re-escape strings, and the signature covers the bytes, not the
41
+ * object they decode to. This is the single most common reason verification
42
+ * "mysteriously" fails, and no amount of correct key handling rescues it.
43
+ *
44
+ * @param rawBody the request body, as bytes or as the exact string they decode to
45
+ * @param signatureHeader the `posthaste-signature` header value
46
+ * @param secret the `signingSecret` returned once when the webhook was created
47
+ */
48
+ export function verifyWebhook(rawBody, signatureHeader, secret, options = {}) {
49
+ if (typeof signatureHeader !== 'string' || signatureHeader.length === 0) {
50
+ return fail('malformed_header');
51
+ }
52
+ if (typeof secret !== 'string' || secret.length === 0) {
53
+ return fail('signature_mismatch');
54
+ }
55
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
56
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
57
+ /*
58
+ * Parsed BY KEY, never positionally.
59
+ *
60
+ * `v1=` is a version tag precisely so a `v2=` can be added beside it. A
61
+ * parser that reads "the second comma-separated field" breaks the day the
62
+ * order changes or a field is inserted — and breaks by reading the wrong
63
+ * value into the signature comparison, which fails closed but for the wrong
64
+ * reason and is maddening to debug.
65
+ */
66
+ const parts = new Map();
67
+ for (const piece of signatureHeader.split(',')) {
68
+ const idx = piece.indexOf('=');
69
+ if (idx > 0)
70
+ parts.set(piece.slice(0, idx).trim(), piece.slice(idx + 1).trim());
71
+ }
72
+ const t = parts.get('t');
73
+ const provided = parts.get(SCHEME_VERSION);
74
+ if (!t || !/^\d+$/.test(t))
75
+ return fail('malformed_header');
76
+ if (!provided) {
77
+ // A timestamp with no v1 is either a newer scheme we do not implement or a
78
+ // truncated header. Neither is acceptable, and they are worth telling apart.
79
+ return fail(parts.size > 1 ? 'unsupported_version' : 'malformed_header');
80
+ }
81
+ const timestamp = Number(t);
82
+ if (now - timestamp > tolerance)
83
+ return fail('timestamp_too_old');
84
+ /*
85
+ * A future timestamp is rejected too, and by the same tolerance.
86
+ *
87
+ * It is tempting to be generous about this on the grounds of clock skew. Do
88
+ * not: a signature dated an hour ahead is not skew, it is an attacker buying
89
+ * themselves an hour-long replay window with a request you would otherwise
90
+ * accept the whole time.
91
+ */
92
+ if (timestamp - now > tolerance)
93
+ return fail('timestamp_in_future');
94
+ const mac = createHmac('sha256', secret);
95
+ mac.update(`${timestamp}.`);
96
+ mac.update(rawBody);
97
+ const expected = mac.digest('hex');
98
+ const a = Buffer.from(provided);
99
+ const b = Buffer.from(expected);
100
+ /*
101
+ * Constant time, and length-checked first because timingSafeEqual throws on
102
+ * a length mismatch. A `===` here would leak how many leading hex characters
103
+ * matched, which is enough to forge a signature one character at a time.
104
+ */
105
+ if (a.length !== b.length || !timingSafeEqual(a, b))
106
+ return fail('signature_mismatch');
107
+ return { valid: true };
108
+ }
109
+ /**
110
+ * Verify and parse in one step.
111
+ *
112
+ * Returns the decoded event on success and `null` on any failure, for the
113
+ * common case where the only thing a handler does with a bad delivery is
114
+ * answer 400. Use `verifyWebhook` directly when the reason matters.
115
+ */
116
+ export function parseWebhookEvent(rawBody, signatureHeader, secret, options = {}) {
117
+ const result = verifyWebhook(rawBody, signatureHeader, secret, options);
118
+ if (!result.valid)
119
+ return null;
120
+ const text = typeof rawBody === 'string' ? rawBody : Buffer.from(rawBody).toString('utf8');
121
+ try {
122
+ return JSON.parse(text);
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ function fail(reason) {
129
+ return { valid: false, reason };
130
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@posthaste/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Official TypeScript SDK for the Posthaste transactional email API.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "keywords": [
14
+ "posthaste",
15
+ "email",
16
+ "transactional-email",
17
+ "smtp",
18
+ "webhooks"
19
+ ],
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md"
32
+ ],
33
+ "sideEffects": false,
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "test": "vitest run"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^26.2.0",
41
+ "typescript": "^7.0.2",
42
+ "vitest": "^4.1.10"
43
+ }
44
+ }