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.
- package/CHANGELOG.md +29 -0
- package/README.md +257 -14
- 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 +7 -3
- package/dist/resources/contacts.d.ts.map +1 -1
- package/dist/resources/contacts.js +17 -5
- 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 +645 -7
- package/dist/types.d.ts.map +1 -1
- 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: "
|
|
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
|
-
|
|
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
|
|
260
|
-
(`created`, `updated`, `deleted`, `unsubscribed`,
|
|
261
|
-
`topic_opt_out`)
|
|
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`,
|
|
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
|
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.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 ??
|
|
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
|