millionsend 0.7.0 → 0.9.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +257 -14
  3. package/dist/error.d.ts +6 -1
  4. package/dist/error.d.ts.map +1 -1
  5. package/dist/error.js +3 -2
  6. package/dist/error.js.map +1 -1
  7. package/dist/http.d.ts +10 -3
  8. package/dist/http.d.ts.map +1 -1
  9. package/dist/http.js +59 -7
  10. package/dist/http.js.map +1 -1
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +4 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/query.d.ts +4 -0
  16. package/dist/query.d.ts.map +1 -1
  17. package/dist/query.js +8 -0
  18. package/dist/query.js.map +1 -1
  19. package/dist/resources/api-keys.d.ts +1 -0
  20. package/dist/resources/api-keys.d.ts.map +1 -1
  21. package/dist/resources/api-keys.js +2 -0
  22. package/dist/resources/api-keys.js.map +1 -1
  23. package/dist/resources/broadcasts.d.ts +4 -1
  24. package/dist/resources/broadcasts.d.ts.map +1 -1
  25. package/dist/resources/broadcasts.js +4 -1
  26. package/dist/resources/broadcasts.js.map +1 -1
  27. package/dist/resources/contacts.d.ts +7 -3
  28. package/dist/resources/contacts.d.ts.map +1 -1
  29. package/dist/resources/contacts.js +17 -5
  30. package/dist/resources/contacts.js.map +1 -1
  31. package/dist/resources/domains.d.ts +9 -2
  32. package/dist/resources/domains.d.ts.map +1 -1
  33. package/dist/resources/domains.js +10 -2
  34. package/dist/resources/domains.js.map +1 -1
  35. package/dist/resources/emails.d.ts +12 -2
  36. package/dist/resources/emails.d.ts.map +1 -1
  37. package/dist/resources/emails.js +13 -2
  38. package/dist/resources/emails.js.map +1 -1
  39. package/dist/resources/inboxes.d.ts +115 -0
  40. package/dist/resources/inboxes.d.ts.map +1 -0
  41. package/dist/resources/inboxes.js +349 -0
  42. package/dist/resources/inboxes.js.map +1 -0
  43. package/dist/resources/receiving.d.ts +57 -0
  44. package/dist/resources/receiving.d.ts.map +1 -0
  45. package/dist/resources/receiving.js +115 -0
  46. package/dist/resources/receiving.js.map +1 -0
  47. package/dist/resources/templates.d.ts.map +1 -1
  48. package/dist/resources/templates.js +4 -1
  49. package/dist/resources/templates.js.map +1 -1
  50. package/dist/resources/topics.d.ts.map +1 -1
  51. package/dist/resources/topics.js +5 -1
  52. package/dist/resources/topics.js.map +1 -1
  53. package/dist/resources/webhooks.d.ts.map +1 -1
  54. package/dist/resources/webhooks.js +7 -1
  55. package/dist/resources/webhooks.js.map +1 -1
  56. package/dist/types.d.ts +645 -7
  57. package/dist/types.d.ts.map +1 -1
  58. package/package.json +19 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  Versions follow the MillionSend API they target; the API stays wire-compatible with Resend.
4
4
 
5
+ ## 0.9.0 — unreleased
6
+
7
+ - `emails.receiving`: `list` (with `wait` long-polling and the `inboxId`, `to`, `from`, `since`, `subjectContains`, `unread`, `includeQuarantined` and `quarantined` filters), `get`, `reply`, `forward`, `remove`, and `attachments.list` / `attachments.get`.
8
+ - `inboxes`: `create`, `list`, `get`, `update`, `remove`, plus `safety`, `codes.latest`, `quarantine`, `labels`, `threads` (with `threads.emails`) and `drafts` (with `send` and `reject`).
9
+ - Replies, forwards and `drafts.send` resolve to `{ kind: "sent", email }`, or `{ kind: "draft", draft }` when the API holds the message for approval (HTTP 202).
10
+ - A request with `wait` gets a deadline that outlasts it, so long-polls past `timeoutMs` no longer abort.
11
+ - An id that is empty, `.` or `..` resolves to `{ error: { name: "validation_error", statusCode: null } }` without a request: URL normalization would have sent it to another resource.
12
+ - The long-poll's `held` items carry `reason` (`pending` or `quarantined`) and `quarantine_reason`.
13
+ - `WebhookEvent` names `email.received` and the 14 `inbox.*` events; webhooks take `inboxIds`; `apiKeys.create` takes `inboxId`; `domains.update` takes `receivingCatchAll`; `Domain`, `ApiKeyListItem`, `WebhookListItem` and `Usage.limits` gain `receiving_catch_all`, `inbox_id`, `inbox_ids` and `inboxes`.
14
+ - `inboxes.create` takes `username` (the part before the @) and `domainId` (a `domains.list` row instead of `emailAddress`); `inboxes.codes.latest` takes `since`; thread list and search items carry an optional `quarantined` count.
15
+ - Domains carry `shared`; the team's shared row, listed last by `domains.list`, adds `sending_mode` (`DomainSendingMode`) and `sending_mode_reason`.
16
+ - Every `ErrorResponse` carries `retryAfter`: the `retry-after` header in seconds, or `null`. A deep-equality check on `error` needs the new key.
17
+ - Contacts batch `errors` items are `BatchContactError`, with the `statusCode` and `name` strict mode would answer.
18
+ - The README covers newest-first lists and their cursors, retries, the shared domain's row, sending rules and refusals, and the recipients the API refuses.
19
+
20
+ Each feature needs this MillionSend version or later. Older instances answer 404 on paths they lack and ignore options they don't know, so an inbox created with `username` or `domainId` on one can get another address: check its `email_address`.
21
+
22
+ | MillionSend | Features |
23
+ |---|---|
24
+ | v0.10.0 | `inboxes`; the receiving long-poll, inbox filters, `reply` and `forward` |
25
+ | v0.11.0 | `422` for recipients that can never receive mail; a batch `idempotencyKey` replayed before validation |
26
+ | v0.12.0 | `username`; inboxes that never expire by default; sends from the shared domain under its mode; `name_review_unavailable`; `statusCode` and `name` on contacts batch errors |
27
+ | v0.13.0 | newest-first lists; `since` on `codes.latest`; `quarantined` on thread items; inbox names up to 320 characters; `unread` with an inbox-scoped key; a JSON 404 for unknown routes |
28
+ | v0.14.0 | `shared`, `sending_mode` and `sending_mode_reason` on domains; `domainId` on `inboxes.create`; `subject` up to 998 characters |
29
+
30
+ ## 0.8.0 — 2026-09-08
31
+
32
+ - `contacts.remove(address, { erase: true })` and `contacts.batch.remove({ ids | emails, erase: true })` also scrub the address from email history, event payloads and API logs (`?erase=true` on the single delete, body `erase` on the batch) — a GDPR/LGPD erasure. Without `erase` a delete keeps the contact's emails in the send log, as before. Responses are unchanged. Needs MillionSend v0.6.54.
33
+
5
34
  ## 0.7.0 — 2026-09-05
