millionsend 0.8.0 → 0.10.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 +37 -0
  2. package/README.md +277 -12
  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 +1 -1
  28. package/dist/resources/contacts.d.ts.map +1 -1
  29. package/dist/resources/contacts.js +5 -1
  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 +635 -5
  57. package/dist/types.d.ts.map +1 -1
  58. package/package.json +19 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  Versions follow the MillionSend API they target; the API stays wire-compatible with Resend.
4
4
 
5
+ ## 0.10.0 — 2026-10-17
6
+
7
+ - `WebhookEvent` names `email.scheduled`, `email.failed` and `email.suppressed`.
8
+ - The README covers their `data` (`failed.reason`; `suppressed.type`, `message` and `reason`), the `source_id` that `suppression.added` and `suppression.removed` carry, and the `suppressed` email kept when the suppression list alone refuses an `emails.send`.
9
+ - `WebhookEvent` names the opt-in `email.prefetched` too: it reaches only endpoints that list it.
10
+
11
+ Each feature needs this MillionSend version or later: an older instance refuses a webhook that names one of the three events with `422 validation_error`.
12
+
13
+ | MillionSend | Features |
14
+ |---|---|
15
+ | v0.15.1 | `email.scheduled`, `email.failed` and `email.suppressed`; `source_id` on `suppression.added` and `suppression.removed`; the `suppressed` email kept when the suppression list alone refuses an `emails.send` |
16
+
17
+ ## 0.9.0 — 2026-10-16
18
+
19
+ - `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`.
20
+ - `inboxes`: `create`, `list`, `get`, `update`, `remove`, plus `safety`, `codes.latest`, `quarantine`, `labels`, `threads` (with `threads.emails`) and `drafts` (with `send` and `reject`).
21
+ - Replies, forwards and `drafts.send` resolve to `{ kind: "sent", email }`, or `{ kind: "draft", draft }` when the API holds the message for approval (HTTP 202).
22
+ - A request with `wait` gets a deadline that outlasts it, so long-polls past `timeoutMs` no longer abort.
23
+ - 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.
24
+ - The long-poll's `held` items carry `reason` (`pending` or `quarantined`) and `quarantine_reason`.
25
+ - `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`.
26
+ - `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.
27
+ - Domains carry `shared`; the team's shared row, listed last by `domains.list`, adds `sending_mode` (`DomainSendingMode`) and `sending_mode_reason`.
28
+ - Every `ErrorResponse` carries `retryAfter`: the `retry-after` header in seconds, or `null`. A deep-equality check on `error` needs the new key.
29
+ - Contacts batch `errors` items are `BatchContactError`, with the `statusCode` and `name` strict mode would answer.
30
+ - 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.
31
+
32
+ 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`.
33
+
34
+ | MillionSend | Features |
35
+ |---|---|
36
+ | v0.10.0 | `inboxes`; the receiving long-poll, inbox filters, `reply` and `forward` |
37
+ | v0.11.0 | `422` for recipients that can never receive mail; a batch `idempotencyKey` replayed before validation |
38
+ | 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 |
39
+ | 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 |
40
+ | v0.14.0 | `shared`, `sending_mode` and `sending_mode_reason` on domains; `domainId` on `inboxes.create`; `subject` up to 998 characters |
41
+
5
42
  ## 0.8.0 — 2026-09-08
6
43
 
7
44
  - `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.
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,71 @@ 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
- `to` recipient is on the suppression list or opted out of the send's `topicId`.
67
+ `to` recipient is on the suppression list or opted out of the send's `topicId`. From
68
+ MillionSend v0.15.1, an `emails.send` the suppression list alone refuses still leaves a
69
+ metadata-only `suppressed` email, which fires `email.suppressed`; each retry leaves
70
+ another, even with the same `idempotencyKey` (a refused send doesn't hold its key).
71
+
72
+ Inboxes add `inbox_send_policy` (403, a send the inbox's rules or the shared domain hold),
73
+ `inbox_send_cap_reached` (429, the inbox's or the team's daily cap), `approval_required` (403),
74
+ `email_quarantined` (403), `email_blocked` (410, a download of an email that carries malware),
75
+ `too_many_waits` (429, too many concurrent long-polls) and `name_review_unavailable` (503, a
76
+ name on the shared domain can't be reviewed yet).
67
77
 
68
78
  ```ts
69
79
  const { data, error } = await ms.emails.get(id);
70
80
  if (error?.name === "not_found") { /* … */ }
