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.
- package/CHANGELOG.md +37 -0
- package/README.md +277 -12
- package/dist/error.d.ts +6 -1
- package/dist/error.d.ts.map +1 -1
- package/dist/error.js +3 -2
- package/dist/error.js.map +1 -1
- package/dist/http.d.ts +10 -3
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +59 -7
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/query.d.ts +4 -0
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +8 -0
- package/dist/query.js.map +1 -1
- package/dist/resources/api-keys.d.ts +1 -0
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +2 -0
- package/dist/resources/api-keys.js.map +1 -1
- package/dist/resources/broadcasts.d.ts +4 -1
- package/dist/resources/broadcasts.d.ts.map +1 -1
- package/dist/resources/broadcasts.js +4 -1
- package/dist/resources/broadcasts.js.map +1 -1
- package/dist/resources/contacts.d.ts +1 -1
- package/dist/resources/contacts.d.ts.map +1 -1
- package/dist/resources/contacts.js +5 -1
- package/dist/resources/contacts.js.map +1 -1
- package/dist/resources/domains.d.ts +9 -2
- package/dist/resources/domains.d.ts.map +1 -1
- package/dist/resources/domains.js +10 -2
- package/dist/resources/domains.js.map +1 -1
- package/dist/resources/emails.d.ts +12 -2
- package/dist/resources/emails.d.ts.map +1 -1
- package/dist/resources/emails.js +13 -2
- package/dist/resources/emails.js.map +1 -1
- package/dist/resources/inboxes.d.ts +115 -0
- package/dist/resources/inboxes.d.ts.map +1 -0
- package/dist/resources/inboxes.js +349 -0
- package/dist/resources/inboxes.js.map +1 -0
- package/dist/resources/receiving.d.ts +57 -0
- package/dist/resources/receiving.d.ts.map +1 -0
- package/dist/resources/receiving.js +115 -0
- package/dist/resources/receiving.js.map +1 -0
- package/dist/resources/templates.d.ts.map +1 -1
- package/dist/resources/templates.js +4 -1
- package/dist/resources/templates.js.map +1 -1
- package/dist/resources/topics.d.ts.map +1 -1
- package/dist/resources/topics.js +5 -1
- package/dist/resources/topics.js.map +1 -1
- package/dist/resources/webhooks.d.ts.map +1 -1
- package/dist/resources/webhooks.js +7 -1
- package/dist/resources/webhooks.js.map +1 -1
- package/dist/types.d.ts +635 -5
- package/dist/types.d.ts.map +1 -1
- 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: "
|
|
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
|
|
263
|
-
|
|
264
|
-
`
|
|
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`,
|
|
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
|
package/dist/error.d.ts.map
CHANGED
|
@@ -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,
|
|
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":"
|
|
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>(
|
|
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
|
package/dist/http.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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 ??
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|