@posthaste/sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -31
- package/dist/base64.d.ts +10 -0
- package/dist/base64.js +22 -0
- package/dist/client.d.ts +102 -11
- package/dist/client.js +181 -14
- package/dist/errors.d.ts +46 -4
- package/dist/errors.js +40 -4
- package/dist/http.d.ts +41 -5
- package/dist/http.js +71 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/pagination.d.ts +66 -24
- package/dist/pagination.js +58 -26
- package/dist/types.d.ts +224 -20
- package/dist/types.js +8 -2
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { HttpClient } from './http.js';
|
|
15
15
|
import { autoPaginate as paginate, collect } from './pagination.js';
|
|
16
|
+
import { bytesToBase64 } from './base64.js';
|
|
16
17
|
export class Posthaste {
|
|
17
18
|
http;
|
|
18
19
|
account;
|
|
@@ -91,9 +92,30 @@ export class DomainsResource {
|
|
|
91
92
|
options,
|
|
92
93
|
});
|
|
93
94
|
}
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* `GET /v1/domains` — one keyset page, newest first.
|
|
97
|
+
*
|
|
98
|
+
* `limit` defaults to 100, which is also its maximum, so on most accounts the
|
|
99
|
+
* first page is every domain. It is still a page: past a hundred domains the
|
|
100
|
+
* rest are behind `nextCursor`, and there is no `hasMore` here to tell you so.
|
|
101
|
+
* Use `autoPaginate` if the count is not bounded by something you control.
|
|
102
|
+
*/
|
|
103
|
+
list(params = {}, options) {
|
|
104
|
+
return this.http.request({
|
|
105
|
+
method: 'GET',
|
|
106
|
+
path: '/v1/domains',
|
|
107
|
+
query: params,
|
|
108
|
+
idempotent: true,
|
|
109
|
+
options,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Every domain, across every page. */
|
|
113
|
+
autoPaginate(params = {}, options) {
|
|
114
|
+
return paginate((p) => this.list(p, options), params);
|
|
115
|
+
}
|
|
116
|
+
/** Drain `autoPaginate` into an array, up to `maxItems`. */
|
|
117
|
+
listAll(params = {}, maxItems = 1000, options) {
|
|
118
|
+
return collect(this.autoPaginate(params, options), maxItems);
|
|
97
119
|
}
|
|
98
120
|
/**
|
|
99
121
|
* `POST /v1/domains/:id/verify` — look for the records and record the result.
|
|
@@ -202,10 +224,20 @@ export class EmailsResource {
|
|
|
202
224
|
* the header.
|
|
203
225
|
*/
|
|
204
226
|
async send(params, options) {
|
|
227
|
+
// Attachment bytes become base64 on the wire. A string is passed through
|
|
228
|
+
// untouched — it is expected to already BE base64, and encoding it again
|
|
229
|
+
// would corrupt the file on arrival.
|
|
230
|
+
const body = { ...params };
|
|
231
|
+
if (params.attachments) {
|
|
232
|
+
body.attachments = params.attachments.map((a) => ({
|
|
233
|
+
...a,
|
|
234
|
+
content: typeof a.content === 'string' ? a.content : bytesToBase64(a.content),
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
205
237
|
const { status, text } = await this.http.send({
|
|
206
238
|
method: 'POST',
|
|
207
239
|
path: '/v1/emails',
|
|
208
|
-
body
|
|
240
|
+
body,
|
|
209
241
|
// The whole rule, in one expression.
|
|
210
242
|
idempotent: Boolean(params.idempotencyKey),
|
|
211
243
|
options,
|
|
@@ -222,8 +254,33 @@ export class EmailsResource {
|
|
|
222
254
|
* the source of truth.
|
|
223
255
|
*/
|
|
224
256
|
duplicate: parsed.status === 'duplicate',
|
|
257
|
+
...(parsed.scheduledAt ? { scheduledAt: parsed.scheduledAt } : {}),
|
|
258
|
+
/*
|
|
259
|
+
* Passed through only when the API sent them, so a single-recipient
|
|
260
|
+
* result keeps exactly the shape it has always had — no empty arrays
|
|
261
|
+
* appearing on responses that never had them.
|
|
262
|
+
*/
|
|
263
|
+
...(parsed.groupId ? { groupId: parsed.groupId } : {}),
|
|
264
|
+
...(parsed.emails ? { emails: parsed.emails } : {}),
|
|
265
|
+
...(parsed.suppressed ? { suppressed: parsed.suppressed } : {}),
|
|
225
266
|
};
|
|
226
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* `DELETE /v1/emails/:id/schedule` — cancel a scheduled send.
|
|
270
|
+
*
|
|
271
|
+
* Succeeds until the message is released to delivery (which happens within
|
|
272
|
+
* about a minute of its scheduled time). A message that already released
|
|
273
|
+
* throws `not_scheduled` carrying its current status; a cancel retried
|
|
274
|
+
* after a lost response returns success again rather than erroring.
|
|
275
|
+
*/
|
|
276
|
+
cancelSchedule(id, options) {
|
|
277
|
+
return this.http.request({
|
|
278
|
+
method: 'DELETE',
|
|
279
|
+
path: `/v1/emails/${encodeURIComponent(id)}/schedule`,
|
|
280
|
+
idempotent: true,
|
|
281
|
+
options,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
227
284
|
}
|
|
228
285
|
// ---------------------------------------------------------------------------
|
|
229
286
|
// Messages
|
|
@@ -268,6 +325,30 @@ export class MessagesResource {
|
|
|
268
325
|
options,
|
|
269
326
|
});
|
|
270
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* `GET /v1/messages/:id/attachments/:attachmentId` — the stored bytes.
|
|
330
|
+
*
|
|
331
|
+
* The attachment id comes from `get(id).content.attachments[].id`. Returns
|
|
332
|
+
* the exact bytes that were sent; the server serves them with
|
|
333
|
+
* `Content-Disposition: attachment` and only ever an inert content type,
|
|
334
|
+
* echoed here for callers who re-serve the file.
|
|
335
|
+
*/
|
|
336
|
+
async downloadAttachment(messageId, attachmentId, options) {
|
|
337
|
+
const { bytes, headers } = await this.http.sendBinary({
|
|
338
|
+
method: 'GET',
|
|
339
|
+
path: `/v1/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
|
|
340
|
+
idempotent: true,
|
|
341
|
+
options,
|
|
342
|
+
});
|
|
343
|
+
const disposition = headers.get('content-disposition') ?? '';
|
|
344
|
+
const star = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
|
|
345
|
+
const plain = /filename="([^"]*)"/i.exec(disposition);
|
|
346
|
+
return {
|
|
347
|
+
bytes,
|
|
348
|
+
contentType: headers.get('content-type') ?? 'application/octet-stream',
|
|
349
|
+
filename: star ? decodeURIComponent(star[1]) : (plain?.[1] ?? null),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
271
352
|
/**
|
|
272
353
|
* `GET /v1/stats/messages` — daily volume with the previous window for
|
|
273
354
|
* comparison.
|
|
@@ -376,9 +457,28 @@ export class WebhooksResource {
|
|
|
376
457
|
options,
|
|
377
458
|
});
|
|
378
459
|
}
|
|
379
|
-
/**
|
|
380
|
-
|
|
381
|
-
|
|
460
|
+
/**
|
|
461
|
+
* `GET /v1/webhooks` — one keyset page, newest first. Never includes secrets.
|
|
462
|
+
*
|
|
463
|
+
* `limit` defaults to 50 and caps at 100. There is no `hasMore` on this
|
|
464
|
+
* endpoint; `nextCursor` is the end-of-list signal.
|
|
465
|
+
*/
|
|
466
|
+
list(params = {}, options) {
|
|
467
|
+
return this.http.request({
|
|
468
|
+
method: 'GET',
|
|
469
|
+
path: '/v1/webhooks',
|
|
470
|
+
query: params,
|
|
471
|
+
idempotent: true,
|
|
472
|
+
options,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/** Every webhook, across every page. */
|
|
476
|
+
autoPaginate(params = {}, options) {
|
|
477
|
+
return paginate((p) => this.list(p, options), params);
|
|
478
|
+
}
|
|
479
|
+
/** Drain `autoPaginate` into an array, up to `maxItems`. */
|
|
480
|
+
listAll(params = {}, maxItems = 1000, options) {
|
|
481
|
+
return collect(this.autoPaginate(params, options), maxItems);
|
|
382
482
|
}
|
|
383
483
|
/** `DELETE /v1/webhooks/:id` — 204, or 404 for an unknown id. */
|
|
384
484
|
delete(id, options) {
|
|
@@ -399,15 +499,33 @@ export class ApiKeysResource {
|
|
|
399
499
|
this.http = http;
|
|
400
500
|
}
|
|
401
501
|
/**
|
|
402
|
-
* `GET /v1/api-keys` —
|
|
502
|
+
* `GET /v1/api-keys` — one keyset page of keys, revoked ones included.
|
|
503
|
+
*
|
|
504
|
+
* `limit` defaults to 50 and caps at 100, and revoked keys count towards it —
|
|
505
|
+
* so an account that has rotated its credentials a few times will have more
|
|
506
|
+
* than one page. No `hasMore`; `nextCursor` is the end-of-list signal.
|
|
403
507
|
*
|
|
404
508
|
* Read only, and that is the whole resource. Creating and revoking keys
|
|
405
509
|
* requires a signed-in owner or admin and refuses a Bearer key outright: a
|
|
406
510
|
* server-side credential that could mint more credentials would make every
|
|
407
511
|
* narrow key one request away from a full one.
|
|
408
512
|
*/
|
|
409
|
-
list(options) {
|
|
410
|
-
return this.http.request({
|
|
513
|
+
list(params = {}, options) {
|
|
514
|
+
return this.http.request({
|
|
515
|
+
method: 'GET',
|
|
516
|
+
path: '/v1/api-keys',
|
|
517
|
+
query: params,
|
|
518
|
+
idempotent: true,
|
|
519
|
+
options,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
/** Every key, across every page. */
|
|
523
|
+
autoPaginate(params = {}, options) {
|
|
524
|
+
return paginate((p) => this.list(p, options), params);
|
|
525
|
+
}
|
|
526
|
+
/** Drain `autoPaginate` into an array, up to `maxItems`. */
|
|
527
|
+
listAll(params = {}, maxItems = 1000, options) {
|
|
528
|
+
return collect(this.autoPaginate(params, options), maxItems);
|
|
411
529
|
}
|
|
412
530
|
}
|
|
413
531
|
// ---------------------------------------------------------------------------
|
|
@@ -429,24 +547,73 @@ export class BillingResource {
|
|
|
429
547
|
get(options) {
|
|
430
548
|
return this.http.request({ method: 'GET', path: '/v1/billing', idempotent: true, options });
|
|
431
549
|
}
|
|
432
|
-
/**
|
|
433
|
-
|
|
550
|
+
/**
|
|
551
|
+
* `GET /v1/billing/history` — one page of the hash-chained commercial record,
|
|
552
|
+
* plus its verification.
|
|
553
|
+
*
|
|
554
|
+
* THE ODD ONE OUT, in two ways that a caller who assumes the house style will
|
|
555
|
+
* get wrong:
|
|
556
|
+
*
|
|
557
|
+
* - It reads FORWARD. Events come back oldest first, and the page resumes
|
|
558
|
+
* from `after`, not `before`. That is not a stylistic choice: a hash chain
|
|
559
|
+
* is verified from its start, and a record that renders in a different
|
|
560
|
+
* order than it verifies in is one people stop trusting.
|
|
561
|
+
* - `after` is a SEQUENCE NUMBER — a `BillingEvent.seq` — not a prefixed id.
|
|
562
|
+
* Passing an id gets `400 invalid_request`. `nextCursor` is that number
|
|
563
|
+
* rendered as a string, so passing it straight back is correct.
|
|
564
|
+
*
|
|
565
|
+
* `limit` defaults to 100 and caps at 200. `chain` is verified over the whole
|
|
566
|
+
* account history rather than over the page, so it means the same thing on
|
|
567
|
+
* every page.
|
|
568
|
+
*/
|
|
569
|
+
history(params = {}, options) {
|
|
434
570
|
return this.http.request({
|
|
435
571
|
method: 'GET',
|
|
436
572
|
path: '/v1/billing/history',
|
|
573
|
+
query: params,
|
|
437
574
|
idempotent: true,
|
|
438
575
|
options,
|
|
439
576
|
});
|
|
440
577
|
}
|
|
441
|
-
/**
|
|
442
|
-
|
|
578
|
+
/**
|
|
579
|
+
* Every billing event, oldest first, across every page.
|
|
580
|
+
*
|
|
581
|
+
* Note that this yields the EVENTS only. `chain` is per-response, so if you
|
|
582
|
+
* need the verification as well, call `history` and read it from there.
|
|
583
|
+
*/
|
|
584
|
+
autoPaginateHistory(params = {}, options) {
|
|
585
|
+
// `after`, not `before` — the whole reason this endpoint needs a helper.
|
|
586
|
+
return paginate((p) => this.history(p, options), params, 'after');
|
|
587
|
+
}
|
|
588
|
+
/** Drain `autoPaginateHistory` into an array, up to `maxItems`. */
|
|
589
|
+
listAllHistory(params = {}, maxItems = 1000, options) {
|
|
590
|
+
return collect(this.autoPaginateHistory(params, options), maxItems);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* `GET /v1/billing/invoices` — one keyset page, newest first.
|
|
594
|
+
*
|
|
595
|
+
* `limit` defaults to 50 and caps at 100, and there is no `hasMore` here. An
|
|
596
|
+
* account past its fiftieth invoice that reads only `data` is silently short
|
|
597
|
+
* of its own financial record — follow `nextCursor`, or use
|
|
598
|
+
* `autoPaginateInvoices`.
|
|
599
|
+
*/
|
|
600
|
+
invoices(params = {}, options) {
|
|
443
601
|
return this.http.request({
|
|
444
602
|
method: 'GET',
|
|
445
603
|
path: '/v1/billing/invoices',
|
|
604
|
+
query: params,
|
|
446
605
|
idempotent: true,
|
|
447
606
|
options,
|
|
448
607
|
});
|
|
449
608
|
}
|
|
609
|
+
/** Every invoice, across every page. */
|
|
610
|
+
autoPaginateInvoices(params = {}, options) {
|
|
611
|
+
return paginate((p) => this.invoices(p, options), params);
|
|
612
|
+
}
|
|
613
|
+
/** Drain `autoPaginateInvoices` into an array, up to `maxItems`. */
|
|
614
|
+
listAllInvoices(params = {}, maxItems = 1000, options) {
|
|
615
|
+
return collect(this.autoPaginateInvoices(params, options), maxItems);
|
|
616
|
+
}
|
|
450
617
|
/** `GET /v1/billing/invoices/:id` — the same document plus the supplier block. */
|
|
451
618
|
invoice(id, options) {
|
|
452
619
|
return this.http.request({
|
package/dist/errors.d.ts
CHANGED
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
export type KnownErrorType = 'unauthorized' | 'unauthenticated' | 'forbidden' | 'csrf_failed' | 'email_unverified' | 'invalid_request' | 'not_found' | 'conflict'
|
|
17
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'
|
|
18
|
+
| 'address_taken' | 'invalid_address' | 'domain_not_found' | 'domain_not_verified' | 'suppressed' | 'rate_limited' | 'daily_limit_reached' | 'monthly_limit_reached'
|
|
19
|
+
/** The platform's own daily ceiling, not this account's. Short and transient:
|
|
20
|
+
* nothing about the account changes it, and it clears without any action —
|
|
21
|
+
* so it is `isRateLimited`, never `isQuotaExhausted`. Sends a `Retry-After`
|
|
22
|
+
* of a few minutes, because the wait is not a clock a client can compute. */
|
|
23
|
+
| 'platform_paused' | 'bulk_send_refused' | 'attachments_too_many' | 'attachments_too_large' | 'attachment_type_blocked' | 'attachment_invalid' | 'schedule_too_far' | 'not_scheduled' | '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
24
|
export type PosthasteErrorType = KnownErrorType | (string & {});
|
|
20
25
|
/** One entry of `error.fields`, present on some — not all — 400s. */
|
|
21
26
|
export interface FieldError {
|
|
@@ -52,8 +57,10 @@ export declare class PosthasteError extends Error {
|
|
|
52
57
|
readonly fields?: FieldError[];
|
|
53
58
|
/**
|
|
54
59
|
* How long to wait, in seconds, when the server said. Taken from
|
|
55
|
-
* `error.retryAfterSeconds` (rate
|
|
56
|
-
* (quota exhaustion
|
|
60
|
+
* `error.retryAfterSeconds` (the per-key rate limiter puts it in the body) or
|
|
61
|
+
* the `Retry-After` header (quota exhaustion and `platform_paused` use the
|
|
62
|
+
* header), in that order — the body wins because only it is specific to the
|
|
63
|
+
* one refusal you got.
|
|
57
64
|
*/
|
|
58
65
|
readonly retryAfterSeconds?: number;
|
|
59
66
|
/** The parsed body. `undefined` when there was nothing parseable. */
|
|
@@ -68,9 +75,44 @@ export declare class PosthasteError extends Error {
|
|
|
68
75
|
* a wall for the rest of the month. This SDK never retries these in-process.
|
|
69
76
|
*/
|
|
70
77
|
get isQuotaExhausted(): boolean;
|
|
71
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* True for the short, transient throttles — the per-key request-rate limiter
|
|
80
|
+
* and the platform-wide daily send ceiling.
|
|
81
|
+
*
|
|
82
|
+
* Grouped because a caller treats them identically: honour `Retry-After` and
|
|
83
|
+
* come back. Neither is a statement about the account, and unlike
|
|
84
|
+
* `isQuotaExhausted` neither needs a plan change or a wait until midnight.
|
|
85
|
+
*
|
|
86
|
+
* `platform_paused` in particular must never be folded in with the quota
|
|
87
|
+
* refusals just because it is also a 429. It says the platform's own daily
|
|
88
|
+
* total is full, not that the caller has spent anything — nothing about the
|
|
89
|
+
* account changes it, upgrading does not clear it, and it frees as the day's
|
|
90
|
+
* total drains rather than on a calendar boundary. Telling a customer to
|
|
91
|
+
* upgrade for it would be wrong twice over.
|
|
92
|
+
*/
|
|
72
93
|
get isRateLimited(): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Which address was suppressed, and why — `undefined` on every other error.
|
|
96
|
+
*
|
|
97
|
+
* The distinction in `reason` is the one that matters, and it is not
|
|
98
|
+
* cosmetic: a `complaint` or a `spam_trap` is permanent and must never be
|
|
99
|
+
* retried or cleared, while a `hard_bounce` (a mailbox that was full) or a
|
|
100
|
+
* `manual` entry can legitimately go stale. Reading it off `body` by hand is
|
|
101
|
+
* exactly the kind of untyped digging this SDK exists to remove.
|
|
102
|
+
*/
|
|
103
|
+
get suppression(): {
|
|
104
|
+
address: string;
|
|
105
|
+
reason: SuppressionReason;
|
|
106
|
+
} | undefined;
|
|
73
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Why an address is on the suppression list.
|
|
110
|
+
*
|
|
111
|
+
* Widened with `(string & {})` for the same reason the error union is: a new
|
|
112
|
+
* reason added server-side must not fail to type-check in a caller pinned to
|
|
113
|
+
* an older SDK.
|
|
114
|
+
*/
|
|
115
|
+
export type SuppressionReason = 'hard_bounce' | 'complaint' | 'spam_trap' | 'manual' | 'unsubscribe' | (string & {});
|
|
74
116
|
export declare function isPosthasteError(value: unknown): value is PosthasteError;
|
|
75
117
|
/**
|
|
76
118
|
* Turn a response body into an error.
|
package/dist/errors.js
CHANGED
|
@@ -24,8 +24,10 @@ export class PosthasteError extends Error {
|
|
|
24
24
|
fields;
|
|
25
25
|
/**
|
|
26
26
|
* How long to wait, in seconds, when the server said. Taken from
|
|
27
|
-
* `error.retryAfterSeconds` (rate
|
|
28
|
-
* (quota exhaustion
|
|
27
|
+
* `error.retryAfterSeconds` (the per-key rate limiter puts it in the body) or
|
|
28
|
+
* the `Retry-After` header (quota exhaustion and `platform_paused` use the
|
|
29
|
+
* header), in that order — the body wins because only it is specific to the
|
|
30
|
+
* one refusal you got.
|
|
29
31
|
*/
|
|
30
32
|
retryAfterSeconds;
|
|
31
33
|
/** The parsed body. `undefined` when there was nothing parseable. */
|
|
@@ -52,9 +54,43 @@ export class PosthasteError extends Error {
|
|
|
52
54
|
get isQuotaExhausted() {
|
|
53
55
|
return this.type === 'daily_limit_reached' || this.type === 'monthly_limit_reached';
|
|
54
56
|
}
|
|
55
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* True for the short, transient throttles — the per-key request-rate limiter
|
|
59
|
+
* and the platform-wide daily send ceiling.
|
|
60
|
+
*
|
|
61
|
+
* Grouped because a caller treats them identically: honour `Retry-After` and
|
|
62
|
+
* come back. Neither is a statement about the account, and unlike
|
|
63
|
+
* `isQuotaExhausted` neither needs a plan change or a wait until midnight.
|
|
64
|
+
*
|
|
65
|
+
* `platform_paused` in particular must never be folded in with the quota
|
|
66
|
+
* refusals just because it is also a 429. It says the platform's own daily
|
|
67
|
+
* total is full, not that the caller has spent anything — nothing about the
|
|
68
|
+
* account changes it, upgrading does not clear it, and it frees as the day's
|
|
69
|
+
* total drains rather than on a calendar boundary. Telling a customer to
|
|
70
|
+
* upgrade for it would be wrong twice over.
|
|
71
|
+
*/
|
|
56
72
|
get isRateLimited() {
|
|
57
|
-
return this.type === 'rate_limited';
|
|
73
|
+
return this.type === 'rate_limited' || this.type === 'platform_paused';
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Which address was suppressed, and why — `undefined` on every other error.
|
|
77
|
+
*
|
|
78
|
+
* The distinction in `reason` is the one that matters, and it is not
|
|
79
|
+
* cosmetic: a `complaint` or a `spam_trap` is permanent and must never be
|
|
80
|
+
* retried or cleared, while a `hard_bounce` (a mailbox that was full) or a
|
|
81
|
+
* `manual` entry can legitimately go stale. Reading it off `body` by hand is
|
|
82
|
+
* exactly the kind of untyped digging this SDK exists to remove.
|
|
83
|
+
*/
|
|
84
|
+
get suppression() {
|
|
85
|
+
if (this.type !== 'suppressed')
|
|
86
|
+
return undefined;
|
|
87
|
+
const rejection = this.body?.error;
|
|
88
|
+
if (typeof rejection?.address !== 'string' || typeof rejection.reason !== 'string') {
|
|
89
|
+
// An older API deployment that predates the structured fields. Absent is
|
|
90
|
+
// the honest answer; a fabricated reason would be worse than none.
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return { address: rejection.address, reason: rejection.reason };
|
|
58
94
|
}
|
|
59
95
|
}
|
|
60
96
|
export function isPosthasteError(value) {
|
package/dist/http.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface PosthasteResponse {
|
|
|
22
22
|
get(name: string): string | null;
|
|
23
23
|
};
|
|
24
24
|
text(): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Optional so an injected `fetch` written against the older contract keeps
|
|
27
|
+
* working. Only attachment downloads need it; the global fetch has it.
|
|
28
|
+
*/
|
|
29
|
+
arrayBuffer?(): Promise<ArrayBuffer>;
|
|
25
30
|
}
|
|
26
31
|
export interface PosthasteRequestInit {
|
|
27
32
|
method: string;
|
|
@@ -90,7 +95,7 @@ export interface InternalRequest {
|
|
|
90
95
|
options?: RequestOptions;
|
|
91
96
|
}
|
|
92
97
|
/** Kept in step with package.json — it is the only place it is stated twice. */
|
|
93
|
-
export declare const SDK_VERSION = "0.
|
|
98
|
+
export declare const SDK_VERSION = "0.3.0";
|
|
94
99
|
export declare class HttpClient {
|
|
95
100
|
private readonly apiKey;
|
|
96
101
|
private readonly baseUrl;
|
|
@@ -117,22 +122,53 @@ export declare class HttpClient {
|
|
|
117
122
|
status: number;
|
|
118
123
|
text: string;
|
|
119
124
|
}>;
|
|
125
|
+
/**
|
|
126
|
+
* A GET that returns bytes, not JSON — attachment downloads.
|
|
127
|
+
*
|
|
128
|
+
* Requires the response to support `arrayBuffer()`; the global fetch does,
|
|
129
|
+
* and an injected fetch that does not gets a clear error rather than a
|
|
130
|
+
* corrupted file — reading binary through `text()` mangles every byte
|
|
131
|
+
* sequence that is not valid UTF-8, which is most of them.
|
|
132
|
+
*/
|
|
133
|
+
sendBinary(req: InternalRequest): Promise<{
|
|
134
|
+
status: number;
|
|
135
|
+
bytes: Uint8Array;
|
|
136
|
+
headers: {
|
|
137
|
+
get(name: string): string | null;
|
|
138
|
+
};
|
|
139
|
+
}>;
|
|
120
140
|
/**
|
|
121
141
|
* How long to wait before repeating this request, or `null` for "do not".
|
|
122
142
|
*
|
|
123
143
|
* The decision branches on `error.type` and NOT on the status, which is the
|
|
124
|
-
* whole point. All
|
|
144
|
+
* whole point. All FOUR of these are 429, and they mean four different things:
|
|
125
145
|
*
|
|
126
146
|
* `rate_limited` — the per-key request limiter. Transient, measured in
|
|
127
147
|
* seconds, and exactly what a retry is for.
|
|
128
|
-
* `
|
|
129
|
-
*
|
|
148
|
+
* `platform_paused` — the platform-wide daily send ceiling. Also transient,
|
|
149
|
+
* and NOT a statement about this account: nothing the customer does
|
|
150
|
+
* clears it and upgrading does not help, so it belongs with
|
|
151
|
+
* `rate_limited` and never with the two below. Its `Retry-After` is a few
|
|
152
|
+
* minutes, because the wait is neither a calendar boundary nor a clock a
|
|
153
|
+
* client can compute.
|
|
154
|
+
* `daily_limit_reached` — the account's warmup cap. `Retry-After` is the
|
|
155
|
+
* seconds until midnight UTC.
|
|
130
156
|
* `monthly_limit_reached` — the plan allowance. `Retry-After` can be
|
|
131
157
|
* weeks.
|
|
132
158
|
*
|
|
133
159
|
* A retry loop written against the status treats the last two as a hiccup and
|
|
134
160
|
* hammers a wall it cannot get through until the calendar moves — burning the
|
|
135
|
-
* caller's own rate limit on requests that are all going to be refused.
|
|
161
|
+
* caller's own rate limit on requests that are all going to be refused. The
|
|
162
|
+
* inverse mistake is just as bad: classing `platform_paused` with them tells a
|
|
163
|
+
* customer their allowance is spent when it is not, and stops a retry that
|
|
164
|
+
* would have succeeded a few minutes later.
|
|
165
|
+
*
|
|
166
|
+
* `platform_paused` is therefore retryable here. Whether it is retried
|
|
167
|
+
* IN-PROCESS is a separate question, settled below by `maxRetryDelayMs` on the
|
|
168
|
+
* honest length of the wait rather than by the type: a several-minute
|
|
169
|
+
* `Retry-After` exceeds the 60s default, so the error comes back to the caller
|
|
170
|
+
* to schedule — which is right for a request handler, and different from the
|
|
171
|
+
* quota rule, which refuses in-process retries however short the wait.
|
|
136
172
|
*/
|
|
137
173
|
private retryDelayFor;
|
|
138
174
|
private backoff;
|
package/dist/http.js
CHANGED
|
@@ -15,7 +15,7 @@ const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
|
|
|
15
15
|
const BASE_BACKOFF_MS = 500;
|
|
16
16
|
const MAX_BACKOFF_MS = 8_000;
|
|
17
17
|
/** Kept in step with package.json — it is the only place it is stated twice. */
|
|
18
|
-
export const SDK_VERSION = '0.
|
|
18
|
+
export const SDK_VERSION = '0.3.0';
|
|
19
19
|
const defaultSleep = (ms) => new Promise((resolve) => {
|
|
20
20
|
const timer = setTimeout(resolve, ms);
|
|
21
21
|
// Never hold a process open just to wait out a retry.
|
|
@@ -133,22 +133,88 @@ export class HttpClient {
|
|
|
133
133
|
attempt += 1;
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* A GET that returns bytes, not JSON — attachment downloads.
|
|
138
|
+
*
|
|
139
|
+
* Requires the response to support `arrayBuffer()`; the global fetch does,
|
|
140
|
+
* and an injected fetch that does not gets a clear error rather than a
|
|
141
|
+
* corrupted file — reading binary through `text()` mangles every byte
|
|
142
|
+
* sequence that is not valid UTF-8, which is most of them.
|
|
143
|
+
*/
|
|
144
|
+
async sendBinary(req) {
|
|
145
|
+
const url = this.buildUrl(req.path, req.query);
|
|
146
|
+
const headers = {
|
|
147
|
+
...this.extraHeaders,
|
|
148
|
+
...(req.options?.headers ?? {}),
|
|
149
|
+
'user-agent': this.userAgent,
|
|
150
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
151
|
+
};
|
|
152
|
+
let attempt = 0;
|
|
153
|
+
for (;;) {
|
|
154
|
+
let response;
|
|
155
|
+
try {
|
|
156
|
+
response = await this.attempt(url, { method: req.method, headers }, req.options);
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
const error = transportError(cause);
|
|
160
|
+
if (attempt < this.maxRetries) {
|
|
161
|
+
await this.backoff(attempt, undefined);
|
|
162
|
+
attempt += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
if (response.status < 400) {
|
|
168
|
+
if (typeof response.arrayBuffer !== 'function') {
|
|
169
|
+
throw new Error('the injected fetch response does not implement arrayBuffer(), which binary downloads require');
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
status: response.status,
|
|
173
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
174
|
+
headers: response.headers,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const text = await response.text();
|
|
178
|
+
const error = errorFromResponse(response.status, text, response.headers.get('retry-after') ?? null);
|
|
179
|
+
const wait = this.retryDelayFor(error, req, attempt);
|
|
180
|
+
if (wait === null)
|
|
181
|
+
throw error;
|
|
182
|
+
await this.sleep(wait);
|
|
183
|
+
attempt += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
136
186
|
/**
|
|
137
187
|
* How long to wait before repeating this request, or `null` for "do not".
|
|
138
188
|
*
|
|
139
189
|
* The decision branches on `error.type` and NOT on the status, which is the
|
|
140
|
-
* whole point. All
|
|
190
|
+
* whole point. All FOUR of these are 429, and they mean four different things:
|
|
141
191
|
*
|
|
142
192
|
* `rate_limited` — the per-key request limiter. Transient, measured in
|
|
143
193
|
* seconds, and exactly what a retry is for.
|
|
144
|
-
* `
|
|
145
|
-
*
|
|
194
|
+
* `platform_paused` — the platform-wide daily send ceiling. Also transient,
|
|
195
|
+
* and NOT a statement about this account: nothing the customer does
|
|
196
|
+
* clears it and upgrading does not help, so it belongs with
|
|
197
|
+
* `rate_limited` and never with the two below. Its `Retry-After` is a few
|
|
198
|
+
* minutes, because the wait is neither a calendar boundary nor a clock a
|
|
199
|
+
* client can compute.
|
|
200
|
+
* `daily_limit_reached` — the account's warmup cap. `Retry-After` is the
|
|
201
|
+
* seconds until midnight UTC.
|
|
146
202
|
* `monthly_limit_reached` — the plan allowance. `Retry-After` can be
|
|
147
203
|
* weeks.
|
|
148
204
|
*
|
|
149
205
|
* A retry loop written against the status treats the last two as a hiccup and
|
|
150
206
|
* hammers a wall it cannot get through until the calendar moves — burning the
|
|
151
|
-
* caller's own rate limit on requests that are all going to be refused.
|
|
207
|
+
* caller's own rate limit on requests that are all going to be refused. The
|
|
208
|
+
* inverse mistake is just as bad: classing `platform_paused` with them tells a
|
|
209
|
+
* customer their allowance is spent when it is not, and stops a retry that
|
|
210
|
+
* would have succeeded a few minutes later.
|
|
211
|
+
*
|
|
212
|
+
* `platform_paused` is therefore retryable here. Whether it is retried
|
|
213
|
+
* IN-PROCESS is a separate question, settled below by `maxRetryDelayMs` on the
|
|
214
|
+
* honest length of the wait rather than by the type: a several-minute
|
|
215
|
+
* `Retry-After` exceeds the 60s default, so the error comes back to the caller
|
|
216
|
+
* to schedule — which is right for a request handler, and different from the
|
|
217
|
+
* quota rule, which refuses in-process retries however short the wait.
|
|
152
218
|
*/
|
|
153
219
|
retryDelayFor(error, req, attempt) {
|
|
154
220
|
if (attempt >= this.maxRetries)
|
package/dist/index.d.ts
CHANGED
|
@@ -8,8 +8,8 @@ export { Posthaste } from './client.js';
|
|
|
8
8
|
export { AccountResource, ApiKeysResource, BillingResource, DomainsResource, EmailsResource, MessagesResource, SuppressionsResource, WebhooksResource, } from './client.js';
|
|
9
9
|
export { PosthasteError, isPosthasteError, type PosthasteErrorType, type KnownErrorType, type FieldError, } from './errors.js';
|
|
10
10
|
export { SDK_VERSION, type PosthasteOptions, type RequestOptions, type FetchLike, type PosthasteRequestInit, type PosthasteResponse, } from './http.js';
|
|
11
|
-
export { autoPaginate, collect, type PageFetcher } from './pagination.js';
|
|
11
|
+
export { autoPaginate, collect, type CursorParam, type CursorParams, type PageFetcher, type WalkablePage, } from './pagination.js';
|
|
12
12
|
export { verifyWebhook, parseWebhookEvent, SIGNATURE_HEADER, DELIVERY_ID_HEADER, ATTEMPT_HEADER, type VerifyWebhookOptions, type WebhookVerifyResult, type WebhookVerifyFailure, } from './webhooks.js';
|
|
13
13
|
export type { AccountId, ApiKeyId, DomainId, EventId, InvoiceId, MessageId, PaymentId, PrefixedId, SubscriptionId, SuppressionId, WebhookId, } from './ids.js';
|
|
14
|
-
export { EVENT_TYPES, MESSAGE_STATUSES, SCOPES, SUPPRESSION_REASONS, type Account, type AccountPlan, type AccountSending, type AccountStatus, type AccountSubscriptionSummary, type ApiKey, type Billing, type BillingCharge, type BillingEvent, type BillingHistory, type BillingPayment, type BillingPlan, type BillingProfile, type BillingSubscription, type ChainVerification, type CheckStatus, type CloudflarePublishResult, type ConnectCloudflareParams, type CreateDomainParams, type CreateSuppressionParams, type CreateWebhookParams, type CreatedDomain, type CreatedSuppression, type CreatedWebhook, type DnsHost, type DnsRecord, type Domain, type DomainSetup, type DomainStatus, type DomainVerification, type EventType, type Invoice, type InvoiceDetail, type KeyEnvironment, type List, type ListMessagesParams, type ListSuppressionsParams, type Message, type MessageContent, type MessageStats, type MessageStatsParams, type MessageStatus, type MessageSummary, type Page, type PaginationParams, type PaymentStatus, type PlanId, type RecordCheck, type Scope, type SendEmailParams, type SendEmailResult, type StatsDay, type StatsTotals, type SubscriptionStatus, type Suppression, type SuppressionReason, type Usage, type UsageDay, type WaybillEntry, type Webhook, type WebhookEvent, type WebhookStatus, } from './types.js';
|
|
14
|
+
export { EVENT_TYPES, MESSAGE_STATUSES, SCOPES, SUPPRESSION_REASONS, type Account, type AccountPlan, type AccountSending, type AccountStatus, type AccountSubscriptionSummary, type ApiKey, type Billing, type BillingCharge, type BillingEvent, type BillingHistory, type BillingPayment, type BillingPlan, type BillingProfile, type BillingSubscription, type ChainVerification, type CheckStatus, type CloudflarePublishResult, type ConnectCloudflareParams, type CreateDomainParams, type CreateSuppressionParams, type CreateWebhookParams, type CreatedDomain, type CreatedSuppression, type CreatedWebhook, type CursorPage, type DnsHost, type DnsRecord, type Domain, type DomainSetup, type DomainStatus, type DomainVerification, type EventType, type Invoice, type InvoiceDetail, type KeyEnvironment, type List, type ListApiKeysParams, type ListBillingHistoryParams, type ListDomainsParams, type ListInvoicesParams, type ListMessagesParams, type ListSuppressionsParams, type ListWebhooksParams, type Message, type AttachmentMeta, type MessageContent, type MessageStats, type MessageStatsParams, type MessageStatus, type MessageSummary, type Page, type PaginationParams, type PaymentStatus, type PlanId, type RecordCheck, type Scope, type SendAttachment, type SendEmailParams, type SendEmailResult, type SentCopy, type StatsDay, type StatsTotals, type SubscriptionStatus, type Suppression, type SuppressionReason, type Usage, type UsageDay, type WaybillEntry, type Webhook, type WebhookEvent, type WebhookStatus, } from './types.js';
|
|
15
15
|
export { Posthaste as default } from './client.js';
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export { Posthaste } from './client.js';
|
|
|
8
8
|
export { AccountResource, ApiKeysResource, BillingResource, DomainsResource, EmailsResource, MessagesResource, SuppressionsResource, WebhooksResource, } from './client.js';
|
|
9
9
|
export { PosthasteError, isPosthasteError, } from './errors.js';
|
|
10
10
|
export { SDK_VERSION, } from './http.js';
|
|
11
|
-
export { autoPaginate, collect } from './pagination.js';
|
|
11
|
+
export { autoPaginate, collect, } from './pagination.js';
|
|
12
12
|
export { verifyWebhook, parseWebhookEvent, SIGNATURE_HEADER, DELIVERY_ID_HEADER, ATTEMPT_HEADER, } from './webhooks.js';
|
|
13
13
|
export { EVENT_TYPES, MESSAGE_STATUSES, SCOPES, SUPPRESSION_REASONS, } from './types.js';
|
|
14
14
|
export { Posthaste as default } from './client.js';
|