71
81
  ```
72
82
 
83
+ ### Retries
84
+
85
+ The SDK never retries on its own. Worth retrying after `retryAfter` seconds:
86
+ `503 name_review_unavailable`, `429 too_many_waits`, and `429 rate_limit_exceeded` when
87
+ `retryAfter` is 60 or less (the per-minute limits). Not worth retrying in the same process:
88
+ `429 inbox_send_cap_reached` and the daily name-review budget (`429 rate_limit_exceeded`
89
+ with a `retryAfter` that runs to the next UTC midnight), and any 403, 404, 409 or 422. An
90
+ unknown route answers `404 not_found` (`application_error` from servers before v0.13.0).
91
+
92
+ Resend a send with the same `idempotencyKey`: a batch the API already accepted gets its
93
+ stored response back. A held draft (`kind: "draft"`) is not a failure; never resend it.
94
+ `inboxes.create` takes no idempotency key: after a 429 or 503 nothing was created, but
95
+ after a timeout a resend can create a second inbox. With a chosen `username`, a 409 on the
96
+ resend usually means the first attempt succeeded: read it with `inboxes.get(address)`.
97
+
73
98
  ## Resources
74
99
 
75
100
  Inputs are camelCase and mapped to the wire's snake_case (`replyTo` → `reply_to`,
76
101
  `scheduledAt` → `scheduled_at`, `topicId` → `topic_id`, …). Every field you pass
77
102
  is sent; nothing is dropped client-side. Responses are the wire shape verbatim.
78
103
  List endpoints take `{ limit, after, before }` (keyset cursors) and return
79
- `{ object: "list", data, has_more }`.
104
+ `{ object: "list", data, has_more }`. Lists run newest first (MillionSend v0.13.0 and
105
+ later): `after` takes the last id of a page and pages toward older items, `before` takes
106
+ the first id and pages toward newer ones, and `has_more` refers to that direction. Threads
107
+ sort by last activity, drafts by last update, a thread's emails run oldest first, and labels
108
+ and topics are not paginated.
109
+
110
+ ```ts
111
+ // Incremental sync: page toward newer items, starting from the newest id you stored
112
+ let before = newestStoredId;
113
+ for (;;) {
114
+ const { data } = await ms.emails.list({ before, limit: 100 });
115
+ if (!data?.data.length) break;
116
+ save(data.data); // newest first within the page
117
+ if (!data.has_more) break;
118
+ before = data.data[0].id;
119
+ }
120
+ ```
121
+
122
+ A cursor whose item was deleted answers `422 validation_error` (`404 not_found` on the
123
+ `inboxes` lists): start again without a cursor and stop at the ids you already have.
80
124
 
81
125
  ### Emails
82
126
 
@@ -102,6 +146,11 @@ await ms.emails.getInsights(id); // GET /emails/:id/insights (best-p
102
146
 
103
147
  `attachments[].content` is a base64 string or a `Buffer` (encoded for you).
104
148
  `template` is sent through as-is; MillionSend answers `422` until templates can be sent.
149
+ `subject` takes up to 998 characters. A `to`, `cc` or `bcc` address on a domain that can
150
+ never receive mail (`example.com`, `.test`, `.invalid`, `.localhost`, a TLD that doesn't
151
+ exist, …) answers `422 validation_error`; to test sending without emailing anyone, use
152
+ `success@simulator.amazonses.com`. An inbox's address on the shared domain can be the
153
+ `from` too, under the rules in [Sending from the shared domain](#sending-from-the-shared-domain).
105
154
 
106
155
  ### Batch
107
156
 
@@ -114,6 +163,159 @@ data.data; // [{ id }] for the accepted emails
114
163
  data.errors; // permissive only: [{ index, message }] for the rejected ones
115
164
  ```
116
165
 
