@pidgeon-ai/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/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ Versioned independently of the application. The API is `/v1`; this package
4
+ is `0.x`. They are different contracts with different audiences, and tying
5
+ the two would publish a release for every unrelated product deploy.
6
+
7
+ Semver applies to **this package's own surface** — the exported types, the
8
+ method signatures, the error shape — not to the API behind it.
9
+
10
+ ## Unreleased
11
+
12
+ ## 0.1.0
13
+
14
+ First release.
15
+
16
+ - `Pidgeon` — a typed client over `/v1`: `identities`, `messages`,
17
+ `attachments`, `threads`, `webhooks`, `events`, `domains`.
18
+ - `verifyWebhook` — the receiving half of the signature scheme, tested
19
+ against payloads signed by the server's own implementation.
20
+ - `PidgeonError` — one error class, carrying the API's `code`, HTTP
21
+ `status`, the offending `field` on a validation failure, and the request
22
+ id.
23
+ - ESM and CommonJS builds with per-condition type declarations. Zero
24
+ runtime dependencies; needs only `fetch` and `crypto.subtle`.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # @pidgeon-ai/sdk
2
+
3
+ A thin TypeScript client for the [Pidgeon](https://pidgeon.ai) email API.
4
+ Typed `fetch` wrappers, one error class, and a webhook verifier.
5
+
6
+ **Zero dependencies.** Needs `fetch` and `crypto.subtle`, so it runs on
7
+ Node 18+, Bun, Deno, Cloudflare Workers and Vercel functions. ESM and
8
+ CommonJS.
9
+
10
+ ```bash
11
+ pnpm add @pidgeon-ai/sdk
12
+ ```
13
+
14
+ ## Receiving mail
15
+
16
+ Mail arriving at an address your code owns becomes an event, and your
17
+ endpoint is called with a signed payload.
18
+
19
+ ```ts
20
+ import { Pidgeon, verifyWebhook } from '@pidgeon-ai/sdk';
21
+
22
+ const pidgeon = new Pidgeon({ apiKey: process.env.PIDGEON_API_KEY! });
23
+
24
+ export async function POST(request: Request) {
25
+ // Verify first. An unverified payload is an unauthenticated POST that
26
+ // claims a message arrived.
27
+ const event = await verifyWebhook({
28
+ rawBody: await request.text(),
29
+ headers: request.headers,
30
+ secret: process.env.PIDGEON_WEBHOOK_SECRET!,
31
+ });
32
+
33
+ if (event.type !== 'message.received') return new Response(null, { status: 204 });
34
+
35
+ // The event carries ids, never a body. Exchange one for the content.
36
+ const mail = await pidgeon.messages.get(event.data.message_id as string);
37
+
38
+ await pidgeon.messages.send({
39
+ from: mail.to[0].email,
40
+ to: mail.from.email,
41
+ subject: `Re: ${mail.subject}`,
42
+ text: 'Got it — someone will be in touch.',
43
+ // Keeps the conversation together in the recipient's client too.
44
+ in_reply_to: mail.id,
45
+ });
46
+
47
+ return new Response(null, { status: 204 });
48
+ }
49
+ ```
50
+
51
+ `rawBody` must be the exact bytes we sent. Parse the JSON _after_
52
+ verifying — re-serialising changes key order and the signature will not
53
+ match.
54
+
55
+ `verifyWebhook` throws rather than returning a boolean, because the failure
56
+ mode of a boolean is a caller who forgets to check it. It rejects a
57
+ timestamp outside tolerance (five minutes by default) before it looks at
58
+ the signature, and compares in constant time.
59
+
60
+ ## Handing a conversation to a person
61
+
62
+ ```ts
63
+ await pidgeon.threads.handOff(threadId);
64
+ ```
65
+
66
+ It appears in the web inbox, and `thread.updated` tells whatever was
67
+ driving the conversation that it no longer is.
68
+
69
+ ## Errors
70
+
71
+ Every failure is a `PidgeonError` carrying the API's own `code`, the HTTP
72
+ `status`, the offending `field` on a validation failure, and the request id
73
+ to quote in a support conversation.
74
+
75
+ ```ts
76
+ import { PidgeonError } from '@pidgeon-ai/sdk';
77
+
78
+ try {
79
+ await pidgeon.messages.send({ from, to, text });
80
+ } catch (error) {
81
+ if (error instanceof PidgeonError && error.code === 'rate_limited') {
82
+ // Back off. Deliberately not done for you — see below.
83
+ }
84
+ throw error;
85
+ }
86
+ ```
87
+
88
+ `code` is one of `validation`, `unauthenticated`, `entitlement`,
89
+ `forbidden`, `not_found`, `conflict`, `rate_limited`, `internal`.
90
+
91
+ ## Pagination
92
+
93
+ List endpoints return a page, not an iterator.
94
+
95
+ ```ts
96
+ let cursor: string | null = null;
97
+
98
+ do {
99
+ const page = await pidgeon.events.list({ limit: 50, cursor: cursor ?? undefined });
100
+ for (const event of page.data) handle(event);
101
+ cursor = page.next_cursor;
102
+ } while (cursor);
103
+ ```
104
+
105
+ ## What this deliberately does not do
106
+
107
+ No retry policy, no queue, no caching, no auto-paginating iterator. Those
108
+ are decisions that belong to your application — an SDK that makes them for
109
+ you is one you eventually work around, and an iterator that hides
110
+ pagination also hides the rate limit pagination is subject to.
111
+
112
+ ## Options
113
+
114
+ ```ts
115
+ new Pidgeon({
116
+ apiKey: process.env.PIDGEON_API_KEY!,
117
+ // Point at a local dev server, or a preview deployment.
118
+ baseUrl: 'http://localhost:3000/api/v1',
119
+ // Inject a transport. Useful in tests.
120
+ fetch: myFetch,
121
+ });
122
+ ```
123
+
124
+ ## Versioning
125
+
126
+ Versioned independently of the API. The API is `/v1`; this package is
127
+ `0.x`, and semver applies to its own surface — the exported types, the
128
+ method signatures, the error shape. See `CHANGELOG.md`.
129
+
130
+ MIT.
package/dist/index.cjs ADDED
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ Pidgeon: () => Pidgeon,
24
+ PidgeonError: () => PidgeonError,
25
+ SIGNATURE_HEADER: () => SIGNATURE_HEADER,
26
+ TIMESTAMP_HEADER: () => TIMESTAMP_HEADER,
27
+ verifyWebhook: () => verifyWebhook
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/client.ts
32
+ var PidgeonError = class extends Error {
33
+ code;
34
+ status;
35
+ field;
36
+ requestId;
37
+ constructor(input) {
38
+ super(input.message);
39
+ this.name = "PidgeonError";
40
+ this.code = input.code;
41
+ this.status = input.status;
42
+ this.field = input.field;
43
+ this.requestId = input.requestId;
44
+ }
45
+ };
46
+ var DEFAULT_BASE_URL = "https://pidgeon.ai/api/v1";
47
+ var Pidgeon = class {
48
+ #apiKey;
49
+ #baseUrl;
50
+ #fetch;
51
+ constructor(options) {
52
+ if (!options.apiKey) throw new Error("An API key is required.");
53
+ this.#apiKey = options.apiKey;
54
+ this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
55
+ this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
56
+ }
57
+ async #request(method, path, options = {}) {
58
+ const url = new URL(`${this.#baseUrl}${path}`);
59
+ for (const [key, value] of Object.entries(options.query ?? {})) {
60
+ if (value !== void 0) url.searchParams.set(key, String(value));
61
+ }
62
+ const response = await this.#fetch(url, {
63
+ method,
64
+ headers: {
65
+ Authorization: `Bearer ${this.#apiKey}`,
66
+ ...options.body ? { "Content-Type": "application/json" } : {}
67
+ },
68
+ body: options.body ? JSON.stringify(options.body) : void 0
69
+ });
70
+ const requestId = response.headers.get("x-request-id") ?? void 0;
71
+ const payload = await response.json().catch(() => null);
72
+ if (!response.ok || payload?.error) {
73
+ throw new PidgeonError({
74
+ code: payload?.error?.code ?? "internal",
75
+ message: payload?.error?.message ?? `Request failed with ${response.status}.`,
76
+ status: response.status,
77
+ field: payload?.error?.field,
78
+ requestId
79
+ });
80
+ }
81
+ return payload?.data;
82
+ }
83
+ // --- identities ---------------------------------------------------------
84
+ identities = {
85
+ list: () => this.#request("GET", "/identities"),
86
+ get: (id) => this.#request("GET", `/identities/${id}`),
87
+ create: (input) => this.#request("POST", "/identities", { body: input }),
88
+ update: (id, input) => this.#request("PATCH", `/identities/${id}`, { body: input }),
89
+ delete: (id) => this.#request("DELETE", `/identities/${id}`)
90
+ };
91
+ // --- messages -----------------------------------------------------------
92
+ messages = {
93
+ get: (id) => this.#request("GET", `/messages/${id}`),
94
+ listInThread: (threadId, options = {}) => this.#requestPage("GET", "/messages", {
95
+ thread_id: threadId,
96
+ limit: options.limit
97
+ }),
98
+ send: (input) => this.#request(
99
+ "POST",
100
+ "/messages",
101
+ { body: input }
102
+ )
103
+ };
104
+ attachments = {
105
+ /** A short-lived signed URL. Fetch it when you need it, don't store it. */
106
+ downloadUrl: (id) => this.#request("GET", `/attachments/${id}`)
107
+ };
108
+ // --- threads ------------------------------------------------------------
109
+ threads = {
110
+ list: (options = {}) => this.#requestPage("GET", "/threads", options),
111
+ get: (id) => this.#request("GET", `/threads/${id}`),
112
+ update: (id, input) => this.#request("PATCH", `/threads/${id}`, { body: input }),
113
+ /** Hand the conversation to a person. The takeover primitive, named. */
114
+ handOff: (id) => this.#request("PATCH", `/threads/${id}`, {
115
+ body: { owner_type: "human", status: "human_review" }
116
+ })
117
+ };
118
+ // --- webhooks -----------------------------------------------------------
119
+ webhooks = {
120
+ list: () => this.#request("GET", "/webhooks"),
121
+ /** The response carries `secret`, once. Store it before you discard it. */
122
+ create: (input) => this.#request("POST", "/webhooks", { body: input }),
123
+ delete: (id) => this.#request("DELETE", `/webhooks/${id}`)
124
+ };
125
+ // --- events -------------------------------------------------------------
126
+ events = {
127
+ list: (options = {}) => this.#requestPage("GET", "/events", options),
128
+ get: (id) => this.#request("GET", `/events/${id}`)
129
+ };
130
+ domains = {
131
+ /** Read-only. Adding and verifying a domain is a DNS-and-a-human task. */
132
+ list: () => this.#request("GET", "/domains")
133
+ };
134
+ async #requestPage(method, path, query) {
135
+ const url = new URL(`${this.#baseUrl}${path}`);
136
+ for (const [key, value] of Object.entries(query)) {
137
+ if (value !== void 0) url.searchParams.set(key, String(value));
138
+ }
139
+ const response = await this.#fetch(url, {
140
+ method,
141
+ headers: { Authorization: `Bearer ${this.#apiKey}` }
142
+ });
143
+ const payload = await response.json().catch(() => null);
144
+ if (!response.ok || payload?.error) {
145
+ throw new PidgeonError({
146
+ code: payload?.error?.code ?? "internal",
147
+ message: payload?.error?.message ?? `Request failed with ${response.status}.`,
148
+ status: response.status,
149
+ field: payload?.error?.field,
150
+ requestId: response.headers.get("x-request-id") ?? void 0
151
+ });
152
+ }
153
+ return {
154
+ data: payload?.data ?? [],
155
+ has_more: payload?.has_more ?? false,
156
+ next_cursor: payload?.next_cursor ?? null
157
+ };
158
+ }
159
+ };
160
+
161
+ // src/webhooks.ts
162
+ var SIGNATURE_HEADER = "x-pidgeon-signature";
163
+ var TIMESTAMP_HEADER = "x-pidgeon-timestamp";
164
+ async function verifyWebhook(input) {
165
+ const get = (name) => input.headers instanceof Headers ? input.headers.get(name) ?? void 0 : input.headers[name] ?? input.headers[name.toLowerCase()];
166
+ const timestamp = get(TIMESTAMP_HEADER);
167
+ const signature = get(SIGNATURE_HEADER);
168
+ if (!timestamp || !signature) throw new Error("This request is not signed.");
169
+ const seconds = Number(timestamp);
170
+ if (!Number.isFinite(seconds)) throw new Error("The signature timestamp is not a number.");
171
+ const now = Math.floor((input.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
172
+ const tolerance = input.toleranceSeconds ?? 300;
173
+ if (Math.abs(now - seconds) > tolerance) {
174
+ throw new Error("This request is too old to trust. It may be a replay.");
175
+ }
176
+ const key = await crypto.subtle.importKey(
177
+ "raw",
178
+ new TextEncoder().encode(input.secret),
179
+ { name: "HMAC", hash: "SHA-256" },
180
+ false,
181
+ ["sign"]
182
+ );
183
+ const mac = await crypto.subtle.sign(
184
+ "HMAC",
185
+ key,
186
+ new TextEncoder().encode(`${seconds}.${input.rawBody}`)
187
+ );
188
+ const expected = `v1=${[...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
189
+ if (!constantTimeEqual(expected, signature)) {
190
+ throw new Error("The signature does not match.");
191
+ }
192
+ return JSON.parse(input.rawBody);
193
+ }
194
+ function constantTimeEqual(a, b) {
195
+ if (a.length !== b.length) return false;
196
+ let difference = 0;
197
+ for (let index = 0; index < a.length; index += 1) {
198
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
199
+ }
200
+ return difference === 0;
201
+ }
202
+ // Annotate the CommonJS export names for ESM import in node:
203
+ 0 && (module.exports = {
204
+ Pidgeon,
205
+ PidgeonError,
206
+ SIGNATURE_HEADER,
207
+ TIMESTAMP_HEADER,
208
+ verifyWebhook
209
+ });
210
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/client.ts","../src/webhooks.ts"],"sourcesContent":["/**\n * `@pidgeon-ai/sdk` — a thin typed client for the Pidgeon email API.\n *\n * Three things and no more: typed `fetch` wrappers, one error class, and a\n * webhook verifier.\n *\n * import { Pidgeon, verifyWebhook } from '@pidgeon-ai/sdk';\n *\n * The module split follows what can be wrong on its own. `wire.ts` is the\n * shape of every response and is the single definition the application\n * checks its own DTOs against. `client.ts` is transport. `webhooks.ts` is\n * the only part with a security contract.\n */\n\nexport { Pidgeon, PidgeonError, type PidgeonOptions } from './client';\n\nexport {\n verifyWebhook,\n SIGNATURE_HEADER,\n TIMESTAMP_HEADER,\n type WebhookEnvelope,\n} from './webhooks';\n\nexport type {\n Address,\n Attachment,\n Domain,\n Event,\n Identity,\n Message,\n Page,\n RoutingRule,\n Thread,\n Webhook,\n} from './wire';\n","import type { Domain, Event, Identity, Message, Page, Thread, Webhook } from './wire';\n\n/**\n * The Pidgeon client.\n *\n * Deliberately thin: typed wrappers over `fetch`, one error class, and\n * nothing else. No retry policy, no queue, no caching, no ambient state —\n * those are decisions that belong to the application, and an SDK that makes\n * them for you is an SDK you eventually have to work around.\n *\n * Zero dependencies. The only runtime requirements are `fetch` and\n * `crypto.subtle`, both standard in Node 18+, Bun, Deno and every edge\n * runtime.\n */\n\nexport interface PidgeonOptions {\n apiKey: string;\n /** Defaults to the hosted API. Point this at a local dev server to test. */\n baseUrl?: string;\n /** Injected in tests. Defaults to the global. */\n fetch?: typeof globalThis.fetch;\n}\n\nexport class PidgeonError extends Error {\n readonly code: string;\n readonly status: number;\n readonly field?: string;\n readonly requestId?: string;\n\n constructor(input: {\n code: string;\n message: string;\n status: number;\n field?: string;\n requestId?: string;\n }) {\n super(input.message);\n this.name = 'PidgeonError';\n this.code = input.code;\n this.status = input.status;\n this.field = input.field;\n this.requestId = input.requestId;\n }\n}\n\nconst DEFAULT_BASE_URL = 'https://pidgeon.ai/api/v1';\n\nexport class Pidgeon {\n #apiKey: string;\n #baseUrl: string;\n #fetch: typeof globalThis.fetch;\n\n constructor(options: PidgeonOptions) {\n if (!options.apiKey) throw new Error('An API key is required.');\n\n this.#apiKey = options.apiKey;\n this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/$/, '');\n this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);\n }\n\n async #request<T>(\n method: string,\n path: string,\n options: { query?: Record<string, string | number | undefined>; body?: unknown } = {},\n ): Promise<T> {\n const url = new URL(`${this.#baseUrl}${path}`);\n\n for (const [key, value] of Object.entries(options.query ?? {})) {\n if (value !== undefined) url.searchParams.set(key, String(value));\n }\n\n const response = await this.#fetch(url, {\n method,\n headers: {\n Authorization: `Bearer ${this.#apiKey}`,\n ...(options.body ? { 'Content-Type': 'application/json' } : {}),\n },\n body: options.body ? JSON.stringify(options.body) : undefined,\n });\n\n const requestId = response.headers.get('x-request-id') ?? undefined;\n const payload = (await response.json().catch(() => null)) as {\n data?: unknown;\n error?: { code: string; message: string; field?: string };\n } | null;\n\n if (!response.ok || payload?.error) {\n throw new PidgeonError({\n code: payload?.error?.code ?? 'internal',\n message: payload?.error?.message ?? `Request failed with ${response.status}.`,\n status: response.status,\n field: payload?.error?.field,\n requestId,\n });\n }\n\n return payload?.data as T;\n }\n\n // --- identities ---------------------------------------------------------\n\n identities = {\n list: () => this.#request<Identity[]>('GET', '/identities'),\n get: (id: string) => this.#request<Identity>('GET', `/identities/${id}`),\n create: (input: {\n domain_id: string;\n local_part: string;\n display_name?: string | null;\n type?: 'human' | 'team' | 'application' | 'workflow' | 'agent';\n mode?: 'human' | 'automated' | 'hybrid';\n }) => this.#request<Identity>('POST', '/identities', { body: input }),\n update: (\n id: string,\n input: {\n display_name?: string | null;\n mode?: 'human' | 'automated' | 'hybrid';\n capabilities?: Record<string, boolean>;\n status?: 'active' | 'suspended' | 'archived';\n },\n ) => this.#request<Identity>('PATCH', `/identities/${id}`, { body: input }),\n delete: (id: string) => this.#request<{ id: string }>('DELETE', `/identities/${id}`),\n };\n\n // --- messages -----------------------------------------------------------\n\n messages = {\n get: (id: string) => this.#request<Message>('GET', `/messages/${id}`),\n listInThread: (threadId: string, options: { limit?: number } = {}) =>\n this.#requestPage<Message>('GET', '/messages', {\n thread_id: threadId,\n limit: options.limit,\n }),\n send: (input: {\n from: string;\n to: string | string[];\n cc?: string | string[];\n bcc?: string | string[];\n subject?: string;\n text?: string;\n in_reply_to?: string | null;\n }) =>\n this.#request<{ id: string; thread_id: string; rejected: { address: string }[] }>(\n 'POST',\n '/messages',\n { body: input },\n ),\n };\n\n attachments = {\n /** A short-lived signed URL. Fetch it when you need it, don't store it. */\n downloadUrl: (id: string) =>\n this.#request<{ id: string; url: string }>('GET', `/attachments/${id}`),\n };\n\n // --- threads ------------------------------------------------------------\n\n threads = {\n list: (\n options: { identity_id?: string; status?: string; limit?: number; cursor?: string } = {},\n ) => this.#requestPage<Thread>('GET', '/threads', options),\n get: (id: string) => this.#request<Thread>('GET', `/threads/${id}`),\n update: (\n id: string,\n input: {\n status?: 'open' | 'waiting' | 'resolved' | 'human_review';\n owner_type?: 'human' | 'automation' | 'agent';\n },\n ) => this.#request<Thread>('PATCH', `/threads/${id}`, { body: input }),\n /** Hand the conversation to a person. The takeover primitive, named. */\n handOff: (id: string) =>\n this.#request<Thread>('PATCH', `/threads/${id}`, {\n body: { owner_type: 'human', status: 'human_review' },\n }),\n };\n\n // --- webhooks -----------------------------------------------------------\n\n webhooks = {\n list: () => this.#request<Webhook[]>('GET', '/webhooks'),\n /** The response carries `secret`, once. Store it before you discard it. */\n create: (input: { url: string; identity_id?: string | null; event_types?: string[] }) =>\n this.#request<Webhook & { secret: string }>('POST', '/webhooks', { body: input }),\n delete: (id: string) => this.#request<{ id: string }>('DELETE', `/webhooks/${id}`),\n };\n\n // --- events -------------------------------------------------------------\n\n events = {\n list: (\n options: { type?: string; identity_id?: string; limit?: number; cursor?: string } = {},\n ) => this.#requestPage<Event>('GET', '/events', options),\n get: (id: string) => this.#request<Event>('GET', `/events/${id}`),\n };\n\n domains = {\n /** Read-only. Adding and verifying a domain is a DNS-and-a-human task. */\n list: () => this.#request<Domain[]>('GET', '/domains'),\n };\n\n async #requestPage<T>(\n method: string,\n path: string,\n query: Record<string, string | number | undefined>,\n ): Promise<Page<T>> {\n const url = new URL(`${this.#baseUrl}${path}`);\n for (const [key, value] of Object.entries(query)) {\n if (value !== undefined) url.searchParams.set(key, String(value));\n }\n\n const response = await this.#fetch(url, {\n method,\n headers: { Authorization: `Bearer ${this.#apiKey}` },\n });\n\n const payload = (await response.json().catch(() => null)) as\n (Page<T> & { error?: { code: string; message: string; field?: string } }) | null;\n\n if (!response.ok || payload?.error) {\n throw new PidgeonError({\n code: payload?.error?.code ?? 'internal',\n message: payload?.error?.message ?? `Request failed with ${response.status}.`,\n status: response.status,\n field: payload?.error?.field,\n requestId: response.headers.get('x-request-id') ?? undefined,\n });\n }\n\n return {\n data: payload?.data ?? [],\n has_more: payload?.has_more ?? false,\n next_cursor: payload?.next_cursor ?? null,\n };\n }\n}\n","/**\n * Verifying a delivery.\n *\n * The only part of this package with a security contract: it is what a\n * customer runs to decide whether a request claiming a message arrived\n * actually came from us. Everything else here is a typed `fetch`.\n *\n * Two properties carry that contract, and both are tested against payloads\n * signed by the server's own implementation — see\n * `tests/unit/sdk-webhook-verifier.test.ts`. Testing this file against\n * itself would prove only that it agrees with itself, which is exactly the\n * bug two implementations of one scheme produce.\n */\n\nexport const SIGNATURE_HEADER = 'x-pidgeon-signature';\nexport const TIMESTAMP_HEADER = 'x-pidgeon-timestamp';\n\nexport interface WebhookEnvelope {\n id: string;\n type: string;\n created_at: string;\n data: Record<string, unknown>;\n}\n\n/**\n * Verifies a delivery and returns its payload.\n *\n * Throws rather than returning false, because the one thing this must not\n * allow is a caller who forgets to check the boolean. Uses `crypto.subtle`,\n * so it works unchanged in Node, Bun, Deno, Workers and Vercel functions.\n *\n * `rawBody` must be the exact bytes we sent. Re-serialising parsed JSON\n * changes key order and whitespace, and the signature will not match — read\n * the body as text before you parse it.\n */\nexport async function verifyWebhook(input: {\n rawBody: string;\n headers: Headers | Record<string, string | undefined>;\n secret: string;\n toleranceSeconds?: number;\n now?: Date;\n}): Promise<WebhookEnvelope> {\n const get = (name: string): string | undefined =>\n input.headers instanceof Headers\n ? (input.headers.get(name) ?? undefined)\n : (input.headers[name] ?? input.headers[name.toLowerCase()]);\n\n const timestamp = get(TIMESTAMP_HEADER);\n const signature = get(SIGNATURE_HEADER);\n\n if (!timestamp || !signature) throw new Error('This request is not signed.');\n\n const seconds = Number(timestamp);\n if (!Number.isFinite(seconds)) throw new Error('The signature timestamp is not a number.');\n\n const now = Math.floor((input.now ?? new Date()).getTime() / 1000);\n const tolerance = input.toleranceSeconds ?? 300;\n\n if (Math.abs(now - seconds) > tolerance) {\n throw new Error('This request is too old to trust. It may be a replay.');\n }\n\n const key = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(input.secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const mac = await crypto.subtle.sign(\n 'HMAC',\n key,\n new TextEncoder().encode(`${seconds}.${input.rawBody}`),\n );\n\n const expected = `v1=${[...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`;\n\n if (!constantTimeEqual(expected, signature)) {\n throw new Error('The signature does not match.');\n }\n\n return JSON.parse(input.rawBody) as WebhookEnvelope;\n}\n\n/** Length-independent compare. Not `===`, which returns on the first byte. */\nfunction constantTimeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n\n let difference = 0;\n for (let index = 0; index < a.length; index += 1) {\n difference |= a.charCodeAt(index) ^ b.charCodeAt(index);\n }\n\n return difference === 0;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACuBO,IAAM,eAAN,cAA2B,MAAM;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,YAAY,OAMT;AACD,UAAM,MAAM,OAAO;AACnB,SAAK,OAAO;AACZ,SAAK,OAAO,MAAM;AAClB,SAAK,SAAS,MAAM;AACpB,SAAK,QAAQ,MAAM;AACnB,SAAK,YAAY,MAAM;AAAA,EACzB;AACF;AAEA,IAAM,mBAAmB;AAElB,IAAM,UAAN,MAAc;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EAEA,YAAY,SAAyB;AACnC,QAAI,CAAC,QAAQ,OAAQ,OAAM,IAAI,MAAM,yBAAyB;AAE9D,SAAK,UAAU,QAAQ;AACvB,SAAK,YAAY,QAAQ,WAAW,kBAAkB,QAAQ,OAAO,EAAE;AACvE,SAAK,SAAS,QAAQ,SAAS,WAAW,MAAM,KAAK,UAAU;AAAA,EACjE;AAAA,EAEA,MAAM,SACJ,QACA,MACA,UAAmF,CAAC,GACxE;AACZ,UAAM,MAAM,IAAI,IAAI,GAAG,KAAK,QAAQ,GAAG,IAAI,EAAE;AAE7C,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,QAAQ,SAAS,CAAC,CAAC,GAAG;AAC9D,UAAI,UAAU,OAAW,KAAI,aAAa,IAAI,KAAK,OAAO,KAAK,CAAC;AAAA,IAClE;AAEA,UAAM,WAAW,MAAM,KAAK,OAAO,KAAK;AAAA,MACtC;AAAA,MACA,SAAS;AAAA,QACP,eAAe,UAAU,KAAK,OAAO;AAAA,QACrC,GAAI,QAAQ,OAAO,EAAE,gBAAgB,mBAAmB,IAAI,CAAC;AAAA,MAC/D;AAAA,MACA,MAAM,QAAQ,OAAO,KAAK,UAAU,QAAQ,IAAI,IAAI;AAAA,IACtD,CAAC;AAED,UAAM,YAAY,SAAS,QAAQ,IAAI,cAAc,KAAK;AAC1D,UAAM,UAAW,MAAM,SAAS,KAAK,EAAE,MAAM,MAAM,IAAI;AAKvD,QAAI,CAAC,SAAS,MAAM,SAAS,OAAO;AAClC,YAAM,IAAI,aAAa;AAAA,QACrB,MAAM,SAAS,OAAO,QAAQ;AAAA,QAC9B,SAAS,SAAS,OAAO,WAAW,uBAAuB,SAAS,MAAM;AAAA,QAC1E,QAAQ,SAAS;AAAA,QACjB,OAAO,SAAS,OAAO;AAAA,QACvB;AAAA,MACF,CAAC;AAAA,IACH;AAEA,WAAO,SAAS;AAAA,EAClB;AAAA;AAAA,EAIA,aAAa;AAAA,IACX,MAAM,MAAM,KAAK,SAAqB,OAAO,aAAa;AAAA,IAC1D,KAAK,CAAC,OAAe,KAAK,SAAmB,OAAO,eAAe,EAAE,EAAE;AAAA,IACvE,QAAQ,CAAC,UAMH,KAAK,SAAmB,QAAQ,eAAe,EAAE,MAAM,MAAM,CAAC;AAAA,IACpE,QAAQ,CACN,IACA,UAMG,KAAK,SAAmB,SAAS,eAAe,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAAA,IAC1E,QAAQ,CAAC,OAAe,KAAK,SAAyB,UAAU,eAAe,EAAE,EAAE;AAAA,EACrF;AAAA;AAAA,EAIA,WAAW;AAAA,IACT,KAAK,CAAC,OAAe,KAAK,SAAkB,OAAO,aAAa,EAAE,EAAE;AAAA,IACpE,cAAc,CAAC,UAAkB,UAA8B,CAAC,MAC9D,KAAK,aAAsB,OAAO,aAAa;AAAA,MAC7C,WAAW;AAAA,MACX,OAAO,QAAQ;AAAA,IACjB,CAAC;AAAA,IACH,MAAM,CAAC,UASL,KAAK;AAAA,MACH;AAAA,MACA;AAAA,MACA,EAAE,MAAM,MAAM;AAAA,IAChB;AAAA,EACJ;AAAA,EAEA,cAAc;AAAA;AAAA,IAEZ,aAAa,CAAC,OACZ,KAAK,SAAsC,OAAO,gBAAgB,EAAE,EAAE;AAAA,EAC1E;AAAA;AAAA,EAIA,UAAU;AAAA,IACR,MAAM,CACJ,UAAsF,CAAC,MACpF,KAAK,aAAqB,OAAO,YAAY,OAAO;AAAA,IACzD,KAAK,CAAC,OAAe,KAAK,SAAiB,OAAO,YAAY,EAAE,EAAE;AAAA,IAClE,QAAQ,CACN,IACA,UAIG,KAAK,SAAiB,SAAS,YAAY,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAAA;AAAA,IAErE,SAAS,CAAC,OACR,KAAK,SAAiB,SAAS,YAAY,EAAE,IAAI;AAAA,MAC/C,MAAM,EAAE,YAAY,SAAS,QAAQ,eAAe;AAAA,IACtD,CAAC;AAAA,EACL;AAAA;AAAA,EAIA,WAAW;AAAA,IACT,MAAM,MAAM,KAAK,SAAoB,OAAO,WAAW;AAAA;AAAA,IAEvD,QAAQ,CAAC,UACP,KAAK,SAAuC,QAAQ,aAAa,EAAE,MAAM,MAAM,CAAC;AAAA,IAClF,QAAQ,CAAC,OAAe,KAAK,SAAyB,UAAU,aAAa,EAAE,EAAE;AAAA,EACnF;AAAA;AAAA,EAIA,SAAS;AAAA,IACP,MAAM,CACJ,UAAoF,CAAC,MAClF,KAAK,aAAoB,OAAO,WAAW,OAAO;AAAA,IACvD,KAAK,CAAC,OAAe,KAAK,SAAgB,OAAO,WAAW,EAAE,EAAE;AAAA,EAClE;AAAA,EAEA,UAAU;AAAA;AAAA,IAER,MAAM,MAAM,KAAK,SAAmB,OAAO,UAAU;AAAA,EACvD;AAAA,EAEA,MAAM,aACJ,QACA,MACA,OACkB;AAClB,UAAM,MAAM,IAAI,IAAI,GAAG,KAAK,QAAQ,GAAG,IAAI,EAAE;AAC7C,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,UAAI,UAAU,OAAW,KAAI,aAAa,IAAI,KAAK,OAAO,KAAK,CAAC;AAAA,IAClE;AAEA,UAAM,WAAW,MAAM,KAAK,OAAO,KAAK;AAAA,MACtC;AAAA,MACA,SAAS,EAAE,eAAe,UAAU,KAAK,OAAO,GAAG;AAAA,IACrD,CAAC;AAED,UAAM,UAAW,MAAM,SAAS,KAAK,EAAE,MAAM,MAAM,IAAI;AAGvD,QAAI,CAAC,SAAS,MAAM,SAAS,OAAO;AAClC,YAAM,IAAI,aAAa;AAAA,QACrB,MAAM,SAAS,OAAO,QAAQ;AAAA,QAC9B,SAAS,SAAS,OAAO,WAAW,uBAAuB,SAAS,MAAM;AAAA,QAC1E,QAAQ,SAAS;AAAA,QACjB,OAAO,SAAS,OAAO;AAAA,QACvB,WAAW,SAAS,QAAQ,IAAI,cAAc,KAAK;AAAA,MACrD,CAAC;AAAA,IACH;AAEA,WAAO;AAAA,MACL,MAAM,SAAS,QAAQ,CAAC;AAAA,MACxB,UAAU,SAAS,YAAY;AAAA,MAC/B,aAAa,SAAS,eAAe;AAAA,IACvC;AAAA,EACF;AACF;;;AC3NO,IAAM,mBAAmB;AACzB,IAAM,mBAAmB;AAoBhC,eAAsB,cAAc,OAMP;AAC3B,QAAM,MAAM,CAAC,SACX,MAAM,mBAAmB,UACpB,MAAM,QAAQ,IAAI,IAAI,KAAK,SAC3B,MAAM,QAAQ,IAAI,KAAK,MAAM,QAAQ,KAAK,YAAY,CAAC;AAE9D,QAAM,YAAY,IAAI,gBAAgB;AACtC,QAAM,YAAY,IAAI,gBAAgB;AAEtC,MAAI,CAAC,aAAa,CAAC,UAAW,OAAM,IAAI,MAAM,6BAA6B;AAE3E,QAAM,UAAU,OAAO,SAAS;AAChC,MAAI,CAAC,OAAO,SAAS,OAAO,EAAG,OAAM,IAAI,MAAM,0CAA0C;AAEzF,QAAM,MAAM,KAAK,OAAO,MAAM,OAAO,oBAAI,KAAK,GAAG,QAAQ,IAAI,GAAI;AACjE,QAAM,YAAY,MAAM,oBAAoB;AAE5C,MAAI,KAAK,IAAI,MAAM,OAAO,IAAI,WAAW;AACvC,UAAM,IAAI,MAAM,uDAAuD;AAAA,EACzE;AAEA,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,YAAY,EAAE,OAAO,MAAM,MAAM;AAAA,IACrC,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AAEA,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA;AAAA,IACA,IAAI,YAAY,EAAE,OAAO,GAAG,OAAO,IAAI,MAAM,OAAO,EAAE;AAAA,EACxD;AAEA,QAAM,WAAW,MAAM,CAAC,GAAG,IAAI,WAAW,GAAG,CAAC,EAAE,IAAI,CAAC,SAAS,KAAK,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC;AAE1G,MAAI,CAAC,kBAAkB,UAAU,SAAS,GAAG;AAC3C,UAAM,IAAI,MAAM,+BAA+B;AAAA,EACjD;AAEA,SAAO,KAAK,MAAM,MAAM,OAAO;AACjC;AAGA,SAAS,kBAAkB,GAAW,GAAoB;AACxD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAElC,MAAI,aAAa;AACjB,WAAS,QAAQ,GAAG,QAAQ,EAAE,QAAQ,SAAS,GAAG;AAChD,kBAAc,EAAE,WAAW,KAAK,IAAI,EAAE,WAAW,KAAK;AAAA,EACxD;AAEA,SAAO,eAAe;AACxB;","names":[]}
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The wire format.
3
+ *
4
+ * **This is the single definition of what the API returns**, and it lives
5
+ * here rather than in the application on purpose: the wire format *is* the
6
+ * public contract, and this package is the artifact that contract is
7
+ * published as.
8
+ *
9
+ * `src/lib/api/dto.ts` imports these types — with `import type`, so nothing
10
+ * of this package reaches the application at runtime — and every mapper
11
+ * there is checked against them. A field renamed in the application is a
12
+ * compile error in the application, immediately, rather than a silent lie
13
+ * in a customer's editor a release later. That was the alternative worth
14
+ * avoiding: two hand-maintained copies of the same shapes, which is what
15
+ * this replaced.
16
+ *
17
+ * Conventions, and they are not negotiable per-field: `snake_case`,
18
+ * ISO-8601 timestamps, ids as opaque strings. Nothing here ever carries a
19
+ * secret, a hash, a spam score or an internal provider id.
20
+ */
21
+ interface Address {
22
+ email: string;
23
+ name?: string | null;
24
+ }
25
+ interface Attachment {
26
+ id: string;
27
+ filename: string;
28
+ content_type: string;
29
+ size: number;
30
+ /** False when there is no object store behind the metadata. */
31
+ downloadable: boolean;
32
+ }
33
+ interface Identity {
34
+ id: string;
35
+ email: string;
36
+ local_part: string;
37
+ domain: string;
38
+ display_name: string | null;
39
+ /** `human`, `team`, `application`, `workflow` or `agent`. */
40
+ type: string;
41
+ /** Who answers: `human`, `automated` or `hybrid`. */
42
+ mode: string;
43
+ status: string;
44
+ capabilities: Record<string, boolean>;
45
+ /** False for an address that turns mail into events and stores nothing. */
46
+ has_inbox: boolean;
47
+ routed_address_count: number;
48
+ created_at: string;
49
+ }
50
+ interface Message {
51
+ id: string;
52
+ thread_id: string;
53
+ identity: string | null;
54
+ direction: string;
55
+ from: Address;
56
+ to: Address[];
57
+ cc: Address[];
58
+ subject: string | null;
59
+ /** The envelope recipient, which is not always in `to`. */
60
+ delivered_to: string | null;
61
+ text: string | null;
62
+ /** Sanitised server-side. Our policy travels with the content. */
63
+ html: string;
64
+ is_read: boolean;
65
+ is_spam: boolean;
66
+ attachments: Attachment[];
67
+ created_at: string;
68
+ }
69
+ interface Thread {
70
+ id: string;
71
+ identity: string;
72
+ subject: string | null;
73
+ status: 'open' | 'waiting' | 'resolved' | 'human_review';
74
+ owner_type: 'human' | 'automation' | 'agent';
75
+ message_count: number;
76
+ last_message_at: string | null;
77
+ created_at: string;
78
+ }
79
+ interface Webhook {
80
+ id: string;
81
+ url: string;
82
+ identity: string | null;
83
+ event_types: string[];
84
+ status: string;
85
+ consecutive_failures: number;
86
+ last_delivered_at: string | null;
87
+ last_failed_at: string | null;
88
+ created_at: string;
89
+ }
90
+ interface Event {
91
+ id: string;
92
+ type: string;
93
+ identity: string | null;
94
+ message_id: string | null;
95
+ thread_id: string | null;
96
+ /** Ids and metadata. An event never carries a message body. */
97
+ data: Record<string, unknown>;
98
+ created_at: string;
99
+ }
100
+ interface RoutingRule {
101
+ id: string;
102
+ /** The address or pattern this rule matches. */
103
+ match: string;
104
+ /** `exact` for an address, `catch_all` for the domain's fallback. */
105
+ match_type: string;
106
+ /** `identity` for an inbox, `webhook` for a URL. */
107
+ destination_type: string;
108
+ destination: string;
109
+ status: string;
110
+ domain: string;
111
+ created_at: string;
112
+ }
113
+ interface Domain {
114
+ id: string;
115
+ domain: string;
116
+ status: string;
117
+ catch_all_enabled: boolean;
118
+ identity_count: number;
119
+ verified_at: string | null;
120
+ created_at: string;
121
+ }
122
+ /**
123
+ * A page of results.
124
+ *
125
+ * Deliberately not an async iterator. Hiding pagination hides the rate
126
+ * limit that pagination is subject to, and the application calling us is
127
+ * better placed to decide what to do when it is hit.
128
+ */
129
+ interface Page<T> {
130
+ data: T[];
131
+ has_more: boolean;
132
+ next_cursor: string | null;
133
+ }
134
+
135
+ /**
136
+ * The Pidgeon client.
137
+ *
138
+ * Deliberately thin: typed wrappers over `fetch`, one error class, and
139
+ * nothing else. No retry policy, no queue, no caching, no ambient state —
140
+ * those are decisions that belong to the application, and an SDK that makes
141
+ * them for you is an SDK you eventually have to work around.
142
+ *
143
+ * Zero dependencies. The only runtime requirements are `fetch` and
144
+ * `crypto.subtle`, both standard in Node 18+, Bun, Deno and every edge
145
+ * runtime.
146
+ */
147
+ interface PidgeonOptions {
148
+ apiKey: string;
149
+ /** Defaults to the hosted API. Point this at a local dev server to test. */
150
+ baseUrl?: string;
151
+ /** Injected in tests. Defaults to the global. */
152
+ fetch?: typeof globalThis.fetch;
153
+ }
154
+ declare class PidgeonError extends Error {
155
+ readonly code: string;
156
+ readonly status: number;
157
+ readonly field?: string;
158
+ readonly requestId?: string;
159
+ constructor(input: {
160
+ code: string;
161
+ message: string;
162
+ status: number;
163
+ field?: string;
164
+ requestId?: string;
165
+ });
166
+ }
167
+ declare class Pidgeon {
168
+ #private;
169
+ constructor(options: PidgeonOptions);
170
+ identities: {
171
+ list: () => Promise<Identity[]>;
172
+ get: (id: string) => Promise<Identity>;
173
+ create: (input: {
174
+ domain_id: string;
175
+ local_part: string;
176
+ display_name?: string | null;
177
+ type?: "human" | "team" | "application" | "workflow" | "agent";
178
+ mode?: "human" | "automated" | "hybrid";
179
+ }) => Promise<Identity>;
180
+ update: (id: string, input: {
181
+ display_name?: string | null;
182
+ mode?: "human" | "automated" | "hybrid";
183
+ capabilities?: Record<string, boolean>;
184
+ status?: "active" | "suspended" | "archived";
185
+ }) => Promise<Identity>;
186
+ delete: (id: string) => Promise<{
187
+ id: string;
188
+ }>;
189
+ };
190
+ messages: {
191
+ get: (id: string) => Promise<Message>;
192
+ listInThread: (threadId: string, options?: {
193
+ limit?: number;
194
+ }) => Promise<Page<Message>>;
195
+ send: (input: {
196
+ from: string;
197
+ to: string | string[];
198
+ cc?: string | string[];
199
+ bcc?: string | string[];
200
+ subject?: string;
201
+ text?: string;
202
+ in_reply_to?: string | null;
203
+ }) => Promise<{
204
+ id: string;
205
+ thread_id: string;
206
+ rejected: {
207
+ address: string;
208
+ }[];
209
+ }>;
210
+ };
211
+ attachments: {
212
+ /** A short-lived signed URL. Fetch it when you need it, don't store it. */
213
+ downloadUrl: (id: string) => Promise<{
214
+ id: string;
215
+ url: string;
216
+ }>;
217
+ };
218
+ threads: {
219
+ list: (options?: {
220
+ identity_id?: string;
221
+ status?: string;
222
+ limit?: number;
223
+ cursor?: string;
224
+ }) => Promise<Page<Thread>>;
225
+ get: (id: string) => Promise<Thread>;
226
+ update: (id: string, input: {
227
+ status?: "open" | "waiting" | "resolved" | "human_review";
228
+ owner_type?: "human" | "automation" | "agent";
229
+ }) => Promise<Thread>;
230
+ /** Hand the conversation to a person. The takeover primitive, named. */
231
+ handOff: (id: string) => Promise<Thread>;
232
+ };
233
+ webhooks: {
234
+ list: () => Promise<Webhook[]>;
235
+ /** The response carries `secret`, once. Store it before you discard it. */
236
+ create: (input: {
237
+ url: string;
238
+ identity_id?: string | null;
239
+ event_types?: string[];
240
+ }) => Promise<Webhook & {
241
+ secret: string;
242
+ }>;
243
+ delete: (id: string) => Promise<{
244
+ id: string;
245
+ }>;
246
+ };
247
+ events: {
248
+ list: (options?: {
249
+ type?: string;
250
+ identity_id?: string;
251
+ limit?: number;
252
+ cursor?: string;
253
+ }) => Promise<Page<Event>>;
254
+ get: (id: string) => Promise<Event>;
255
+ };
256
+ domains: {
257
+ /** Read-only. Adding and verifying a domain is a DNS-and-a-human task. */
258
+ list: () => Promise<Domain[]>;
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Verifying a delivery.
264
+ *
265
+ * The only part of this package with a security contract: it is what a
266
+ * customer runs to decide whether a request claiming a message arrived
267
+ * actually came from us. Everything else here is a typed `fetch`.
268
+ *
269
+ * Two properties carry that contract, and both are tested against payloads
270
+ * signed by the server's own implementation — see
271
+ * `tests/unit/sdk-webhook-verifier.test.ts`. Testing this file against
272
+ * itself would prove only that it agrees with itself, which is exactly the
273
+ * bug two implementations of one scheme produce.
274
+ */
275
+ declare const SIGNATURE_HEADER = "x-pidgeon-signature";
276
+ declare const TIMESTAMP_HEADER = "x-pidgeon-timestamp";
277
+ interface WebhookEnvelope {
278
+ id: string;
279
+ type: string;
280
+ created_at: string;
281
+ data: Record<string, unknown>;
282
+ }
283
+ /**
284
+ * Verifies a delivery and returns its payload.
285
+ *
286
+ * Throws rather than returning false, because the one thing this must not
287
+ * allow is a caller who forgets to check the boolean. Uses `crypto.subtle`,
288
+ * so it works unchanged in Node, Bun, Deno, Workers and Vercel functions.
289
+ *
290
+ * `rawBody` must be the exact bytes we sent. Re-serialising parsed JSON
291
+ * changes key order and whitespace, and the signature will not match — read
292
+ * the body as text before you parse it.
293
+ */
294
+ declare function verifyWebhook(input: {
295
+ rawBody: string;
296
+ headers: Headers | Record<string, string | undefined>;
297
+ secret: string;
298
+ toleranceSeconds?: number;
299
+ now?: Date;
300
+ }): Promise<WebhookEnvelope>;
301
+
302
+ export { type Address, type Attachment, type Domain, type Event, type Identity, type Message, type Page, Pidgeon, PidgeonError, type PidgeonOptions, type RoutingRule, SIGNATURE_HEADER, TIMESTAMP_HEADER, type Thread, type Webhook, type WebhookEnvelope, verifyWebhook };
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The wire format.
3
+ *
4
+ * **This is the single definition of what the API returns**, and it lives
5
+ * here rather than in the application on purpose: the wire format *is* the
6
+ * public contract, and this package is the artifact that contract is
7
+ * published as.
8
+ *
9
+ * `src/lib/api/dto.ts` imports these types — with `import type`, so nothing
10
+ * of this package reaches the application at runtime — and every mapper
11
+ * there is checked against them. A field renamed in the application is a
12
+ * compile error in the application, immediately, rather than a silent lie
13
+ * in a customer's editor a release later. That was the alternative worth
14
+ * avoiding: two hand-maintained copies of the same shapes, which is what
15
+ * this replaced.
16
+ *
17
+ * Conventions, and they are not negotiable per-field: `snake_case`,
18
+ * ISO-8601 timestamps, ids as opaque strings. Nothing here ever carries a
19
+ * secret, a hash, a spam score or an internal provider id.
20
+ */
21
+ interface Address {
22
+ email: string;
23
+ name?: string | null;
24
+ }
25
+ interface Attachment {
26
+ id: string;
27
+ filename: string;
28
+ content_type: string;
29
+ size: number;
30
+ /** False when there is no object store behind the metadata. */
31
+ downloadable: boolean;
32
+ }
33
+ interface Identity {
34
+ id: string;
35
+ email: string;
36
+ local_part: string;
37
+ domain: string;
38
+ display_name: string | null;
39
+ /** `human`, `team`, `application`, `workflow` or `agent`. */
40
+ type: string;
41
+ /** Who answers: `human`, `automated` or `hybrid`. */
42
+ mode: string;
43
+ status: string;
44
+ capabilities: Record<string, boolean>;
45
+ /** False for an address that turns mail into events and stores nothing. */
46
+ has_inbox: boolean;
47
+ routed_address_count: number;
48
+ created_at: string;
49
+ }
50
+ interface Message {
51
+ id: string;
52
+ thread_id: string;
53
+ identity: string | null;
54
+ direction: string;
55
+ from: Address;
56
+ to: Address[];
57
+ cc: Address[];
58
+ subject: string | null;
59
+ /** The envelope recipient, which is not always in `to`. */
60
+ delivered_to: string | null;
61
+ text: string | null;
62
+ /** Sanitised server-side. Our policy travels with the content. */
63
+ html: string;
64
+ is_read: boolean;
65
+ is_spam: boolean;
66
+ attachments: Attachment[];
67
+ created_at: string;
68
+ }
69
+ interface Thread {
70
+ id: string;
71
+ identity: string;
72
+ subject: string | null;
73
+ status: 'open' | 'waiting' | 'resolved' | 'human_review';
74
+ owner_type: 'human' | 'automation' | 'agent';
75
+ message_count: number;
76
+ last_message_at: string | null;
77
+ created_at: string;
78
+ }
79
+ interface Webhook {
80
+ id: string;
81
+ url: string;
82
+ identity: string | null;
83
+ event_types: string[];
84
+ status: string;
85
+ consecutive_failures: number;
86
+ last_delivered_at: string | null;
87
+ last_failed_at: string | null;
88
+ created_at: string;
89
+ }
90
+ interface Event {
91
+ id: string;
92
+ type: string;
93
+ identity: string | null;
94
+ message_id: string | null;
95
+ thread_id: string | null;
96
+ /** Ids and metadata. An event never carries a message body. */
97
+ data: Record<string, unknown>;
98
+ created_at: string;
99
+ }
100
+ interface RoutingRule {
101
+ id: string;
102
+ /** The address or pattern this rule matches. */
103
+ match: string;
104
+ /** `exact` for an address, `catch_all` for the domain's fallback. */
105
+ match_type: string;
106
+ /** `identity` for an inbox, `webhook` for a URL. */
107
+ destination_type: string;
108
+ destination: string;
109
+ status: string;
110
+ domain: string;
111
+ created_at: string;
112
+ }
113
+ interface Domain {
114
+ id: string;
115
+ domain: string;
116
+ status: string;
117
+ catch_all_enabled: boolean;
118
+ identity_count: number;
119
+ verified_at: string | null;
120
+ created_at: string;
121
+ }
122
+ /**
123
+ * A page of results.
124
+ *
125
+ * Deliberately not an async iterator. Hiding pagination hides the rate
126
+ * limit that pagination is subject to, and the application calling us is
127
+ * better placed to decide what to do when it is hit.
128
+ */
129
+ interface Page<T> {
130
+ data: T[];
131
+ has_more: boolean;
132
+ next_cursor: string | null;
133
+ }
134
+
135
+ /**
136
+ * The Pidgeon client.
137
+ *
138
+ * Deliberately thin: typed wrappers over `fetch`, one error class, and
139
+ * nothing else. No retry policy, no queue, no caching, no ambient state —
140
+ * those are decisions that belong to the application, and an SDK that makes
141
+ * them for you is an SDK you eventually have to work around.
142
+ *
143
+ * Zero dependencies. The only runtime requirements are `fetch` and
144
+ * `crypto.subtle`, both standard in Node 18+, Bun, Deno and every edge
145
+ * runtime.
146
+ */
147
+ interface PidgeonOptions {
148
+ apiKey: string;
149
+ /** Defaults to the hosted API. Point this at a local dev server to test. */
150
+ baseUrl?: string;
151
+ /** Injected in tests. Defaults to the global. */
152
+ fetch?: typeof globalThis.fetch;
153
+ }
154
+ declare class PidgeonError extends Error {
155
+ readonly code: string;
156
+ readonly status: number;
157
+ readonly field?: string;
158
+ readonly requestId?: string;
159
+ constructor(input: {
160
+ code: string;
161
+ message: string;
162
+ status: number;
163
+ field?: string;
164
+ requestId?: string;
165
+ });
166
+ }
167
+ declare class Pidgeon {
168
+ #private;
169
+ constructor(options: PidgeonOptions);
170
+ identities: {
171
+ list: () => Promise<Identity[]>;
172
+ get: (id: string) => Promise<Identity>;
173
+ create: (input: {
174
+ domain_id: string;
175
+ local_part: string;
176
+ display_name?: string | null;
177
+ type?: "human" | "team" | "application" | "workflow" | "agent";
178
+ mode?: "human" | "automated" | "hybrid";
179
+ }) => Promise<Identity>;
180
+ update: (id: string, input: {
181
+ display_name?: string | null;
182
+ mode?: "human" | "automated" | "hybrid";
183
+ capabilities?: Record<string, boolean>;
184
+ status?: "active" | "suspended" | "archived";
185
+ }) => Promise<Identity>;
186
+ delete: (id: string) => Promise<{
187
+ id: string;
188
+ }>;
189
+ };
190
+ messages: {
191
+ get: (id: string) => Promise<Message>;
192
+ listInThread: (threadId: string, options?: {
193
+ limit?: number;
194
+ }) => Promise<Page<Message>>;
195
+ send: (input: {
196
+ from: string;
197
+ to: string | string[];
198
+ cc?: string | string[];
199
+ bcc?: string | string[];
200
+ subject?: string;
201
+ text?: string;
202
+ in_reply_to?: string | null;
203
+ }) => Promise<{
204
+ id: string;
205
+ thread_id: string;
206
+ rejected: {
207
+ address: string;
208
+ }[];
209
+ }>;
210
+ };
211
+ attachments: {
212
+ /** A short-lived signed URL. Fetch it when you need it, don't store it. */
213
+ downloadUrl: (id: string) => Promise<{
214
+ id: string;
215
+ url: string;
216
+ }>;
217
+ };
218
+ threads: {
219
+ list: (options?: {
220
+ identity_id?: string;
221
+ status?: string;
222
+ limit?: number;
223
+ cursor?: string;
224
+ }) => Promise<Page<Thread>>;
225
+ get: (id: string) => Promise<Thread>;
226
+ update: (id: string, input: {
227
+ status?: "open" | "waiting" | "resolved" | "human_review";
228
+ owner_type?: "human" | "automation" | "agent";
229
+ }) => Promise<Thread>;
230
+ /** Hand the conversation to a person. The takeover primitive, named. */
231
+ handOff: (id: string) => Promise<Thread>;
232
+ };
233
+ webhooks: {
234
+ list: () => Promise<Webhook[]>;
235
+ /** The response carries `secret`, once. Store it before you discard it. */
236
+ create: (input: {
237
+ url: string;
238
+ identity_id?: string | null;
239
+ event_types?: string[];
240
+ }) => Promise<Webhook & {
241
+ secret: string;
242
+ }>;
243
+ delete: (id: string) => Promise<{
244
+ id: string;
245
+ }>;
246
+ };
247
+ events: {
248
+ list: (options?: {
249
+ type?: string;
250
+ identity_id?: string;
251
+ limit?: number;
252
+ cursor?: string;
253
+ }) => Promise<Page<Event>>;
254
+ get: (id: string) => Promise<Event>;
255
+ };
256
+ domains: {
257
+ /** Read-only. Adding and verifying a domain is a DNS-and-a-human task. */
258
+ list: () => Promise<Domain[]>;
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Verifying a delivery.
264
+ *
265
+ * The only part of this package with a security contract: it is what a
266
+ * customer runs to decide whether a request claiming a message arrived
267
+ * actually came from us. Everything else here is a typed `fetch`.
268
+ *
269
+ * Two properties carry that contract, and both are tested against payloads
270
+ * signed by the server's own implementation — see
271
+ * `tests/unit/sdk-webhook-verifier.test.ts`. Testing this file against
272
+ * itself would prove only that it agrees with itself, which is exactly the
273
+ * bug two implementations of one scheme produce.
274
+ */
275
+ declare const SIGNATURE_HEADER = "x-pidgeon-signature";
276
+ declare const TIMESTAMP_HEADER = "x-pidgeon-timestamp";
277
+ interface WebhookEnvelope {
278
+ id: string;
279
+ type: string;
280
+ created_at: string;
281
+ data: Record<string, unknown>;
282
+ }
283
+ /**
284
+ * Verifies a delivery and returns its payload.
285
+ *
286
+ * Throws rather than returning false, because the one thing this must not
287
+ * allow is a caller who forgets to check the boolean. Uses `crypto.subtle`,
288
+ * so it works unchanged in Node, Bun, Deno, Workers and Vercel functions.
289
+ *
290
+ * `rawBody` must be the exact bytes we sent. Re-serialising parsed JSON
291
+ * changes key order and whitespace, and the signature will not match — read
292
+ * the body as text before you parse it.
293
+ */
294
+ declare function verifyWebhook(input: {
295
+ rawBody: string;
296
+ headers: Headers | Record<string, string | undefined>;
297
+ secret: string;
298
+ toleranceSeconds?: number;
299
+ now?: Date;
300
+ }): Promise<WebhookEnvelope>;
301
+
302
+ export { type Address, type Attachment, type Domain, type Event, type Identity, type Message, type Page, Pidgeon, PidgeonError, type PidgeonOptions, type RoutingRule, SIGNATURE_HEADER, TIMESTAMP_HEADER, type Thread, type Webhook, type WebhookEnvelope, verifyWebhook };
package/dist/index.js ADDED
@@ -0,0 +1,179 @@
1
+ // src/client.ts
2
+ var PidgeonError = class extends Error {
3
+ code;
4
+ status;
5
+ field;
6
+ requestId;
7
+ constructor(input) {
8
+ super(input.message);
9
+ this.name = "PidgeonError";
10
+ this.code = input.code;
11
+ this.status = input.status;
12
+ this.field = input.field;
13
+ this.requestId = input.requestId;
14
+ }
15
+ };
16
+ var DEFAULT_BASE_URL = "https://pidgeon.ai/api/v1";
17
+ var Pidgeon = class {
18
+ #apiKey;
19
+ #baseUrl;
20
+ #fetch;
21
+ constructor(options) {
22
+ if (!options.apiKey) throw new Error("An API key is required.");
23
+ this.#apiKey = options.apiKey;
24
+ this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
25
+ this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
26
+ }
27
+ async #request(method, path, options = {}) {
28
+ const url = new URL(`${this.#baseUrl}${path}`);
29
+ for (const [key, value] of Object.entries(options.query ?? {})) {
30
+ if (value !== void 0) url.searchParams.set(key, String(value));
31
+ }
32
+ const response = await this.#fetch(url, {
33
+ method,
34
+ headers: {
35
+ Authorization: `Bearer ${this.#apiKey}`,
36
+ ...options.body ? { "Content-Type": "application/json" } : {}
37
+ },
38
+ body: options.body ? JSON.stringify(options.body) : void 0
39
+ });
40
+ const requestId = response.headers.get("x-request-id") ?? void 0;
41
+ const payload = await response.json().catch(() => null);
42
+ if (!response.ok || payload?.error) {
43
+ throw new PidgeonError({
44
+ code: payload?.error?.code ?? "internal",
45
+ message: payload?.error?.message ?? `Request failed with ${response.status}.`,
46
+ status: response.status,
47
+ field: payload?.error?.field,
48
+ requestId
49
+ });
50
+ }
51
+ return payload?.data;
52
+ }
53
+ // --- identities ---------------------------------------------------------
54
+ identities = {
55
+ list: () => this.#request("GET", "/identities"),
56
+ get: (id) => this.#request("GET", `/identities/${id}`),
57
+ create: (input) => this.#request("POST", "/identities", { body: input }),
58
+ update: (id, input) => this.#request("PATCH", `/identities/${id}`, { body: input }),
59
+ delete: (id) => this.#request("DELETE", `/identities/${id}`)
60
+ };
61
+ // --- messages -----------------------------------------------------------
62
+ messages = {
63
+ get: (id) => this.#request("GET", `/messages/${id}`),
64
+ listInThread: (threadId, options = {}) => this.#requestPage("GET", "/messages", {
65
+ thread_id: threadId,
66
+ limit: options.limit
67
+ }),
68
+ send: (input) => this.#request(
69
+ "POST",
70
+ "/messages",
71
+ { body: input }
72
+ )
73
+ };
74
+ attachments = {
75
+ /** A short-lived signed URL. Fetch it when you need it, don't store it. */
76
+ downloadUrl: (id) => this.#request("GET", `/attachments/${id}`)
77
+ };
78
+ // --- threads ------------------------------------------------------------
79
+ threads = {
80
+ list: (options = {}) => this.#requestPage("GET", "/threads", options),
81
+ get: (id) => this.#request("GET", `/threads/${id}`),
82
+ update: (id, input) => this.#request("PATCH", `/threads/${id}`, { body: input }),
83
+ /** Hand the conversation to a person. The takeover primitive, named. */
84
+ handOff: (id) => this.#request("PATCH", `/threads/${id}`, {
85
+ body: { owner_type: "human", status: "human_review" }
86
+ })
87
+ };
88
+ // --- webhooks -----------------------------------------------------------
89
+ webhooks = {
90
+ list: () => this.#request("GET", "/webhooks"),
91
+ /** The response carries `secret`, once. Store it before you discard it. */
92
+ create: (input) => this.#request("POST", "/webhooks", { body: input }),
93
+ delete: (id) => this.#request("DELETE", `/webhooks/${id}`)
94
+ };
95
+ // --- events -------------------------------------------------------------
96
+ events = {
97
+ list: (options = {}) => this.#requestPage("GET", "/events", options),
98
+ get: (id) => this.#request("GET", `/events/${id}`)
99
+ };
100
+ domains = {
101
+ /** Read-only. Adding and verifying a domain is a DNS-and-a-human task. */
102
+ list: () => this.#request("GET", "/domains")
103
+ };
104
+ async #requestPage(method, path, query) {
105
+ const url = new URL(`${this.#baseUrl}${path}`);
106
+ for (const [key, value] of Object.entries(query)) {
107
+ if (value !== void 0) url.searchParams.set(key, String(value));
108
+ }
109
+ const response = await this.#fetch(url, {
110
+ method,
111
+ headers: { Authorization: `Bearer ${this.#apiKey}` }
112
+ });
113
+ const payload = await response.json().catch(() => null);
114
+ if (!response.ok || payload?.error) {
115
+ throw new PidgeonError({
116
+ code: payload?.error?.code ?? "internal",
117
+ message: payload?.error?.message ?? `Request failed with ${response.status}.`,
118
+ status: response.status,
119
+ field: payload?.error?.field,
120
+ requestId: response.headers.get("x-request-id") ?? void 0
121
+ });
122
+ }
123
+ return {
124
+ data: payload?.data ?? [],
125
+ has_more: payload?.has_more ?? false,
126
+ next_cursor: payload?.next_cursor ?? null
127
+ };
128
+ }
129
+ };
130
+
131
+ // src/webhooks.ts
132
+ var SIGNATURE_HEADER = "x-pidgeon-signature";
133
+ var TIMESTAMP_HEADER = "x-pidgeon-timestamp";
134
+ async function verifyWebhook(input) {
135
+ const get = (name) => input.headers instanceof Headers ? input.headers.get(name) ?? void 0 : input.headers[name] ?? input.headers[name.toLowerCase()];
136
+ const timestamp = get(TIMESTAMP_HEADER);
137
+ const signature = get(SIGNATURE_HEADER);
138
+ if (!timestamp || !signature) throw new Error("This request is not signed.");
139
+ const seconds = Number(timestamp);
140
+ if (!Number.isFinite(seconds)) throw new Error("The signature timestamp is not a number.");
141
+ const now = Math.floor((input.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
142
+ const tolerance = input.toleranceSeconds ?? 300;
143
+ if (Math.abs(now - seconds) > tolerance) {
144
+ throw new Error("This request is too old to trust. It may be a replay.");
145
+ }
146
+ const key = await crypto.subtle.importKey(
147
+ "raw",
148
+ new TextEncoder().encode(input.secret),
149
+ { name: "HMAC", hash: "SHA-256" },
150
+ false,
151
+ ["sign"]
152
+ );
153
+ const mac = await crypto.subtle.sign(
154
+ "HMAC",
155
+ key,
156
+ new TextEncoder().encode(`${seconds}.${input.rawBody}`)
157
+ );
158
+ const expected = `v1=${[...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
159
+ if (!constantTimeEqual(expected, signature)) {
160
+ throw new Error("The signature does not match.");
161
+ }
162
+ return JSON.parse(input.rawBody);
163
+ }
164
+ function constantTimeEqual(a, b) {
165
+ if (a.length !== b.length) return false;
166
+ let difference = 0;
167
+ for (let index = 0; index < a.length; index += 1) {
168
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
169
+ }
170
+ return difference === 0;
171
+ }
172
+ export {
173
+ Pidgeon,
174
+ PidgeonError,
175
+ SIGNATURE_HEADER,
176
+ TIMESTAMP_HEADER,
177
+ verifyWebhook
178
+ };
179
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts","../src/webhooks.ts"],"sourcesContent":["import type { Domain, Event, Identity, Message, Page, Thread, Webhook } from './wire';\n\n/**\n * The Pidgeon client.\n *\n * Deliberately thin: typed wrappers over `fetch`, one error class, and\n * nothing else. No retry policy, no queue, no caching, no ambient state —\n * those are decisions that belong to the application, and an SDK that makes\n * them for you is an SDK you eventually have to work around.\n *\n * Zero dependencies. The only runtime requirements are `fetch` and\n * `crypto.subtle`, both standard in Node 18+, Bun, Deno and every edge\n * runtime.\n */\n\nexport interface PidgeonOptions {\n apiKey: string;\n /** Defaults to the hosted API. Point this at a local dev server to test. */\n baseUrl?: string;\n /** Injected in tests. Defaults to the global. */\n fetch?: typeof globalThis.fetch;\n}\n\nexport class PidgeonError extends Error {\n readonly code: string;\n readonly status: number;\n readonly field?: string;\n readonly requestId?: string;\n\n constructor(input: {\n code: string;\n message: string;\n status: number;\n field?: string;\n requestId?: string;\n }) {\n super(input.message);\n this.name = 'PidgeonError';\n this.code = input.code;\n this.status = input.status;\n this.field = input.field;\n this.requestId = input.requestId;\n }\n}\n\nconst DEFAULT_BASE_URL = 'https://pidgeon.ai/api/v1';\n\nexport class Pidgeon {\n #apiKey: string;\n #baseUrl: string;\n #fetch: typeof globalThis.fetch;\n\n constructor(options: PidgeonOptions) {\n if (!options.apiKey) throw new Error('An API key is required.');\n\n this.#apiKey = options.apiKey;\n this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/$/, '');\n this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);\n }\n\n async #request<T>(\n method: string,\n path: string,\n options: { query?: Record<string, string | number | undefined>; body?: unknown } = {},\n ): Promise<T> {\n const url = new URL(`${this.#baseUrl}${path}`);\n\n for (const [key, value] of Object.entries(options.query ?? {})) {\n if (value !== undefined) url.searchParams.set(key, String(value));\n }\n\n const response = await this.#fetch(url, {\n method,\n headers: {\n Authorization: `Bearer ${this.#apiKey}`,\n ...(options.body ? { 'Content-Type': 'application/json' } : {}),\n },\n body: options.body ? JSON.stringify(options.body) : undefined,\n });\n\n const requestId = response.headers.get('x-request-id') ?? undefined;\n const payload = (await response.json().catch(() => null)) as {\n data?: unknown;\n error?: { code: string; message: string; field?: string };\n } | null;\n\n if (!response.ok || payload?.error) {\n throw new PidgeonError({\n code: payload?.error?.code ?? 'internal',\n message: payload?.error?.message ?? `Request failed with ${response.status}.`,\n status: response.status,\n field: payload?.error?.field,\n requestId,\n });\n }\n\n return payload?.data as T;\n }\n\n // --- identities ---------------------------------------------------------\n\n identities = {\n list: () => this.#request<Identity[]>('GET', '/identities'),\n get: (id: string) => this.#request<Identity>('GET', `/identities/${id}`),\n create: (input: {\n domain_id: string;\n local_part: string;\n display_name?: string | null;\n type?: 'human' | 'team' | 'application' | 'workflow' | 'agent';\n mode?: 'human' | 'automated' | 'hybrid';\n }) => this.#request<Identity>('POST', '/identities', { body: input }),\n update: (\n id: string,\n input: {\n display_name?: string | null;\n mode?: 'human' | 'automated' | 'hybrid';\n capabilities?: Record<string, boolean>;\n status?: 'active' | 'suspended' | 'archived';\n },\n ) => this.#request<Identity>('PATCH', `/identities/${id}`, { body: input }),\n delete: (id: string) => this.#request<{ id: string }>('DELETE', `/identities/${id}`),\n };\n\n // --- messages -----------------------------------------------------------\n\n messages = {\n get: (id: string) => this.#request<Message>('GET', `/messages/${id}`),\n listInThread: (threadId: string, options: { limit?: number } = {}) =>\n this.#requestPage<Message>('GET', '/messages', {\n thread_id: threadId,\n limit: options.limit,\n }),\n send: (input: {\n from: string;\n to: string | string[];\n cc?: string | string[];\n bcc?: string | string[];\n subject?: string;\n text?: string;\n in_reply_to?: string | null;\n }) =>\n this.#request<{ id: string; thread_id: string; rejected: { address: string }[] }>(\n 'POST',\n '/messages',\n { body: input },\n ),\n };\n\n attachments = {\n /** A short-lived signed URL. Fetch it when you need it, don't store it. */\n downloadUrl: (id: string) =>\n this.#request<{ id: string; url: string }>('GET', `/attachments/${id}`),\n };\n\n // --- threads ------------------------------------------------------------\n\n threads = {\n list: (\n options: { identity_id?: string; status?: string; limit?: number; cursor?: string } = {},\n ) => this.#requestPage<Thread>('GET', '/threads', options),\n get: (id: string) => this.#request<Thread>('GET', `/threads/${id}`),\n update: (\n id: string,\n input: {\n status?: 'open' | 'waiting' | 'resolved' | 'human_review';\n owner_type?: 'human' | 'automation' | 'agent';\n },\n ) => this.#request<Thread>('PATCH', `/threads/${id}`, { body: input }),\n /** Hand the conversation to a person. The takeover primitive, named. */\n handOff: (id: string) =>\n this.#request<Thread>('PATCH', `/threads/${id}`, {\n body: { owner_type: 'human', status: 'human_review' },\n }),\n };\n\n // --- webhooks -----------------------------------------------------------\n\n webhooks = {\n list: () => this.#request<Webhook[]>('GET', '/webhooks'),\n /** The response carries `secret`, once. Store it before you discard it. */\n create: (input: { url: string; identity_id?: string | null; event_types?: string[] }) =>\n this.#request<Webhook & { secret: string }>('POST', '/webhooks', { body: input }),\n delete: (id: string) => this.#request<{ id: string }>('DELETE', `/webhooks/${id}`),\n };\n\n // --- events -------------------------------------------------------------\n\n events = {\n list: (\n options: { type?: string; identity_id?: string; limit?: number; cursor?: string } = {},\n ) => this.#requestPage<Event>('GET', '/events', options),\n get: (id: string) => this.#request<Event>('GET', `/events/${id}`),\n };\n\n domains = {\n /** Read-only. Adding and verifying a domain is a DNS-and-a-human task. */\n list: () => this.#request<Domain[]>('GET', '/domains'),\n };\n\n async #requestPage<T>(\n method: string,\n path: string,\n query: Record<string, string | number | undefined>,\n ): Promise<Page<T>> {\n const url = new URL(`${this.#baseUrl}${path}`);\n for (const [key, value] of Object.entries(query)) {\n if (value !== undefined) url.searchParams.set(key, String(value));\n }\n\n const response = await this.#fetch(url, {\n method,\n headers: { Authorization: `Bearer ${this.#apiKey}` },\n });\n\n const payload = (await response.json().catch(() => null)) as\n (Page<T> & { error?: { code: string; message: string; field?: string } }) | null;\n\n if (!response.ok || payload?.error) {\n throw new PidgeonError({\n code: payload?.error?.code ?? 'internal',\n message: payload?.error?.message ?? `Request failed with ${response.status}.`,\n status: response.status,\n field: payload?.error?.field,\n requestId: response.headers.get('x-request-id') ?? undefined,\n });\n }\n\n return {\n data: payload?.data ?? [],\n has_more: payload?.has_more ?? false,\n next_cursor: payload?.next_cursor ?? null,\n };\n }\n}\n","/**\n * Verifying a delivery.\n *\n * The only part of this package with a security contract: it is what a\n * customer runs to decide whether a request claiming a message arrived\n * actually came from us. Everything else here is a typed `fetch`.\n *\n * Two properties carry that contract, and both are tested against payloads\n * signed by the server's own implementation — see\n * `tests/unit/sdk-webhook-verifier.test.ts`. Testing this file against\n * itself would prove only that it agrees with itself, which is exactly the\n * bug two implementations of one scheme produce.\n */\n\nexport const SIGNATURE_HEADER = 'x-pidgeon-signature';\nexport const TIMESTAMP_HEADER = 'x-pidgeon-timestamp';\n\nexport interface WebhookEnvelope {\n id: string;\n type: string;\n created_at: string;\n data: Record<string, unknown>;\n}\n\n/**\n * Verifies a delivery and returns its payload.\n *\n * Throws rather than returning false, because the one thing this must not\n * allow is a caller who forgets to check the boolean. Uses `crypto.subtle`,\n * so it works unchanged in Node, Bun, Deno, Workers and Vercel functions.\n *\n * `rawBody` must be the exact bytes we sent. Re-serialising parsed JSON\n * changes key order and whitespace, and the signature will not match — read\n * the body as text before you parse it.\n */\nexport async function verifyWebhook(input: {\n rawBody: string;\n headers: Headers | Record<string, string | undefined>;\n secret: string;\n toleranceSeconds?: number;\n now?: Date;\n}): Promise<WebhookEnvelope> {\n const get = (name: string): string | undefined =>\n input.headers instanceof Headers\n ? (input.headers.get(name) ?? undefined)\n : (input.headers[name] ?? input.headers[name.toLowerCase()]);\n\n const timestamp = get(TIMESTAMP_HEADER);\n const signature = get(SIGNATURE_HEADER);\n\n if (!timestamp || !signature) throw new Error('This request is not signed.');\n\n const seconds = Number(timestamp);\n if (!Number.isFinite(seconds)) throw new Error('The signature timestamp is not a number.');\n\n const now = Math.floor((input.now ?? new Date()).getTime() / 1000);\n const tolerance = input.toleranceSeconds ?? 300;\n\n if (Math.abs(now - seconds) > tolerance) {\n throw new Error('This request is too old to trust. It may be a replay.');\n }\n\n const key = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(input.secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const mac = await crypto.subtle.sign(\n 'HMAC',\n key,\n new TextEncoder().encode(`${seconds}.${input.rawBody}`),\n );\n\n const expected = `v1=${[...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`;\n\n if (!constantTimeEqual(expected, signature)) {\n throw new Error('The signature does not match.');\n }\n\n return JSON.parse(input.rawBody) as WebhookEnvelope;\n}\n\n/** Length-independent compare. Not `===`, which returns on the first byte. */\nfunction constantTimeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n\n let difference = 0;\n for (let index = 0; index < a.length; index += 1) {\n difference |= a.charCodeAt(index) ^ b.charCodeAt(index);\n }\n\n return difference === 0;\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@pidgeon-ai/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Thin TypeScript client for the Pidgeon email API.",
5
+ "keywords": [
6
+ "pidgeon",
7
+ "email",
8
+ "webhooks",
9
+ "api-client"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/patrickcneuhaus/pidgeon.git",
15
+ "directory": "packages/sdk"
16
+ },
17
+ "homepage": "https://pidgeon.ai",
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "exports": {
21
+ ".": {
22
+ "import": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "require": {
27
+ "types": "./dist/index.d.cts",
28
+ "default": "./dist/index.cjs"
29
+ }
30
+ }
31
+ },
32
+ "main": "./dist/index.cjs",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "CHANGELOG.md"
39
+ ],
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "typecheck": "tsc --noEmit",
49
+ "prepublishOnly": "pnpm build"
50
+ },
51
+ "devDependencies": {
52
+ "tsup": "^8.5.0",
53
+ "typescript": "^5"
54
+ }
55
+ }