@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/client.js ADDED
@@ -0,0 +1,459 @@
1
+ /**
2
+ * The client.
3
+ *
4
+ * One class, one resource object per area of the API, and every method a thin
5
+ * declaration of a URL, a method, a body and — the only interesting bit —
6
+ * whether repeating the request is safe.
7
+ *
8
+ * Scope: the API-KEY surface only. Endpoints that require a browser session
9
+ * (sign-in, checkout, profile, minting keys) are deliberately absent, because
10
+ * they refuse a Bearer token and an SDK method that can only ever 403 is worse
11
+ * than no method. `/admin/v1/*` is absent for the same reason. `/v1/inbound/*`
12
+ * is absent because a customer cannot use it — see the README.
13
+ */
14
+ import { HttpClient } from './http.js';
15
+ import { autoPaginate as paginate, collect } from './pagination.js';
16
+ export class Posthaste {
17
+ http;
18
+ account;
19
+ domains;
20
+ emails;
21
+ messages;
22
+ suppressions;
23
+ webhooks;
24
+ apiKeys;
25
+ billing;
26
+ constructor(options) {
27
+ this.http = new HttpClient(options);
28
+ this.account = new AccountResource(this.http);
29
+ this.domains = new DomainsResource(this.http);
30
+ this.emails = new EmailsResource(this.http);
31
+ this.messages = new MessagesResource(this.http);
32
+ this.suppressions = new SuppressionsResource(this.http);
33
+ this.webhooks = new WebhooksResource(this.http);
34
+ this.apiKeys = new ApiKeysResource(this.http);
35
+ this.billing = new BillingResource(this.http);
36
+ }
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Account
40
+ // ---------------------------------------------------------------------------
41
+ export class AccountResource {
42
+ http;
43
+ constructor(http) {
44
+ this.http = http;
45
+ }
46
+ /** `GET /v1/me` — who this key belongs to, its scopes, plan and sending caps. */
47
+ me(options) {
48
+ return this.http.request({ method: 'GET', path: '/v1/me', idempotent: true, options });
49
+ }
50
+ /**
51
+ * `GET /v1/account/verify` — replay the account's ENTIRE event chain.
52
+ *
53
+ * The strong claim, not the per-message one: nothing in the whole delivery
54
+ * history has been altered or removed. Expensive by design; not a health
55
+ * check to run on every request.
56
+ */
57
+ verify(options) {
58
+ return this.http.request({
59
+ method: 'GET',
60
+ path: '/v1/account/verify',
61
+ idempotent: true,
62
+ options,
63
+ });
64
+ }
65
+ /** `GET /v1/usage` — the current UTC month, with the daily series behind it. */
66
+ usage(options) {
67
+ return this.http.request({ method: 'GET', path: '/v1/usage', idempotent: true, options });
68
+ }
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Domains
72
+ // ---------------------------------------------------------------------------
73
+ export class DomainsResource {
74
+ http;
75
+ constructor(http) {
76
+ this.http = http;
77
+ }
78
+ /**
79
+ * `POST /v1/domains` — 201 with the DNS records to publish.
80
+ *
81
+ * Safe to repeat: a second create for the same name is refused with `409
82
+ * conflict` rather than producing a second domain, so a retry after a lost
83
+ * response cannot leave duplicates behind.
84
+ */
85
+ create(params, options) {
86
+ return this.http.request({
87
+ method: 'POST',
88
+ path: '/v1/domains',
89
+ body: params,
90
+ idempotent: true,
91
+ options,
92
+ });
93
+ }
94
+ /** `GET /v1/domains` — every domain, newest first. Not paginated. */
95
+ list(options) {
96
+ return this.http.request({ method: 'GET', path: '/v1/domains', idempotent: true, options });
97
+ }
98
+ /**
99
+ * `POST /v1/domains/:id/verify` — look for the records and record the result.
100
+ *
101
+ * Branch on `verified`, not on `status`: `checks` reports SPF and DMARC too,
102
+ * and neither of them failing stops the domain being usable.
103
+ */
104
+ verify(id, options) {
105
+ return this.http.request({
106
+ method: 'POST',
107
+ path: `/v1/domains/${encodeURIComponent(id)}/verify`,
108
+ idempotent: true,
109
+ options,
110
+ });
111
+ }
112
+ /**
113
+ * `DELETE /v1/domains/:id` — 204.
114
+ *
115
+ * Refused with `409 domain_in_use` while any message references it. That is
116
+ * deliberate: the delivery record is the product, and deleting the domain
117
+ * would take its history with it.
118
+ */
119
+ delete(id, options) {
120
+ return this.http.requestVoid({
121
+ method: 'DELETE',
122
+ path: `/v1/domains/${encodeURIComponent(id)}`,
123
+ idempotent: true,
124
+ options,
125
+ });
126
+ }
127
+ /** `GET /v1/domains/:id/setup` — who runs this domain's DNS, and whether one-click is available. */
128
+ setup(id, options) {
129
+ return this.http.request({
130
+ method: 'GET',
131
+ path: `/v1/domains/${encodeURIComponent(id)}/setup`,
132
+ idempotent: true,
133
+ options,
134
+ });
135
+ }
136
+ /**
137
+ * `POST /v1/domains/:id/cloudflare` — publish the DKIM record on the
138
+ * customer's behalf.
139
+ *
140
+ * Only DKIM is written. SPF and DMARC come back under `notPublished`,
141
+ * untouched, because overwriting either is worse than asking somebody to
142
+ * copy a string by hand.
143
+ *
144
+ * Safe to repeat: the record write is an upsert and the token store is an
145
+ * `on conflict do update`.
146
+ */
147
+ connectCloudflare(id, params = {}, options) {
148
+ return this.http.request({
149
+ method: 'POST',
150
+ path: `/v1/domains/${encodeURIComponent(id)}/cloudflare`,
151
+ body: params,
152
+ idempotent: true,
153
+ options,
154
+ });
155
+ }
156
+ /**
157
+ * `DELETE /v1/account/cloudflare` — forget the stored Cloudflare token.
158
+ *
159
+ * Account-scoped rather than domain-scoped (one token serves every domain),
160
+ * but it lives here because it is part of the DNS story and needs
161
+ * `domains:write`. Always 204, whether a token was stored or not.
162
+ */
163
+ disconnectCloudflare(options) {
164
+ return this.http.requestVoid({
165
+ method: 'DELETE',
166
+ path: '/v1/account/cloudflare',
167
+ idempotent: true,
168
+ options,
169
+ });
170
+ }
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // Sending
174
+ // ---------------------------------------------------------------------------
175
+ export class EmailsResource {
176
+ http;
177
+ constructor(http) {
178
+ this.http = http;
179
+ }
180
+ /**
181
+ * `POST /v1/emails`.
182
+ *
183
+ * TWO success statuses, and they mean different things:
184
+ *
185
+ * 202 `{ status: 'queued' }` — accepted, a new message exists.
186
+ * 200 `{ status: 'duplicate' }` — an idempotency replay. No new message was
187
+ * created; the id is the original one.
188
+ *
189
+ * Both are returned as a `SendEmailResult` with a `duplicate` boolean, rather
190
+ * than collapsed into "it worked". A caller that bills, logs or counts per
191
+ * send needs to know which of the two happened, and finding out from an HTTP
192
+ * status they never see is not a reasonable ask.
193
+ *
194
+ * RETRIES. A send is only repeated automatically when `idempotencyKey` is
195
+ * set, because without one a retry after a lost response sends the email
196
+ * twice. Setting it is the single most useful thing you can do here.
197
+ *
198
+ * IDEMPOTENCY IS A BODY FIELD. It goes in the JSON as `idempotencyKey`. The
199
+ * `Idempotency-Key` HTTP header is in the API's CORS allowlist but no handler
200
+ * reads it, so a client that sends the header and not the field gets no
201
+ * idempotency at all and no warning that it has none. This SDK never sends
202
+ * the header.
203
+ */
204
+ async send(params, options) {
205
+ const { status, text } = await this.http.send({
206
+ method: 'POST',
207
+ path: '/v1/emails',
208
+ body: params,
209
+ // The whole rule, in one expression.
210
+ idempotent: Boolean(params.idempotencyKey),
211
+ options,
212
+ });
213
+ const parsed = JSON.parse(text);
214
+ return {
215
+ id: parsed.id,
216
+ status: parsed.status,
217
+ /*
218
+ * Read from the BODY, not from `status === 200`.
219
+ *
220
+ * The body is the authoritative answer and survives a proxy that
221
+ * normalises a 202 to a 200; the status is a corroborating signal, not
222
+ * the source of truth.
223
+ */
224
+ duplicate: parsed.status === 'duplicate',
225
+ };
226
+ }
227
+ }
228
+ // ---------------------------------------------------------------------------
229
+ // Messages
230
+ // ---------------------------------------------------------------------------
231
+ export class MessagesResource {
232
+ http;
233
+ constructor(http) {
234
+ this.http = http;
235
+ }
236
+ /** `GET /v1/messages` — one keyset page, newest first. */
237
+ list(params = {}, options) {
238
+ return this.http.request({
239
+ method: 'GET',
240
+ path: '/v1/messages',
241
+ query: params,
242
+ idempotent: true,
243
+ options,
244
+ });
245
+ }
246
+ /**
247
+ * Every message matching the filter, across every page.
248
+ *
249
+ * Loops on `hasMore`. Never write the `while (nextCursor)` version by hand —
250
+ * see `pagination.ts` for why it does not terminate.
251
+ */
252
+ autoPaginate(params = {}, options) {
253
+ return paginate((p) => this.list(p, options), params);
254
+ }
255
+ /** Drain `autoPaginate` into an array, up to `maxItems`. */
256
+ listAll(params = {}, maxItems = 1000, options) {
257
+ return collect(this.autoPaginate(params, options), maxItems);
258
+ }
259
+ /**
260
+ * `GET /v1/messages/:id` — the waybill: content, every event, and the hash
261
+ * linkage, with the chain re-verified on read (`recordIntact`).
262
+ */
263
+ get(id, options) {
264
+ return this.http.request({
265
+ method: 'GET',
266
+ path: `/v1/messages/${encodeURIComponent(id)}`,
267
+ idempotent: true,
268
+ options,
269
+ });
270
+ }
271
+ /**
272
+ * `GET /v1/stats/messages` — daily volume with the previous window for
273
+ * comparison.
274
+ *
275
+ * Rates here are PERCENTAGES over settled mail (`total` minus `pending`).
276
+ * `GET /v1/usage` reports its rates as fractions over everything sent — the
277
+ * two endpoints genuinely differ, and dividing one by 100 to compare them is
278
+ * not enough.
279
+ */
280
+ stats(params = {}, options) {
281
+ return this.http.request({
282
+ method: 'GET',
283
+ path: '/v1/stats/messages',
284
+ query: params,
285
+ idempotent: true,
286
+ options,
287
+ });
288
+ }
289
+ }
290
+ // ---------------------------------------------------------------------------
291
+ // Suppressions
292
+ // ---------------------------------------------------------------------------
293
+ export class SuppressionsResource {
294
+ http;
295
+ constructor(http) {
296
+ this.http = http;
297
+ }
298
+ /** `GET /v1/suppressions` — one keyset page. `limit` caps at 200. */
299
+ list(params = {}, options) {
300
+ return this.http.request({
301
+ method: 'GET',
302
+ path: '/v1/suppressions',
303
+ query: params,
304
+ idempotent: true,
305
+ options,
306
+ });
307
+ }
308
+ /** Every suppression matching the filter. Loops on `hasMore`. */
309
+ autoPaginate(params = {}, options) {
310
+ return paginate((p) => this.list(p, options), params);
311
+ }
312
+ /** Drain `autoPaginate` into an array, up to `maxItems`. */
313
+ listAll(params = {}, maxItems = 1000, options) {
314
+ return collect(this.autoPaginate(params, options), maxItems);
315
+ }
316
+ /**
317
+ * `POST /v1/suppressions` — 201.
318
+ *
319
+ * `reason` here is free text and is stored as the entry's DETAIL. The entry's
320
+ * own reason is always `manual`; only the platform creates `hard_bounce`,
321
+ * `complaint`, `unsubscribe` and `spam_trap` entries.
322
+ *
323
+ * Safe to repeat: the insert is `on conflict do nothing`, and re-adding an
324
+ * address answers 201 again.
325
+ */
326
+ create(params, options) {
327
+ return this.http.request({
328
+ method: 'POST',
329
+ path: '/v1/suppressions',
330
+ body: params,
331
+ idempotent: true,
332
+ options,
333
+ });
334
+ }
335
+ /**
336
+ * `DELETE /v1/suppressions/:address` — 204.
337
+ *
338
+ * Addressed by ADDRESS, not by `sup_` id, and this SDK URL-encodes it for
339
+ * you. Two entries are refused: a `complaint` (422 `suppression_protected` —
340
+ * sending again is what gets an IP blocklisted) and a platform-wide entry
341
+ * (422 `suppression_platform`).
342
+ */
343
+ delete(address, options) {
344
+ return this.http.requestVoid({
345
+ method: 'DELETE',
346
+ path: `/v1/suppressions/${encodeURIComponent(address)}`,
347
+ idempotent: true,
348
+ options,
349
+ });
350
+ }
351
+ }
352
+ // ---------------------------------------------------------------------------
353
+ // Webhooks
354
+ // ---------------------------------------------------------------------------
355
+ export class WebhooksResource {
356
+ http;
357
+ constructor(http) {
358
+ this.http = http;
359
+ }
360
+ /**
361
+ * `POST /v1/webhooks` — 201, carrying `signingSecret`.
362
+ *
363
+ * The secret is shown exactly ONCE and is not retrievable afterwards, because
364
+ * a signing secret that can be re-read is one that anybody with a stolen API
365
+ * key can read too. Store it before you do anything else with the response.
366
+ *
367
+ * NOT auto-retried: repeating this creates a second webhook, and the
368
+ * duplicate would then receive every event twice.
369
+ */
370
+ create(params, options) {
371
+ return this.http.request({
372
+ method: 'POST',
373
+ path: '/v1/webhooks',
374
+ body: params,
375
+ idempotent: false,
376
+ options,
377
+ });
378
+ }
379
+ /** `GET /v1/webhooks` — newest first. Not paginated. Never includes secrets. */
380
+ list(options) {
381
+ return this.http.request({ method: 'GET', path: '/v1/webhooks', idempotent: true, options });
382
+ }
383
+ /** `DELETE /v1/webhooks/:id` — 204, or 404 for an unknown id. */
384
+ delete(id, options) {
385
+ return this.http.requestVoid({
386
+ method: 'DELETE',
387
+ path: `/v1/webhooks/${encodeURIComponent(id)}`,
388
+ idempotent: true,
389
+ options,
390
+ });
391
+ }
392
+ }
393
+ // ---------------------------------------------------------------------------
394
+ // API keys
395
+ // ---------------------------------------------------------------------------
396
+ export class ApiKeysResource {
397
+ http;
398
+ constructor(http) {
399
+ this.http = http;
400
+ }
401
+ /**
402
+ * `GET /v1/api-keys` — the 100 most recent keys, revoked ones included.
403
+ *
404
+ * Read only, and that is the whole resource. Creating and revoking keys
405
+ * requires a signed-in owner or admin and refuses a Bearer key outright: a
406
+ * server-side credential that could mint more credentials would make every
407
+ * narrow key one request away from a full one.
408
+ */
409
+ list(options) {
410
+ return this.http.request({ method: 'GET', path: '/v1/api-keys', idempotent: true, options });
411
+ }
412
+ }
413
+ // ---------------------------------------------------------------------------
414
+ // Billing
415
+ // ---------------------------------------------------------------------------
416
+ export class BillingResource {
417
+ http;
418
+ constructor(http) {
419
+ this.http = http;
420
+ }
421
+ /**
422
+ * `GET /v1/billing` — current plan, subscription, profile, recent payments
423
+ * and the full price list.
424
+ *
425
+ * All four billing reads accept `billing:read` OR `account:read`; an API key
426
+ * cannot hold `billing:read`, so in practice `account:read` is the one that
427
+ * gets you in. Money is always in minor units.
428
+ */
429
+ get(options) {
430
+ return this.http.request({ method: 'GET', path: '/v1/billing', idempotent: true, options });
431
+ }
432
+ /** `GET /v1/billing/history` — the hash-chained commercial record, oldest first, plus its verification. */
433
+ history(options) {
434
+ return this.http.request({
435
+ method: 'GET',
436
+ path: '/v1/billing/history',
437
+ idempotent: true,
438
+ options,
439
+ });
440
+ }
441
+ /** `GET /v1/billing/invoices` — the 100 most recent, newest first. Not paginated. */
442
+ invoices(options) {
443
+ return this.http.request({
444
+ method: 'GET',
445
+ path: '/v1/billing/invoices',
446
+ idempotent: true,
447
+ options,
448
+ });
449
+ }
450
+ /** `GET /v1/billing/invoices/:id` — the same document plus the supplier block. */
451
+ invoice(id, options) {
452
+ return this.http.request({
453
+ method: 'GET',
454
+ path: `/v1/billing/invoices/${encodeURIComponent(id)}`,
455
+ idempotent: true,
456
+ options,
457
+ });
458
+ }
459
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Errors.
3
+ *
4
+ * Every failure this SDK can produce arrives as a `PosthasteError`, including
5
+ * the ones that never touched the server (a socket that would not open, a
6
+ * request that timed out). One `catch` clause, one shape.
7
+ */
8
+ /**
9
+ * The `error.type` values the API emits.
10
+ *
11
+ * Listed as a union so a `switch` is exhaustive where it can be, and widened
12
+ * with `(string & {})` so a type this SDK version has not heard of still
13
+ * type-checks — a new refusal reason must not be a compile error for anybody
14
+ * who has not upgraded.
15
+ */
16
+ export type KnownErrorType = 'unauthorized' | 'unauthenticated' | 'forbidden' | 'csrf_failed' | 'email_unverified' | 'invalid_request' | 'not_found' | 'conflict'
17
+ /** Inbound addresses. Not reachable through this SDK — see the README. */
18
+ | 'address_taken' | 'invalid_address' | 'domain_not_found' | 'domain_not_verified' | 'suppressed' | 'rate_limited' | 'daily_limit_reached' | 'monthly_limit_reached' | 'domain_limit_reached' | 'domain_in_use' | 'token_required' | 'cloudflare_token_invalid' | 'cloudflare_zone_not_found' | 'cloudflare_write_failed' | 'suppression_protected' | 'suppression_platform' | 'not_configured' | 'provider_error' | 'already_subscribed' | 'internal' | 'unknown_error' | 'connection_error' | 'timeout';
19
+ export type PosthasteErrorType = KnownErrorType | (string & {});
20
+ /** One entry of `error.fields`, present on some — not all — 400s. */
21
+ export interface FieldError {
22
+ /** Dotted path into the request body, e.g. `headers.x-thing`. */
23
+ path: string;
24
+ message: string;
25
+ }
26
+ export interface PosthasteErrorInit {
27
+ status: number;
28
+ type: PosthasteErrorType;
29
+ message: string;
30
+ fields?: FieldError[];
31
+ retryAfterSeconds?: number;
32
+ /** The parsed response body, exactly as it arrived. */
33
+ body?: unknown;
34
+ cause?: unknown;
35
+ }
36
+ export declare class PosthasteError extends Error {
37
+ readonly name = "PosthasteError";
38
+ /**
39
+ * The HTTP status. `0` when the request never got a response at all — a DNS
40
+ * failure, a refused connection, an abort. Checking `status >= 500` is
41
+ * therefore not a substitute for checking `type`.
42
+ */
43
+ readonly status: number;
44
+ readonly type: PosthasteErrorType;
45
+ /**
46
+ * Field-level detail, when the server sent any.
47
+ *
48
+ * `error.fields` appears on the 400s produced by the shared body validator
49
+ * and is ABSENT on the several 400s that are hand-written with a message
50
+ * only. Always treat it as optional; never index into it unchecked.
51
+ */
52
+ readonly fields?: FieldError[];
53
+ /**
54
+ * How long to wait, in seconds, when the server said. Taken from
55
+ * `error.retryAfterSeconds` (rate limiting) or the `Retry-After` header
56
+ * (quota exhaustion), in that order.
57
+ */
58
+ readonly retryAfterSeconds?: number;
59
+ /** The parsed body. `undefined` when there was nothing parseable. */
60
+ readonly body?: unknown;
61
+ constructor(init: PosthasteErrorInit);
62
+ /**
63
+ * True for the two refusals that mean "your allowance is spent", as opposed
64
+ * to "you are going too fast".
65
+ *
66
+ * Both arrive as 429 with a `Retry-After`, and the status alone cannot tell
67
+ * them apart — which is exactly the trap that makes a naive retry loop hammer
68
+ * a wall for the rest of the month. This SDK never retries these in-process.
69
+ */
70
+ get isQuotaExhausted(): boolean;
71
+ /** True for the per-key request-rate limiter, which is short and transient. */
72
+ get isRateLimited(): boolean;
73
+ }
74
+ export declare function isPosthasteError(value: unknown): value is PosthasteError;
75
+ /**
76
+ * Turn a response body into an error.
77
+ *
78
+ * The API's own refusals use `{ error: { type, message, ... } }`. Two kinds of
79
+ * response do NOT, and both are parsed defensively here rather than assumed
80
+ * away:
81
+ *
82
+ * An UNHANDLED 500. There is no `setErrorHandler` on the API, so a thrown
83
+ * exception is serialised by Fastify's default handler as
84
+ * `{ statusCode, error: "Internal Server Error", message }` — `error` is a
85
+ * STRING. Reading `body.error.type` off that yields `undefined` and reading
86
+ * `body.error.message` throws, which would turn the one response where a
87
+ * caller most needs a clear message into a crash inside the SDK.
88
+ *
89
+ * Anything that never reached the application at all — a proxy's HTML error
90
+ * page, an empty body, a truncated response.
91
+ *
92
+ * Neither invents a `type`: both become `unknown_error`, so nothing downstream
93
+ * can mistake an unclassified failure for a documented one.
94
+ */
95
+ export declare function errorFromResponse(status: number, rawBody: string, retryAfterHeader: string | null): PosthasteError;
96
+ /**
97
+ * `Retry-After` is either a number of seconds or an HTTP date. The API sends
98
+ * seconds; a proxy in front of it may not.
99
+ */
100
+ export declare function parseRetryAfter(header: string | null): number | undefined;