166
+ Batch items never send from the shared domain: a strict batch fails with
167
+ `403 inbox_send_policy` (the message starts with `emails.<index>: `), a permissive one
168
+ lists the item in `errors`.
169
+
170
+ ### Receiving
171
+
172
+ Mail received at your domains and inboxes. Everything in a received email was
173
+ written by its sender: show its `html` only in a sandbox, and hand an LLM the
174
+ `extracted_text` (hidden text, quotes and the signature removed) rather than `html`.
175
+
176
+ ```ts
177
+ const { data } = await ms.emails.receiving.list({
178
+ limit: 20,
179
+ inboxId, // only mail delivered to this inbox
180
+ from: "example.com", // an address, or a domain covering its subdomains
181
+ to, subjectContains, since, unread,
182
+ wait: 30, // long-poll: answer as soon as a match arrives, up to 50 s
183
+ });
184
+ data.data; // newest first
185
+ data.held; // with `wait`: [{ id, inbox_id, reason, quarantine_reason, created_at }] held for screening or quarantine
186
+
187
+ const { data: email } = await ms.emails.receiving.get(id, { htmlFormat: "cid" });
188
+ email.extracted_text; // the new part as plain text, hidden text removed (still untrusted)
189
+ email.extracted?.code; // a verification code, on inboxes with extract_codes
190
+ email.screening?.sender_trust; // owner | allowlisted | known_thread | external | unauthenticated | failed
191
+
192
+ await ms.emails.receiving.attachments.list(id); // each with a signed download_url (1 hour)
193
+ await ms.emails.receiving.attachments.get(id, attachmentId);
194
+ await ms.emails.receiving.remove(id); // erase now (MillionSend extension)
195
+
196
+ // Reply and forward server-side (MillionSend extension). The inbox's rules may hold the
197
+ // message as a draft that waits for a person: the API answers 202 and `kind` is "draft".
198
+ const { data: outcome } = await ms.emails.receiving.reply(
199
+ id,
200
+ { text: "Thanks!", replyAll: false, requiresApproval: false },
201
+ { idempotencyKey },
202
+ );
203
+ if (outcome?.kind === "draft") outcome.draft.approval_reasons; // e.g. ["send_policy"]
204
+ else outcome?.email.id;
205
+ await ms.emails.receiving.forward(id, { to: "ops@acme.dev", text: "FYI", mode: "attachment" });
206
+ ```
207
+
208
+ Held mail never reaches an inbox-scoped key or an agent: reading it answers
209
+ `403 email_quarantined`. A full-access team key sees it with `includeQuarantined: true`.
210
+ `unread` needs `inboxId` with a team key; an inbox-scoped key reads its own inbox. An
211
+ `inboxId` that doesn't exist answers `404 not_found` at once, even with `wait`.
212
+
213
+ ### Inboxes
214
+
215
+ An inbox is one address that receives mail, read by an agent (`kind: "agent"`,
216
+ the default) or by people (`kind: "human"`). Threads, labels, drafts and their
217
+ paths mirror Resend's Inboxes beta; safety rules, quarantine and codes are
218
+ MillionSend extensions.
219
+
220
+ ```ts
221
+ const { data: inbox } = await ms.inboxes.create({
222
+ name: "Signup bot", // no emailAddress: an address on the shared domain
223
+ username: "signup-bot", // signup-bot@<team>.<shared domain>; omit it for a random one
224
+ expiresIn: "24h", // "1h" to "365d"; omitted or null, it never expires
225
+ safety: { acceptFrom: "anyone" },
226
+ });
227
+ await ms.inboxes.create({ emailAddress: "support@acme.dev", name: "Support", kind: "human" });
228
+ await ms.inboxes.create({ domainId, username: "orders" }); // a domains.list id: orders@ that domain
229
+ await ms.inboxes.list({ kind: "agent" });
230
+ await ms.inboxes.get("support@acme.dev"); // by id or address
231
+ await ms.inboxes.update(inbox.id, { fromName: "Acme", expiresIn: "7d" });
232
+ await ms.inboxes.remove(inbox.id); // also revokes its inbox-scoped keys
233
+
234
+ // Codes and sign-in links, without parsing HTML (wait long-polls up to 50 s)
235
+ const { data: code } = await ms.inboxes.codes.latest(inbox.id, { from: "github.com", wait: 30 });
236
+ code.status; // "found" | "none" | "ambiguous" | "timeout"; code.code, code.url
237
+ // The next sign-in: only a code received after the one already used
238
+ await ms.inboxes.codes.latest(inbox.id, { since: code.received_at, wait: 30 });
239
+
240
+ // Safety rules: who can write in, what the inbox may send
241
+ await ms.inboxes.safety.get(inbox.id);
242
+ await ms.inboxes.safety.update(inbox.id, { acceptFrom: "allowlist", allowlist: ["*@acme.dev"], sendCap: 50 });
243
+
244
+ // Quarantine: held mail, kept until a person releases it
245
+ await ms.inboxes.quarantine.list(inbox.id);
246
+ await ms.inboxes.quarantine.release(inbox.id, emailId, { trustSender: true });
247
+
248
+ // Threads and their messages
249
+ await ms.inboxes.threads.list(inbox.id, { folders: ["inbox", "archive"], read: false });
250
+ await ms.inboxes.threads.list(inbox.id, { includeQuarantined: true }); // owner views: a `quarantined` count per thread
251
+ await ms.inboxes.threads.search(inbox.id, { query: "invoice", from: ["example.com"] });
252
+ await ms.inboxes.threads.get(inbox.id, threadId);
253
+ await ms.inboxes.threads.update(inbox.id, threadId, { read: true, folder: "archive", labelId });
254
+ await ms.inboxes.threads.remove(inbox.id, threadId); // to trash, purged after 30 days
255
+ await ms.inboxes.threads.emails.list(inbox.id, threadId); // oldest first; includeQuarantined for owner views
256
+ await ms.inboxes.threads.emails.get(inbox.id, threadId, emailId);
257
+ await ms.inboxes.threads.emails.reply(inbox.id, threadId, emailId, { text: "Done." }, { idempotencyKey });
258
+ await ms.inboxes.threads.emails.forward(inbox.id, threadId, emailId, { to: "ops@acme.dev" });
259
+
260
+ // Labels (up to 100 per inbox)
261
+ await ms.inboxes.labels.create(inbox.id, { name: "Billing", color: "#FF8800" });
262
+ await ms.inboxes.labels.list(inbox.id);
263
+ await ms.inboxes.labels.update(inbox.id, labelId, { name: "Invoices" });
264
+ await ms.inboxes.labels.remove(inbox.id, labelId);
265
+
266
+ // Drafts, and the ones that wait for approval
267
+ await ms.inboxes.drafts.create(inbox.id, { to: "ada@acme.dev", subject: "Hi", text: "…" });
268
+ await ms.inboxes.drafts.list(inbox.id);
269
+ await ms.inboxes.drafts.get(inbox.id, draftId);
270
+ await ms.inboxes.drafts.update(inbox.id, draftId, { cc: null }); // null clears
271
+ await ms.inboxes.drafts.send(inbox.id, draftId, { idempotencyKey }); // { kind: "sent" | "draft" }
272
+ await ms.inboxes.drafts.reject(inbox.id, draftId);
273
+ await ms.inboxes.drafts.remove(inbox.id, draftId);
274
+ ```
275
+
276
+ Replies, forwards and `drafts.send` resolve to `{ kind: "sent", email }` or, when
277
+ the inbox's rules hold the message for a person (HTTP 202), `{ kind: "draft", draft }`.
278
+ An agent's reply to a Reply-To at another organization is held that way too, with
279
+ `"reply_to_redirect"` among its `approval_reasons`.
280
+ A key restricted to the inbox cannot send a draft that waits for approval
281
+ (`403 approval_required`); a person in the dashboard or a full-access team key approves it.
282
+ Hosted agent settings (`/inboxes/:id/agent`) are not exposed: MillionSend runs no agent of its own.
283
+
284
+ An inbox never expires unless `expiresIn` says so, and its `name` takes up to 320
285
+ characters. `domainId` takes a `domains.list` row instead of `emailAddress`: the
286
+ `shared: true` row works like leaving both out, and one of your own domains needs a
287
+ `username`.
288
+
289
+ On the shared domain, an inbox unused for 90 days (no mail in or out, no API call on it)
290
+ is deleted, which fires `inbox.expired`, then `inbox.deleted`. A chosen `username` or
291
+ `fromName` there is reviewed first: reserved names (`postmaster`, `admin`, `support`,
292
+ `noreply`, …), a `fwd-` prefix and look-alikes of brands answer `422`, a username in use
293
+ `409`, and `429` means today's review budget is spent. `503 name_review_unavailable`
294
+ means try again after `retryAfter` seconds: the review is down, or the team's new
295
+ subdomain still waits for its own, and then every shared or forwarding create gets it.
296
+ Safety rules differ there too: `sendPolicy: "allowlist"` answers `422`, and so does
297
+ `"open"` unless the team's `sending_mode` is `open`; `ownerAddresses` don't make senders
298
+ trusted; `forwardTo` takes team members only.
299
+
300
+ #### Sending from the shared domain
301
+
302
+ What the team's shared addresses may send is the `sending_mode` of the `shared: true` row
303
+ of `domains.list`: `open` starts conversations, `reply_only` only answers inside a thread
304
+ to its own correspondents, and `receive_only` sends nothing. The same answers come back
305
+ from `emails.send`, replies, forwards and drafts:
306
+
307
+ - `403 inbox_send_policy`: the mode doesn't allow this send (or the inbox's name looks
308
+ like a brand, which only replies even under `open`);
309
+ - `429 inbox_send_cap_reached`: the team's daily cap for the shared domain, with a
310
+ `retryAfter` that runs to the next UTC midnight;
311
+ - `422 validation_error`: more than 10 distinct recipients across `to`, `cc` and `bcc`.
312
+
313
+ `emails.send` takes a shared address in `open` mode only: the inbox's exact
314
+ `email_address` (no +tag), a display name left out or equal to its `from_name`, and no
315
+ `replyTo`, `attachments`, `headers` or `scheduledAt`. A key restricted to a domain gets
316
+ `403 restricted_api_key`. Batch items and broadcasts never send from the shared domain.
317
+ Switch on `name` and `statusCode`; the messages may change.
318
+
117
319
  ### Contacts