6
35
 
7
36
  - `contacts.list({ include: ["properties", "topics"] })` attaches the property map and the topic subscriptions to every item (`?include=`), on the team list and on `segmentId` lists alike. Without `include` the item is unchanged.
package/README.md CHANGED
@@ -25,7 +25,7 @@ const ms = new MillionSend("ms_123"); // Cloud; self-hosted: { baseUrl: "https:/
25
25
 
26
26
  const { data, error } = await ms.emails.send({
27
27
  from: "Acme <onboarding@acme.dev>",
28
- to: "delivered@resend.dev",
28
+ to: "success@simulator.amazonses.com",
29
29
  subject: "Hello from MillionSend",
30
30
  html: "<strong>It works!</strong>",
31
31
  });
@@ -56,27 +56,68 @@ new MillionSend(apiKey?, {
56
56
  ## Errors
57
57
 
58
58
  No method throws for an API error — every call resolves to `{ data, error }`.
59
- `error` is `null` on success; otherwise `{ name, message, statusCode }` where
59
+ `error` is `null` on success; otherwise `{ name, message, statusCode, retryAfter }` where
60
60
  `name` is a stable snake_case code you can switch on (`validation_error`,
61
- `not_found`, `restricted_api_key`, `sending_paused`,
61
+ `not_found`, `forbidden`, `restricted_api_key`, `rate_limit_exceeded`, `sending_paused`,
62
62
  `all_recipients_suppressed`, …). Client-side and transport failures carry
63
- `statusCode: null`.
63
+ `statusCode: null`. `retryAfter` is the response's `retry-after` header in seconds, or
64
+ `null` when it has none.
64
65
 
65
66
  `emails.send` and `batch.send` answer `422 all_recipients_suppressed` when every
66
67
  `to` recipient is on the suppression list or opted out of the send's `topicId`.
67
68
 
69
+ Inboxes add `inbox_send_policy` (403, a send the inbox's rules or the shared domain hold),
70
+ `inbox_send_cap_reached` (429, the inbox's or the team's daily cap), `approval_required` (403),
71
+ `email_quarantined` (403), `email_blocked` (410, a download of an email that carries malware),
72
+ `too_many_waits` (429, too many concurrent long-polls) and `name_review_unavailable` (503, a
73
+ name on the shared domain can't be reviewed yet).
74
+
68
75
  ```ts
69
76
  const { data, error } = await ms.emails.get(id);
70
77
  if (error?.name === "not_found") { /* … */ }
71
78
  ```
72
79
 
80
+ ### Retries
81
+
82
+ The SDK never retries on its own. Worth retrying after `retryAfter` seconds:
83
+ `503 name_review_unavailable`, `429 too_many_waits`, and `429 rate_limit_exceeded` when
84
+ `retryAfter` is 60 or less (the per-minute limits). Not worth retrying in the same process:
85
+ `429 inbox_send_cap_reached` and the daily name-review budget (`429 rate_limit_exceeded`
86
+ with a `retryAfter` that runs to the next UTC midnight), and any 403, 404, 409 or 422. An
87
+ unknown route answers `404 not_found` (`application_error` from servers before v0.13.0).
88
+
89
+ Resend a send with the same `idempotencyKey`: a batch the API already accepted gets its
90
+ stored response back. A held draft (`kind: "draft"`) is not a failure; never resend it.
91
+ `inboxes.create` takes no idempotency key: after a 429 or 503 nothing was created, but
92
+ after a timeout a resend can create a second inbox. With a chosen `username`, a 409 on the
93
+ resend usually means the first attempt succeeded: read it with `inboxes.get(address)`.
94
+
73
95
  ## Resources
74
96
 
75
97
  Inputs are camelCase and mapped to the wire's snake_case (`replyTo` → `reply_to`,
76
98
  `scheduledAt` → `scheduled_at`, `topicId` → `topic_id`, …). Every field you pass
77
99
  is sent; nothing is dropped client-side. Responses are the wire shape verbatim.
78
100
  List endpoints take `{ limit, after, before }` (keyset cursors) and return
79
- `{ object: "list", data, has_more }`.
101
+ `{ object: "list", data, has_more }`. Lists run newest first (MillionSend v0.13.0 and
102
+ later): `after` takes the last id of a page and pages toward older items, `before` takes
103
+ the first id and pages toward newer ones, and `has_more` refers to that direction. Threads
104
+ sort by last activity, drafts by last update, a thread's emails run oldest first, and labels
105
+ and topics are not paginated.
106
+
107
+ ```ts
108
+ // Incremental sync: page toward newer items, starting from the newest id you stored
109
+ let before = newestStoredId;
110
+ for (;;) {
111
+ const { data } = await ms.emails.list({ before, limit: 100 });
112
+ if (!data?.data.length) break;
113
+ save(data.data); // newest first within the page
114
+ if (!data.has_more) break;
115
+ before = data.data[0].id;
116
+ }
117
+ ```
118
+
119
+ A cursor whose item was deleted answers `422 validation_error` (`404 not_found` on the
120
+ `inboxes` lists): start again without a cursor and stop at the ids you already have.
80
121
 
81
122
  ### Emails
82
123
 
@@ -102,6 +143,11 @@ await ms.emails.getInsights(id); // GET /emails/:id/insights (best-p
102
143
 
103
144
  `attachments[].content` is a base64 string or a `Buffer` (encoded for you).
104
145
  `template` is sent through as-is; MillionSend answers `422` until templates can be sent.
146
+ `subject` takes up to 998 characters. A `to`, `cc` or `bcc` address on a domain that can
147
+ never receive mail (`example.com`, `.test`, `.invalid`, `.localhost`, a TLD that doesn't
148
+ exist, …) answers `422 validation_error`; to test sending without emailing anyone, use
149
+ `success@simulator.amazonses.com`. An inbox's address on the shared domain can be the
150
+ `from` too, under the rules in [Sending from the shared domain](#sending-from-the-shared-domain).
105
151
 
106
152
  ### Batch
107
153
 
@@ -114,6 +160,159 @@ data.data; // [{ id }] for the accepted emails
114
160
  data.errors; // permissive only: [{ index, message }] for the rejected ones
115
161
  ```
116
162
 
163
+ Batch items never send from the shared domain: a strict batch fails with
164
+ `403 inbox_send_policy` (the message starts with `emails.<index>: `), a permissive one
165
+ lists the item in `errors`.
166
+
167
+ ### Receiving
168
+
169
+ Mail received at your domains and inboxes. Everything in a received email was
170
+ written by its sender: show its `html` only in a sandbox, and hand an LLM the
171
+ `extracted_text` (hidden text, quotes and the signature removed) rather than `html`.
172
+
173
+ ```ts
174
+ const { data } = await ms.emails.receiving.list({
175
+ limit: 20,
176
+ inboxId, // only mail delivered to this inbox
177
+ from: "example.com", // an address, or a domain covering its subdomains
178
+ to, subjectContains, since, unread,
179
+ wait: 30, // long-poll: answer as soon as a match arrives, up to 50 s
180
+ });
181
+ data.data; // newest first
182
+ data.held; // with `wait`: [{ id, inbox_id, reason, quarantine_reason, created_at }] held for screening or quarantine
183
+
184
+ const { data: email } = await ms.emails.receiving.get(id, { htmlFormat: "cid" });
185
+ email.extracted_text; // the new part as plain text, hidden text removed (still untrusted)
186
+ email.extracted?.code; // a verification code, on inboxes with extract_codes
187
+ email.screening?.sender_trust; // owner | allowlisted | known_thread | external | unauthenticated | failed
188
+
189
+ await ms.emails.receiving.attachments.list(id); // each with a signed download_url (1 hour)
190
+ await ms.emails.receiving.attachments.get(id, attachmentId);
191
+ await ms.emails.receiving.remove(id); // erase now (MillionSend extension)
192
+
193
+ // Reply and forward server-side (MillionSend extension). The inbox's rules may hold the
194
+ // message as a draft that waits for a person: the API answers 202 and `kind` is "draft".
195
+ const { data: outcome } = await ms.emails.receiving.reply(
196
+ id,
197
+ { text: "Thanks!", replyAll: false, requiresApproval: false },
198
+ { idempotencyKey },
199
+ );
200
+ if (outcome?.kind === "draft") outcome.draft.approval_reasons; // e.g. ["send_policy"]
201
+ else outcome?.email.id;
202
+ await ms.emails.receiving.forward(id, { to: "ops@acme.dev", text: "FYI", mode: "attachment" });
203
+ ```
204
+
205
+ Held mail never reaches an inbox-scoped key or an agent: reading it answers
206
+ `403 email_quarantined`. A full-access team key sees it with `includeQuarantined: true`.
207
+ `unread` needs `inboxId` with a team key; an inbox-scoped key reads its own inbox. An
208
+ `inboxId` that doesn't exist answers `404 not_found` at once, even with `wait`.
209
+
210
+ ### Inboxes
211
+
212
+ An inbox is one address that receives mail, read by an agent (`kind: "agent"`,
213
+ the default) or by people (`kind: "human"`). Threads, labels, drafts and their
214
+ paths mirror Resend's Inboxes beta; safety rules, quarantine and codes are
215
+ MillionSend extensions.
216
+
217
+ ```ts
218
+ const { data: inbox } = await ms.inboxes.create({
219
+ name: "Signup bot", // no emailAddress: an address on the shared domain
220
+ username: "signup-bot", // signup-bot@<team>.<shared domain>; omit it for a random one
221
+ expiresIn: "24h", // "1h" to "365d"; omitted or null, it never expires
222
+ safety: { acceptFrom: "anyone" },
223
+ });
224
+ await ms.inboxes.create({ emailAddress: "support@acme.dev", name: "Support", kind: "human" });
225
+ await ms.inboxes.create({ domainId, username: "orders" }); // a domains.list id: orders@ that domain
226
+ await ms.inboxes.list({ kind: "agent" });
227
+ await ms.inboxes.get("support@acme.dev"); // by id or address
228
+ await ms.inboxes.update(inbox.id, { fromName: "Acme", expiresIn: "7d" });
229
+ await ms.inboxes.remove(inbox.id); // also revokes its inbox-scoped keys
230
+
231
+ // Codes and sign-in links, without parsing HTML (wait long-polls up to 50 s)
232
+ const { data: code } = await ms.inboxes.codes.latest(inbox.id, { from: "github.com", wait: 30 });
233
+ code.status; // "found" | "none" | "ambiguous" | "timeout"; code.code, code.url
234
+ // The next sign-in: only a code received after the one already used
235
+ await ms.inboxes.codes.latest(inbox.id, { since: code.received_at, wait: 30 });
236
+
237
+ // Safety rules: who can write in, what the inbox may send
238
+ await ms.inboxes.safety.get(inbox.id);
239
+ await ms.inboxes.safety.update(inbox.id, { acceptFrom: "allowlist", allowlist: ["*@acme.dev"], sendCap: 50 });
240
+
241
+ // Quarantine: held mail, kept until a person releases it
242
+ await ms.inboxes.quarantine.list(inbox.id);
243
+ await ms.inboxes.quarantine.release(inbox.id, emailId, { trustSender: true });
244
+
245
+ // Threads and their messages
246
+ await ms.inboxes.threads.list(inbox.id, { folders: ["inbox", "archive"], read: false });
247
+ await ms.inboxes.threads.list(inbox.id, { includeQuarantined: true }); // owner views: a `quarantined` count per thread
248
+ await ms.inboxes.threads.search(inbox.id, { query: "invoice", from: ["example.com"] });
249
+ await ms.inboxes.threads.get(inbox.id, threadId);
250
+ await ms.inboxes.threads.update(inbox.id, threadId, { read: true, folder: "archive", labelId });
251
+ await ms.inboxes.threads.remove(inbox.id, threadId); // to trash, purged after 30 days
252
+ await ms.inboxes.threads.emails.list(inbox.id, threadId); // oldest first; includeQuarantined for owner views
253
+ await ms.inboxes.threads.emails.get(inbox.id, threadId, emailId);
254
+ await ms.inboxes.threads.emails.reply(inbox.id, threadId, emailId, { text: "Done." }, { idempotencyKey });
255
+ await ms.inboxes.threads.emails.forward(inbox.id, threadId, emailId, { to: "ops@acme.dev" });
256
+
257
+ // Labels (up to 100 per inbox)
258
+ await ms.inboxes.labels.create(inbox.id, { name: "Billing", color: "#FF8800" });
259
+ await ms.inboxes.labels.list(inbox.id);
260
+ await ms.inboxes.labels.update(inbox.id, labelId, { name: "Invoices" });
261
+ await ms.inboxes.labels.remove(inbox.id, labelId);
262
+
263
+ // Drafts, and the ones that wait for approval
264
+ await ms.inboxes.drafts.create(inbox.id, { to: "ada@acme.dev", subject: "Hi", text: "…" });
265
+ await ms.inboxes.drafts.list(inbox.id);
266
+ await ms.inboxes.drafts.get(inbox.id, draftId);
267
+ await ms.inboxes.drafts.update(inbox.id, draftId, { cc: null }); // null clears
268
+ await ms.inboxes.drafts.send(inbox.id, draftId, { idempotencyKey }); // { kind: "sent" | "draft" }
269
+ await ms.inboxes.drafts.reject(inbox.id, draftId);
270
+ await ms.inboxes.drafts.remove(inbox.id, draftId);
271
+ ```
272
+
273
+ Replies, forwards and `drafts.send` resolve to `{ kind: "sent", email }` or, when
274
+ the inbox's rules hold the message for a person (HTTP 202), `{ kind: "draft", draft }`.
275
+ An agent's reply to a Reply-To at another organization is held that way too, with
276
+ `"reply_to_redirect"` among its `approval_reasons`.
277
+ A key restricted to the inbox cannot send a draft that waits for approval
278
+ (`403 approval_required`); a person in the dashboard or a full-access team key approves it.
279
+ Hosted agent settings (`/inboxes/:id/agent`) are not exposed: MillionSend runs no agent of its own.
280
+
281
+ An inbox never expires unless `expiresIn` says so, and its `name` takes up to 320
282
+ characters. `domainId` takes a `domains.list` row instead of `emailAddress`: the
283
+ `shared: true` row works like leaving both out, and one of your own domains needs a
284
+ `username`.
285
+
286
+ On the shared domain, an inbox unused for 90 days (no mail in or out, no API call on it)
287
+ is deleted, which fires `inbox.expired`, then `inbox.deleted`. A chosen `username` or
288
+ `fromName` there is reviewed first: reserved names (`postmaster`, `admin`, `support`,
289
+ `noreply`, …), a `fwd-` prefix and look-alikes of brands answer `422`, a username in use
290
+ `409`, and `429` means today's review budget is spent. `503 name_review_unavailable`
291
+ means try again after `retryAfter` seconds: the review is down, or the team's new
292
+ subdomain still waits for its own, and then every shared or forwarding create gets it.
293
+ Safety rules differ there too: `sendPolicy: "allowlist"` answers `422`, and so does
294
+ `"open"` unless the team's `sending_mode` is `open`; `ownerAddresses` don't make senders
295
+ trusted; `forwardTo` takes team members only.
296
+
297
+ #### Sending from the shared domain
298
+
299
+ What the team's shared addresses may send is the `sending_mode` of the `shared: true` row
300
+ of `domains.list`: `open` starts conversations, `reply_only` only answers inside a thread
301
+ to its own correspondents, and `receive_only` sends nothing. The same answers come back
302
+ from `emails.send`, replies, forwards and drafts:
303
+
304
+ - `403 inbox_send_policy`: the mode doesn't allow this send (or the inbox's name looks
305
+ like a brand, which only replies even under `open`);
306
+ - `429 inbox_send_cap_reached`: the team's daily cap for the shared domain, with a
307
+ `retryAfter` that runs to the next UTC midnight;
308
+ - `422 validation_error`: more than 10 distinct recipients across `to`, `cc` and `bcc`.
309
+
310
+ `emails.send` takes a shared address in `open` mode only: the inbox's exact
311
+ `email_address` (no +tag), a display name left out or equal to its `from_name`, and no
312
+ `replyTo`, `attachments`, `headers` or `scheduledAt`. A key restricted to a domain gets
313
+ `403 restricted_api_key`. Batch items and broadcasts never send from the shared domain.
314
+ Switch on `name` and `statusCode`; the messages may change.
315
+
117
316
  ### Contacts
118
317
 
119
318
  Contacts are team-global — one list per team, no audiences.
@@ -128,7 +327,9 @@ await ms.contacts.create({
128
327
  await ms.contacts.get({ email }); // by id or email (email wins)
129
328
  await ms.contacts.get(contactId); // bare string works too
130
329
  await ms.contacts.update({ id, unsubscribed: true, firstName: null }); // null clears
131
- await ms.contacts.remove({ email });
330
+ await ms.contacts.remove({ email }); // the contact's emails stay in the send log
331
+ await ms.contacts.remove({ email }, { erase: true }); // ?erase=true also scrubs the address from
332
+ // email history, events and API logs (GDPR/LGPD)
132
333
  await ms.contacts.list({ limit: 50 });
133
334
  await ms.contacts.list({ segmentId }); // GET /segments/:id/contacts
134
335
  // Bulk read (MillionSend extension): attach properties and topic subscriptions to every item,
@@ -147,7 +348,7 @@ const { data } = await ms.contacts.batch.create(items, {
147
348
  });
148
349
  data.data; // [{ index, id, status: "created" | "updated" | "skipped" }]
149
350
  data.counts; // { created, updated, skipped, failed }
150
- data.errors; // permissive only
351
+ data.errors; // permissive only: [{ index, statusCode, name, message }], as strict mode would answer
151
352
 
152
353
  // Bulk lookup (MillionSend extension): up to 1000 contacts by id or email in one request,
153
354
  // in request order; unknown entries are listed, not errors — one request against the rate limit
@@ -155,8 +356,9 @@ const { data } = await ms.contacts.batch.get([contactId, { email }], { include:
155
356
  data.data; // [{ object: "contact", id, email, ..., topics }] — the contacts found
156
357
  data.missing; // [{ index, email }] — request entries that matched nobody
157
358
 
158
- // Bulk delete (MillionSend extension): up to 1000 per call, by ids or by emails
159
- const { data } = await ms.contacts.batch.remove({ emails }); // or { ids }
359
+ // Bulk delete (MillionSend extension): up to 1000 per call, by ids or by emails.
360
+ // Their emails stay in the send log; erase: true also scrubs each address from email history
361
+ const { data } = await ms.contacts.batch.remove({ emails, erase: true }); // or { ids }
160
362
  data.data; // [{ object: "contact", contact, deleted: true }] — only the rows actually deleted
161
363
 
162
364
  // Segment membership
@@ -210,6 +412,9 @@ await ms.broadcasts.cancel(id); // scheduled only
210
412
  await ms.broadcasts.remove(id); // draft only
211
413
  ```
212
414
 
415
+ `subject` takes up to 998 characters. A broadcast never sends from the shared domain:
416
+ `create({ send: true })` and `send` answer `403 inbox_send_policy`.
417
+
213
418
  ### Suppressions
214
419
 
215
420
  ```ts
@@ -233,9 +438,27 @@ await ms.domains.list();
233
438
  await ms.domains.get(id);
234
439
  await ms.domains.verify(id);
235
440
  await ms.domains.update({ id, clickTracking: false, trackingSubdomain: null }); // null clears
441
+ await ms.domains.update({ id, receivingCatchAll: true }); // accept mail for addresses without an inbox
236
442
  await ms.domains.remove(id);
237
443
  ```
238
444
 
445
+ On an instance with a shared inbox domain, the team's subdomain of it is the last row of
446
+ `domains.list` (`shared: true`; MillionSend v0.14.0 and later). It is verified, has no DNS
447
+ records, and its id is the `domainId` of the team's shared inboxes. Its `sending_mode`
448
+ (`open`, `reply_only` or `receive_only`) says what those inboxes may send, and
449
+ `sending_mode_reason` (`plan` or `instance`, `null` under `open`) why it is held below
450
+ `open`. It is read-only: `update`, `verify` and `remove` answer `403 forbidden`, and an API
451
+ key can't be restricted to it. It comes after every own domain whatever its `created_at`,
452
+ alone on the next page when they exactly fill one. A team whose new subdomain still waits
453
+ for its name review has no such row yet. Older servers send no `shared`: read it as `false`.
454
+
455
+ It sends only from its inboxes' own addresses, so leave it out when picking a domain to send from:
456
+
457
+ ```ts
458
+ const { data } = await ms.domains.list({ limit: 100 });
459
+ const sender = data?.data.find((d) => d.status === "verified" && !d.shared);
460
+ ```
461
+
239
462
  ### Webhooks
240
463
 
241
464
  ```ts
@@ -256,19 +479,32 @@ const { data } = await ms.webhooks.rotate(id, { overlapHours: 24 }); // or { sig
256
479
  data.signing_secret; data.previous_secret_expires_at; // null when the old secret is dropped at once
257
480
  ```
258
481
 
259
- Subscribable events include `email.*`, `deliverability.*`, `quota.*`, `contact.*`
260
- (`created`, `updated`, `deleted`, `unsubscribed`, `resubscribed`, `topic_opt_in`,
261
- `topic_opt_out`) and `suppression.added` / `suppression.removed`.
482
+ Subscribable events include `email.*` (with `email.received`), `deliverability.*`,
483
+ `quota.*`, `contact.*` (`created`, `updated`, `deleted`, `unsubscribed`,
484
+ `resubscribed`, `topic_opt_in`, `topic_opt_out`), `suppression.added` /
485
+ `suppression.removed`, and the `inbox.*` events (`inbox.created`, `inbox.updated`,
486
+ `inbox.deleted`, `inbox.expired`, `inbox.thread.created`, `inbox.thread.folder.updated`,
487
+ `inbox.thread.labels.updated`, `inbox.email.received`, `inbox.email.sent`,
488
+ `inbox.email.quarantined`, `inbox.draft.created`, `inbox.draft.updated`,
489
+ `inbox.draft.sent`, `inbox.draft.deleted`). Inbox events reach only endpoints that
490
+ list them by name. `inboxIds` limits an endpoint's inbox events and `email.received`
491
+ to those inboxes (`null` = every inbox). Inbox events from sends made in the dashboard
492
+ carry `data.source: "dashboard"`.
262
493
 
263
494
  ### API keys
264
495
 
265
496
  ```ts
266
497
  const { data } = await ms.apiKeys.create({ name: "ci", permission: "sending_access", domainId });
267
498
  data.token; // shown once
499
+ await ms.apiKeys.create({ name: "signup-agent", permission: "full_access", inboxId }); // one inbox only
268
500
  await ms.apiKeys.list();
269
501
  await ms.apiKeys.remove(id);
270
502
  ```
271
503
 
504
+ An inbox-scoped key reads only its inbox and sends only from its address; any other
505
+ route answers `403 restricted_api_key`. `domainId` takes one of your own domains: the
506
+ `shared: true` row of `domains.list` answers `422 validation_error`.
507
+
272
508
  ### Templates
273
509
 
274
510
  `id` arguments accept the template id or its alias.
@@ -337,8 +573,15 @@ match `resend`. Notes:
337
573
  answered with `422`. `templates.publish` is a no-op (every save is live).
338
574
  - **MillionSend extensions** (no Resend counterpart): `segments`,
339
575
  `deliverability`, `usage`, `emails.getInsights`, `contacts.batch`,
340
- `contacts.preferencesLink`, `webhooks.rotate`, and the `origin` field on
341
- suppressions.
576
+ `contacts.preferencesLink`, `webhooks.rotate`, the `origin` field on
577
+ suppressions, `emails.receiving.reply` / `remove`, `inboxes.safety`,
578
+ `inboxes.codes`, `inboxes.quarantine`, `inboxes.drafts.reject`, the `username` and
579
+ `domainId` of `inboxes.create`, and `error.retryAfter`.
580
+ - **`emails.receiving.forward(id, payload)`** forwards server-side, from your
581
+ inbox or domain, instead of Resend's client-side re-send of the raw message.
582
+ - **Inboxes** follow Resend's preview shapes with ids as positional arguments
583
+ (`ms.inboxes.threads.emails.reply(inboxId, threadId, emailId, payload)`), and
584
+ return the full object on create.
342
585
 
343
586
  ## License
344
587
 
package/dist/error.d.ts CHANGED
@@ -10,6 +10,11 @@ export interface ErrorResponse {
10
10
  message: string;
11
11
  /** HTTP status; null when the request never reached the API. */
12
12
  statusCode: number | null;
13
+ /**
14
+ * Seconds the response's `retry-after` header asks to wait before trying
15
+ * again; null when it sent none (and on client-side failures).
16
+ */
17
+ retryAfter: number | null;
13
18
  }
14
19
  /** Every method resolves to this — it never throws for an API error. */
15
20
  export type Result<T> = {
@@ -20,5 +25,5 @@ export type Result<T> = {
20
25
  error: ErrorResponse;
21
26
  };
22
27
  /** Coerce an arbitrary parsed error body into the canonical shape. */
23
- export declare function toErrorResponse(body: unknown, status: number): ErrorResponse;
28
+ export declare function toErrorResponse(body: unknown, status: number, retryAfter: number | null): ErrorResponse;
24
29
  //# sourceMappingURL=error.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,uFAAuF;IACvF,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,wEAAwE;AACxE,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,CAAC,CAAC;IAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,aAAa,CAAA;CAAE,CAAC;AAExF,sEAAsE;AACtE,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,aAAa,CAa5E"}
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,uFAAuF;IACvF,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;OAGG;IACH,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,wEAAwE;AACxE,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,CAAC,CAAC;IAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,aAAa,CAAA;CAAE,CAAC;AAExF,sEAAsE;AACtE,wBAAgB,eAAe,CAC7B,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,GAAG,IAAI,GACxB,aAAa,CAef"}
package/dist/error.js CHANGED
@@ -1,16 +1,17 @@
1
1
  /** Coerce an arbitrary parsed error body into the canonical shape. */
2
- export function toErrorResponse(body, status) {
2
+ export function toErrorResponse(body, status, retryAfter) {
3
3
  if (body && typeof body === "object") {
4
4
  const b = body;
5
5
  const name = typeof b.name === "string" ? b.name : "application_error";
6
6
  const message = typeof b.message === "string" ? b.message : `Request failed with status ${status}`;
7
7
  const statusCode = typeof b.statusCode === "number" ? b.statusCode : status;
8
- return { name, message, statusCode };
8
+ return { name, message, statusCode, retryAfter };
9
9
  }
10
10
  return {
11
11
  name: "application_error",
12
12
  message: `Request failed with status ${status}`,
13
13
  statusCode: status,
14
+ retryAfter,
14
15
  };
15
16
  }
16
17
  //# sourceMappingURL=error.js.map
package/dist/error.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"error.js","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAiBA,sEAAsE;AACtE,MAAM,UAAU,eAAe,CAAC,IAAa,EAAE,MAAc;IAC3D,IAAI,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QACrC,MAAM,CAAC,GAAG,IAA+B,CAAC;QAC1C,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,mBAAmB,CAAC;QACvE,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,8BAA8B,MAAM,EAAE,CAAC;QACnG,MAAM,UAAU,GAAG,OAAO,CAAC,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC;QAC5E,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC;IACvC,CAAC;IACD,OAAO;QACL,IAAI,EAAE,mBAAmB;QACzB,OAAO,EAAE,8BAA8B,MAAM,EAAE;QAC/C,UAAU,EAAE,MAAM;KACnB,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"error.js","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAsBA,sEAAsE;AACtE,MAAM,UAAU,eAAe,CAC7B,IAAa,EACb,MAAc,EACd,UAAyB;IAEzB,IAAI,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QACrC,MAAM,CAAC,GAAG,IAA+B,CAAC;QAC1C,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,mBAAmB,CAAC;QACvE,MAAM,OAAO,GACX,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,8BAA8B,MAAM,EAAE,CAAC;QACrF,MAAM,UAAU,GAAG,OAAO,CAAC,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC;QAC5E,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;IACnD,CAAC;IACD,OAAO;QACL,IAAI,EAAE,mBAAmB;QACzB,OAAO,EAAE,8BAA8B,MAAM,EAAE;QAC/C,UAAU,EAAE,MAAM;QAClB,UAAU;KACX,CAAC;AACJ,CAAC"}
package/dist/http.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Result } from "./error.js";
2
- import type { BatchValidation } from "./types.js";
2
+ import type { BatchValidation, InboxSendOutcome } from "./types.js";
3
3
  export interface MillionSendOptions {
4
4
  /**
5
5
  * Your MillionSend instance URL (no trailing slash needed). Falls back to
@@ -21,7 +21,7 @@ export interface MillionSendOptions {
21
21
  allowInsecureHttp?: boolean;
22
22
  }
23
23
  export interface RequestOptions {
24
- /** POST-only idempotency key (emails.send, batch.send). */
24
+ /** POST-only idempotency key (emails.send, batch.send, replies, forwards, drafts.send). */
25
25
  idempotencyKey?: string;
26
26
  }
27
27
  export interface BatchRequestOptions extends RequestOptions {
@@ -48,7 +48,14 @@ export declare class HttpClient {
48
48
  private readonly timeoutMs;
49
49
  private readonly userAgent;
50
50
  constructor(apiKey: string, options?: MillionSendOptions);
51
- request<T>({ method, path, body, query, idempotencyKey, headers: extraHeaders, }: DoRequest): Promise<Result<T>>;
51
+ request<T>(req: DoRequest): Promise<Result<T>>;
52
+ /**
53
+ * A send a person may have to approve (replies, forwards, drafts.send): an
54
+ * HTTP 202 answer is the draft the API held for approval, any other success
55
+ * the sent email.
56
+ */
57
+ requestOutcome<T>(req: DoRequest): Promise<Result<InboxSendOutcome<T>>>;
58
+ private call;
52
59
  }
53
60
  export {};
54
61
  //# sourceMappingURL=http.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,MAAM,EAAmB,MAAM,YAAY,CAAC;AAC9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAMlD,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,2DAA2D;IAC3D,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,mBAAoB,SAAQ,cAAc;IACzD;;;;OAIG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AAED,UAAU,SAAS;IACjB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC;IAChE,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC;CAC1D;AA0BD,qBAAa,UAAU;;IAErB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;gBAEvB,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB;IA2BtD,OAAO,CAAC,CAAC,EAAE,EACf,MAAM,EACN,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,cAAc,EACd,OAAO,EAAE,YAAY,GACtB,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;CA6ClC"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,MAAM,EAAmB,MAAM,YAAY,CAAC;AAC9E,OAAO,KAAK,EAAE,eAAe,EAAS,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAQ3E,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,2FAA2F;IAC3F,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,mBAAoB,SAAQ,cAAc;IACzD;;;;OAIG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AAED,UAAU,SAAS;IACjB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC;IAChE,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC;CAC1D;AA2CD,qBAAa,UAAU;;IAErB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;gBAEvB,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB;IA4BtD,OAAO,CAAC,CAAC,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAIpD;;;;OAIG;IACG,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC;YAU/D,IAAI;CAqEnB"}
package/dist/http.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { toErrorResponse } from "./error.js";
2
2
  const DEFAULT_BASE_URL = "https://api.millionsend.com";
3
3
  const DEFAULT_TIMEOUT_MS = 30_000;
4
- const SDK_VERSION = "0.6.0";
4
+ /** Time a long-poll answer may take to arrive after the server's own `wait` deadline. */
5
+ const LONG_POLL_MARGIN_MS = 10_000;
6
+ const SDK_VERSION = "0.9.0";
5
7
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
6
8
  /** True for an `http://` URL whose host is not loopback. Unparseable URLs are left to fetch. */
7
9
  function isInsecureHttpUrl(url) {
@@ -16,6 +18,21 @@ function isInsecureHttpUrl(url) {
16
18
  return false;
17
19
  return !LOOPBACK_HOSTS.has(parsed.hostname) && !parsed.hostname.startsWith("127.");
18
20
  }
21
+ /**
22
+ * Whether a path has an empty, "." or ".." segment: URL normalization
23
+ * resolves those away, so an id such as ".." would send the request to
24
+ * another resource (DELETE /inboxes/{id}/labels/.. deletes the inbox).
25
+ */
26
+ function hasDotSegment(path) {
27
+ return path
28
+ .split("/")
29
+ .slice(1)
30
+ .some((segment) => segment === "" || segment === "." || segment === "..");
31
+ }
32
+ /** `retry-after` in seconds; null when absent or not a whole number of seconds (the API sends only those). */
33
+ function retryAfterSeconds(header) {
34
+ return header !== null && /^\s*\d+\s*$/.test(header) ? Number(header) : null;
35
+ }
19
36
  function queryString(query) {
20
37
  if (!query)
21
38
  return "";
@@ -35,7 +52,9 @@ export class HttpClient {
35
52
  userAgent;
36
53
  constructor(apiKey, options = {}) {
37
54
  this.#apiKey = apiKey;
38
- this.baseUrl = (options.baseUrl ?? process.env.MILLIONSEND_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
55
+ this.baseUrl = (options.baseUrl ??
56
+ process.env.MILLIONSEND_BASE_URL ??
57
+ DEFAULT_BASE_URL).replace(/\/+$/, "");
39
58
  if (!options.allowInsecureHttp && isInsecureHttpUrl(this.baseUrl)) {
40
59
  throw new Error(`Refusing to send the API key over plain http to ${this.baseUrl}. Use https, or set allowInsecureHttp: true.`);
41
60
  }
@@ -52,7 +71,33 @@ export class HttpClient {
52
71
  const base = `millionsend-node/${SDK_VERSION}`;
53
72
  this.userAgent = options.userAgent ? `${base} ${options.userAgent}` : base;
54
73
  }
55
- async request({ method, path, body, query, idempotencyKey, headers: extraHeaders, }) {
74
+ async request(req) {
75
+ return (await this.call(req)).result;
76
+ }
77
+ /**
78
+ * A send a person may have to approve (replies, forwards, drafts.send): an
79
+ * HTTP 202 answer is the draft the API held for approval, any other success
80
+ * the sent email.
81
+ */
82
+ async requestOutcome(req) {
83
+ const { status, result } = await this.call(req);
84
+ if (result.error)
85
+ return result;
86
+ const data = status === 202
87
+ ? { kind: "draft", draft: result.data }
88
+ : { kind: "sent", email: result.data };
89
+ return { data, error: null };
90
+ }
91
+ async call({ method, path, body, query, idempotencyKey, headers: extraHeaders, }) {
92
+ if (hasDotSegment(path)) {
93
+ const error = {
94
+ name: "validation_error",
95
+ message: 'An id in the request path is empty, "." or "..".',
96
+ statusCode: null,
97
+ retryAfter: null,
98
+ };
99
+ return { status: null, result: { data: null, error } };
100
+ }
56
101
  const url = `${this.baseUrl}${path}${queryString(query)}`;
57
102
  const headers = {
58
103
  Authorization: `Bearer ${this.#apiKey}`,
@@ -63,7 +108,11 @@ export class HttpClient {
63
108
  if (value !== undefined)
64
109
  headers[key] = value;
65
110
  }
66
- const init = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
111
+ // A `wait` query asks the API to hold its answer up to that many seconds
112
+ // (long-poll), so the deadline has to outlast it.
113
+ const wait = Number(query?.wait ?? 0);
114
+ const timeoutMs = wait > 0 ? Math.max(this.timeoutMs, wait * 1000 + LONG_POLL_MARGIN_MS) : this.timeoutMs;
115
+ const init = { method, headers, signal: AbortSignal.timeout(timeoutMs) };
67
116
  if (body !== undefined && method !== "GET" && method !== "DELETE") {
68
117
  headers["Content-Type"] = "application/json";
69
118
  init.body = JSON.stringify(body);
@@ -80,8 +129,9 @@ export class HttpClient {
80
129
  name: "application_error",
81
130
  message: cause instanceof Error ? cause.message : String(cause),
82
131
  statusCode: null,
132
+ retryAfter: null,
83
133
  };
84
- return { data: null, error };
134
+ return { status: null, result: { data: null, error } };
85
135
  }
86
136
  const text = await response.text();
87
137
  let parsed = null;
@@ -93,10 +143,12 @@ export class HttpClient {
93
143
  parsed = text;
94
144
  }
95
145
  }
146
+ const status = response.status;
96
147
  if (!response.ok) {
97
- return { data: null, error: toErrorResponse(parsed, response.status) };
148
+ const retryAfter = retryAfterSeconds(response.headers.get("retry-after"));
149
+ return { status, result: { data: null, error: toErrorResponse(parsed, status, retryAfter) } };
98
150
  }
99
- return { data: parsed, error: null };
151
+ return { status, result: { data: parsed, error: null } };
100
152
  }
101
153
  }
102
154
  //# sourceMappingURL=http.js.map