@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/README.md +454 -0
- package/dist/client.d.ts +234 -0
- package/dist/client.js +459 -0
- package/dist/errors.d.ts +100 -0
- package/dist/errors.js +154 -0
- package/dist/http.d.ts +150 -0
- package/dist/http.js +258 -0
- package/dist/ids.d.ts +25 -0
- package/dist/ids.js +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/dist/pagination.d.ts +45 -0
- package/dist/pagination.js +69 -0
- package/dist/types.d.ts +593 -0
- package/dist/types.js +76 -0
- package/dist/webhooks.d.ts +70 -0
- package/dist/webhooks.js +130 -0
- package/package.json +44 -0
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;
|
package/dist/webhooks.js
ADDED
|
@@ -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
|
+
}
|