118
320
 
119
321
  Contacts are team-global — one list per team, no audiences.
@@ -149,7 +351,7 @@ const { data } = await ms.contacts.batch.create(items, {
149
351
  });
150
352
  data.data; // [{ index, id, status: "created" | "updated" | "skipped" }]
151
353
  data.counts; // { created, updated, skipped, failed }
152
- data.errors; // permissive only
354
+ data.errors; // permissive only: [{ index, statusCode, name, message }], as strict mode would answer
153
355
 
154
356
  // Bulk lookup (MillionSend extension): up to 1000 contacts by id or email in one request,
155
357
  // in request order; unknown entries are listed, not errors — one request against the rate limit
@@ -213,6 +415,9 @@ await ms.broadcasts.cancel(id); // scheduled only
213
415
  await ms.broadcasts.remove(id); // draft only
214
416
  ```
215
417
 
418
+ `subject` takes up to 998 characters. A broadcast never sends from the shared domain:
419
+ `create({ send: true })` and `send` answer `403 inbox_send_policy`.
420
+
216
421
  ### Suppressions
217
422
 
218
423
  ```ts
@@ -236,9 +441,27 @@ await ms.domains.list();
236
441
  await ms.domains.get(id);
237
442
  await ms.domains.verify(id);
238
443
  await ms.domains.update({ id, clickTracking: false, trackingSubdomain: null }); // null clears
