@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/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
- /** `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 });
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: params,
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
- /** `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 });
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` — the 100 most recent keys, revoked ones included.
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({ method: 'GET', path: '/v1/api-keys', idempotent: true, options });
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
- /** `GET /v1/billing/history` — the hash-chained commercial record, oldest first, plus its verification. */
433
- history(options) {
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
- /** `GET /v1/billing/invoices` — the 100 most recent, newest first. Not paginated. */
442
- invoices(options) {
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' | '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';
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 limiting) or the `Retry-After` header
56
- * (quota exhaustion), in that order.
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
- /** True for the per-key request-rate limiter, which is short and transient. */
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 limiting) or the `Retry-After` header
28
- * (quota exhaustion), in that order.
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
- /** True for the per-key request-rate limiter, which is short and transient. */
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.1.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 three of these are 429:
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
- * `daily_limit_reached` — the warmup cap. `Retry-After` is the seconds
129
- * until midnight UTC.
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.1.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 three of these are 429:
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
- * `daily_limit_reached` — the warmup cap. `Retry-After` is the seconds
145
- * until midnight UTC.
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';