444
+ await ms.domains.update({ id, receivingCatchAll: true }); // accept mail for addresses without an inbox
239
445
  await ms.domains.remove(id);
240
446
  ```
241
447
 
448
+ On an instance with a shared inbox domain, the team's subdomain of it is the last row of
449
+ `domains.list` (`shared: true`; MillionSend v0.14.0 and later). It is verified, has no DNS
450
+ records, and its id is the `domainId` of the team's shared inboxes. Its `sending_mode`
451
+ (`open`, `reply_only` or `receive_only`) says what those inboxes may send, and
452
+ `sending_mode_reason` (`plan` or `instance`, `null` under `open`) why it is held below
453
+ `open`. It is read-only: `update`, `verify` and `remove` answer `403 forbidden`, and an API
454
+ key can't be restricted to it. It comes after every own domain whatever its `created_at`,
455
+ alone on the next page when they exactly fill one. A team whose new subdomain still waits
456
+ for its name review has no such row yet. Older servers send no `shared`: read it as `false`.
457
+
458
+ It sends only from its inboxes' own addresses, so leave it out when picking a domain to send from:
459
+
460
+ ```ts
461
+ const { data } = await ms.domains.list({ limit: 100 });
462
+ const sender = data?.data.find((d) => d.status === "verified" && !d.shared);
463
+ ```
464
+
242
465
  ### Webhooks
243
466
 
244
467
  ```ts
@@ -259,19 +482,54 @@ const { data } = await ms.webhooks.rotate(id, { overlapHours: 24 }); // or { sig
259
482
  data.signing_secret; data.previous_secret_expires_at; // null when the old secret is dropped at once
260
483
  ```
261
484
 
262
- Subscribable events include `email.*`, `deliverability.*`, `quota.*`, `contact.*`
263
- (`created`, `updated`, `deleted`, `unsubscribed`, `resubscribed`, `topic_opt_in`,
264
- `topic_opt_out`) and `suppression.added` / `suppression.removed`.
485
+ Subscribable events include `email.*` (with `email.scheduled`, `email.failed`,
486
+ `email.suppressed`, `email.prefetched` and `email.received`), `deliverability.*`,
487
+ `quota.*`, `contact.*` (`created`, `updated`, `deleted`, `unsubscribed`,
488
+ `resubscribed`, `topic_opt_in`, `topic_opt_out`), `suppression.added` /
489
+ `suppression.removed`, and the `inbox.*` events (`inbox.created`, `inbox.updated`,
490
+ `inbox.deleted`, `inbox.expired`, `inbox.thread.created`, `inbox.thread.folder.updated`,
491
+ `inbox.thread.labels.updated`, `inbox.email.received`, `inbox.email.sent`,
492
+ `inbox.email.quarantined`, `inbox.draft.created`, `inbox.draft.updated`,
493
+ `inbox.draft.sent`, `inbox.draft.deleted`). `email.prefetched` and inbox events reach
494
+ only endpoints that list them by name. `inboxIds` limits an endpoint's inbox events
495
+ and `email.received` to those inboxes (`null` = every inbox). Inbox events from sends
496
+ made in the dashboard carry `data.source: "dashboard"`.
497
+
498
+ `email.scheduled`, `email.failed` and `email.suppressed` need MillionSend v0.15.1 or later:
499
+ an older server answers `422 validation_error` to a webhook that names one. Unlike inbox
500
+ events, they also reach endpoints subscribed to every event.
501
+
502
+ - `email.scheduled`: accepted with a future `scheduledAt`; no extra `data`. Rescheduling
503
+ with `emails.update` fires nothing.
504
+ - `email.failed`: it won't be sent, or SES refused it after `email.sent`.
505
+ `data.failed.reason` is `domain_not_verified`, `domain_deleted`, `body_missing`,
506
+ `body_unreadable`, `message_rejected`, `retries_exhausted`, `send_interrupted` or
507
+ `provider_error` (any other error).
508
+ - `email.suppressed`: it won't be sent. `data.suppressed` is `{ type, message, reason }`:
509
+ `type` is `OnAccountSuppressionList`, `Unsubscribed` or `Ineligible`, and `reason` is
510
+ `recipient_suppressed`, `contact_unsubscribed`, `topic_unsubscribed`, `contact_missing`,
511
+ `contact_recipient_mismatch`, `broadcast_missing`, `topic_missing` or `ineligible`. An
512
+ `emails.send` the suppression list alone refuses with `422 all_recipients_suppressed`
513
+ fires it too.
514
+
515
+ New `reason` and `type` values can appear. From v0.15.1, `suppression.added` and
516
+ `suppression.removed` carry `data.source_id`: the email whose bounce or complaint created
517
+ the entry, or `null`.
265
518
 
266
519
  ### API keys
267
520
 
268
521
  ```ts
269
522
  const { data } = await ms.apiKeys.create({ name: "ci", permission: "sending_access", domainId });
270
523
  data.token; // shown once
524
+ await ms.apiKeys.create({ name: "signup-agent", permission: "full_access", inboxId }); // one inbox only
271
525
  await ms.apiKeys.list();
272
526
  await ms.apiKeys.remove(id);
273
527
  ```
274
528
 
529
+ An inbox-scoped key reads only its inbox and sends only from its address; any other
530
+ route answers `403 restricted_api_key`. `domainId` takes one of your own domains: the
531
+ `shared: true` row of `domains.list` answers `422 validation_error`.
532
+
275
533
  ### Templates
276
534
 
277
535
  `id` arguments accept the template id or its alias.
@@ -340,8 +598,15 @@ match `resend`. Notes:
340
598
  answered with `422`. `templates.publish` is a no-op (every save is live).
341
599
  - **MillionSend extensions** (no Resend counterpart): `segments`,
342
600
  `deliverability`, `usage`, `emails.getInsights`, `contacts.batch`,
343
- `contacts.preferencesLink`, `webhooks.rotate`, and the `origin` field on
344
- suppressions.
601
+ `contacts.preferencesLink`, `webhooks.rotate`, the `origin` field on
602
+ suppressions, `emails.receiving.reply` / `remove`, `inboxes.safety`,
603
+ `inboxes.codes`, `inboxes.quarantine`, `inboxes.drafts.reject`, the `username` and
604
+ `domainId` of `inboxes.create`, and `error.retryAfter`.
605
+ - **`emails.receiving.forward(id, payload)`** forwards server-side, from your
606
+ inbox or domain, instead of Resend's client-side re-send of the raw message.
607
+ - **Inboxes** follow Resend's preview shapes with ids as positional arguments
608
+ (`ms.inboxes.threads.emails.reply(inboxId, threadId, emailId, payload)`), and
609
+ return the full object on create.
345
610
 
346
611
  ## License
347
612
 
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.8.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.10.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