diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcc838..b05929e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ Versions follow the MillionSend API they target; the API stays wire-compatible with Resend. +## 0.9.0 — unreleased + +- `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`. +- `inboxes`: `create`, `list`, `get`, `update`, `remove`, plus `safety`, `codes.latest`, `quarantine`, `labels`, `threads` (with `threads.emails`) and `drafts` (with `send` and `reject`). +- Replies, forwards and `drafts.send` resolve to `{ kind: "sent", email }`, or `{ kind: "draft", draft }` when the API holds the message for approval (HTTP 202). +- A request with `wait` gets a deadline that outlasts it, so long-polls past `timeoutMs` no longer abort. +- 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. +- The long-poll's `held` items carry `reason` (`pending` or `quarantined`) and `quarantine_reason`. +- `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`. +- `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. +- Domains carry `shared`; the team's shared row, listed last by `domains.list`, adds `sending_mode` (`DomainSendingMode`) and `sending_mode_reason`. +- Every `ErrorResponse` carries `retryAfter`: the `retry-after` header in seconds, or `null`. A deep-equality check on `error` needs the new key. +- Contacts batch `errors` items are `BatchContactError`, with the `statusCode` and `name` strict mode would answer. +- 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. + +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`. + +| MillionSend | Features | +|---|---| +| v0.10.0 | `inboxes`; the receiving long-poll, inbox filters, `reply` and `forward` | +| v0.11.0 | `422` for recipients that can never receive mail; a batch `idempotencyKey` replayed before validation | +| 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 | +| 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 | +| v0.14.0 | `shared`, `sending_mode` and `sending_mode_reason` on domains; `domainId` on `inboxes.create`; `subject` up to 998 characters | + ## 0.8.0 — 2026-09-08 - `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. diff --git a/README.md b/README.md index 6e185f2..baef1a6 100644 --- a/README.md +++ b/README.md @@ -56,27 +56,68 @@ new MillionSend(apiKey?, { ## Errors No method throws for an API error — every call resolves to `{ data, error }`. -`error` is `null` on success; otherwise `{ name, message, statusCode }` where +`error` is `null` on success; otherwise `{ name, message, statusCode, retryAfter }` where `name` is a stable snake_case code you can switch on (`validation_error`, -`not_found`, `restricted_api_key`, `sending_paused`, +`not_found`, `forbidden`, `restricted_api_key`, `rate_limit_exceeded`, `sending_paused`, `all_recipients_suppressed`, …). Client-side and transport failures carry -`statusCode: null`. +`statusCode: null`. `retryAfter` is the response's `retry-after` header in seconds, or +`null` when it has none. `emails.send` and `batch.send` answer `422 all_recipients_suppressed` when every `to` recipient is on the suppression list or opted out of the send's `topicId`. +Inboxes add `inbox_send_policy` (403, a send the inbox's rules or the shared domain hold), +`inbox_send_cap_reached` (429, the inbox's or the team's daily cap), `approval_required` (403), +`email_quarantined` (403), `email_blocked` (410, a download of an email that carries malware), +`too_many_waits` (429, too many concurrent long-polls) and `name_review_unavailable` (503, a +name on the shared domain can't be reviewed yet). + ```ts const { data, error } = await ms.emails.get(id); if (error?.name === "not_found") { /* … */ } ``` +### Retries + +The SDK never retries on its own. Worth retrying after `retryAfter` seconds: +`503 name_review_unavailable`, `429 too_many_waits`, and `429 rate_limit_exceeded` when +`retryAfter` is 60 or less (the per-minute limits). Not worth retrying in the same process: +`429 inbox_send_cap_reached` and the daily name-review budget (`429 rate_limit_exceeded` +with a `retryAfter` that runs to the next UTC midnight), and any 403, 404, 409 or 422. An +unknown route answers `404 not_found` (`application_error` from servers before v0.13.0). + +Resend a send with the same `idempotencyKey`: a batch the API already accepted gets its +stored response back. A held draft (`kind: "draft"`) is not a failure; never resend it. +`inboxes.create` takes no idempotency key: after a 429 or 503 nothing was created, but +after a timeout a resend can create a second inbox. With a chosen `username`, a 409 on the +resend usually means the first attempt succeeded: read it with `inboxes.get(address)`. + ## Resources Inputs are camelCase and mapped to the wire's snake_case (`replyTo` → `reply_to`, `scheduledAt` → `scheduled_at`, `topicId` → `topic_id`, …). Every field you pass is sent; nothing is dropped client-side. Responses are the wire shape verbatim. List endpoints take `{ limit, after, before }` (keyset cursors) and return -`{ object: "list", data, has_more }`. +`{ object: "list", data, has_more }`. Lists run newest first (MillionSend v0.13.0 and +later): `after` takes the last id of a page and pages toward older items, `before` takes +the first id and pages toward newer ones, and `has_more` refers to that direction. Threads +sort by last activity, drafts by last update, a thread's emails run oldest first, and labels +and topics are not paginated. + +```ts +// Incremental sync: page toward newer items, starting from the newest id you stored +let before = newestStoredId; +for (;;) { + const { data } = await ms.emails.list({ before, limit: 100 }); + if (!data?.data.length) break; + save(data.data); // newest first within the page + if (!data.has_more) break; + before = data.data[0].id; +} +``` + +A cursor whose item was deleted answers `422 validation_error` (`404 not_found` on the +`inboxes` lists): start again without a cursor and stop at the ids you already have. ### Emails @@ -102,6 +143,11 @@ await ms.emails.getInsights(id); // GET /emails/:id/insights (best-p `attachments[].content` is a base64 string or a `Buffer` (encoded for you). `template` is sent through as-is; MillionSend answers `422` until templates can be sent. +`subject` takes up to 998 characters. A `to`, `cc` or `bcc` address on a domain that can +never receive mail (`example.com`, `.test`, `.invalid`, `.localhost`, a TLD that doesn't +exist, …) answers `422 validation_error`; to test sending without emailing anyone, use +`success@simulator.amazonses.com`. An inbox's address on the shared domain can be the +`from` too, under the rules in [Sending from the shared domain](#sending-from-the-shared-domain). ### Batch @@ -114,6 +160,159 @@ data.data; // [{ id }] for the accepted emails data.errors; // permissive only: [{ index, message }] for the rejected ones ``` +Batch items never send from the shared domain: a strict batch fails with +`403 inbox_send_policy` (the message starts with `emails.: `), a permissive one +lists the item in `errors`. + +### Receiving + +Mail received at your domains and inboxes. Everything in a received email was +written by its sender: show its `html` only in a sandbox, and hand an LLM the +`extracted_text` (hidden text, quotes and the signature removed) rather than `html`. + +```ts +const { data } = await ms.emails.receiving.list({ + limit: 20, + inboxId, // only mail delivered to this inbox + from: "example.com", // an address, or a domain covering its subdomains + to, subjectContains, since, unread, + wait: 30, // long-poll: answer as soon as a match arrives, up to 50 s +}); +data.data; // newest first +data.held; // with `wait`: [{ id, inbox_id, reason, quarantine_reason, created_at }] held for screening or quarantine + +const { data: email } = await ms.emails.receiving.get(id, { htmlFormat: "cid" }); +email.extracted_text; // the new part as plain text, hidden text removed (still untrusted) +email.extracted?.code; // a verification code, on inboxes with extract_codes +email.screening?.sender_trust; // owner | allowlisted | known_thread | external | unauthenticated | failed + +await ms.emails.receiving.attachments.list(id); // each with a signed download_url (1 hour) +await ms.emails.receiving.attachments.get(id, attachmentId); +await ms.emails.receiving.remove(id); // erase now (MillionSend extension) + +// Reply and forward server-side (MillionSend extension). The inbox's rules may hold the +// message as a draft that waits for a person: the API answers 202 and `kind` is "draft". +const { data: outcome } = await ms.emails.receiving.reply( + id, + { text: "Thanks!", replyAll: false, requiresApproval: false }, + { idempotencyKey }, +); +if (outcome?.kind === "draft") outcome.draft.approval_reasons; // e.g. ["send_policy"] +else outcome?.email.id; +await ms.emails.receiving.forward(id, { to: "ops@acme.dev", text: "FYI", mode: "attachment" }); +``` + +Held mail never reaches an inbox-scoped key or an agent: reading it answers +`403 email_quarantined`. A full-access team key sees it with `includeQuarantined: true`. +`unread` needs `inboxId` with a team key; an inbox-scoped key reads its own inbox. An +`inboxId` that doesn't exist answers `404 not_found` at once, even with `wait`. + +### Inboxes + +An inbox is one address that receives mail, read by an agent (`kind: "agent"`, +the default) or by people (`kind: "human"`). Threads, labels, drafts and their +paths mirror Resend's Inboxes beta; safety rules, quarantine and codes are +MillionSend extensions. + +```ts +const { data: inbox } = await ms.inboxes.create({ + name: "Signup bot", // no emailAddress: an address on the shared domain + username: "signup-bot", // signup-bot@.; omit it for a random one + expiresIn: "24h", // "1h" to "365d"; omitted or null, it never expires + safety: { acceptFrom: "anyone" }, +}); +await ms.inboxes.create({ emailAddress: "support@acme.dev", name: "Support", kind: "human" }); +await ms.inboxes.create({ domainId, username: "orders" }); // a domains.list id: orders@ that domain +await ms.inboxes.list({ kind: "agent" }); +await ms.inboxes.get("support@acme.dev"); // by id or address +await ms.inboxes.update(inbox.id, { fromName: "Acme", expiresIn: "7d" }); +await ms.inboxes.remove(inbox.id); // also revokes its inbox-scoped keys + +// Codes and sign-in links, without parsing HTML (wait long-polls up to 50 s) +const { data: code } = await ms.inboxes.codes.latest(inbox.id, { from: "github.com", wait: 30 }); +code.status; // "found" | "none" | "ambiguous" | "timeout"; code.code, code.url +// The next sign-in: only a code received after the one already used +await ms.inboxes.codes.latest(inbox.id, { since: code.received_at, wait: 30 }); + +// Safety rules: who can write in, what the inbox may send +await ms.inboxes.safety.get(inbox.id); +await ms.inboxes.safety.update(inbox.id, { acceptFrom: "allowlist", allowlist: ["*@acme.dev"], sendCap: 50 }); + +// Quarantine: held mail, kept until a person releases it +await ms.inboxes.quarantine.list(inbox.id); +await ms.inboxes.quarantine.release(inbox.id, emailId, { trustSender: true }); + +// Threads and their messages +await ms.inboxes.threads.list(inbox.id, { folders: ["inbox", "archive"], read: false }); +await ms.inboxes.threads.list(inbox.id, { includeQuarantined: true }); // owner views: a `quarantined` count per thread +await ms.inboxes.threads.search(inbox.id, { query: "invoice", from: ["example.com"] }); +await ms.inboxes.threads.get(inbox.id, threadId); +await ms.inboxes.threads.update(inbox.id, threadId, { read: true, folder: "archive", labelId }); +await ms.inboxes.threads.remove(inbox.id, threadId); // to trash, purged after 30 days +await ms.inboxes.threads.emails.list(inbox.id, threadId); // oldest first; includeQuarantined for owner views +await ms.inboxes.threads.emails.get(inbox.id, threadId, emailId); +await ms.inboxes.threads.emails.reply(inbox.id, threadId, emailId, { text: "Done." }, { idempotencyKey }); +await ms.inboxes.threads.emails.forward(inbox.id, threadId, emailId, { to: "ops@acme.dev" }); + +// Labels (up to 100 per inbox) +await ms.inboxes.labels.create(inbox.id, { name: "Billing", color: "#FF8800" }); +await ms.inboxes.labels.list(inbox.id); +await ms.inboxes.labels.update(inbox.id, labelId, { name: "Invoices" }); +await ms.inboxes.labels.remove(inbox.id, labelId); + +// Drafts, and the ones that wait for approval +await ms.inboxes.drafts.create(inbox.id, { to: "ada@acme.dev", subject: "Hi", text: "…" }); +await ms.inboxes.drafts.list(inbox.id); +await ms.inboxes.drafts.get(inbox.id, draftId); +await ms.inboxes.drafts.update(inbox.id, draftId, { cc: null }); // null clears +await ms.inboxes.drafts.send(inbox.id, draftId, { idempotencyKey }); // { kind: "sent" | "draft" } +await ms.inboxes.drafts.reject(inbox.id, draftId); +await ms.inboxes.drafts.remove(inbox.id, draftId); +``` + +Replies, forwards and `drafts.send` resolve to `{ kind: "sent", email }` or, when +the inbox's rules hold the message for a person (HTTP 202), `{ kind: "draft", draft }`. +An agent's reply to a Reply-To at another organization is held that way too, with +`"reply_to_redirect"` among its `approval_reasons`. +A key restricted to the inbox cannot send a draft that waits for approval +(`403 approval_required`); a person in the dashboard or a full-access team key approves it. +Hosted agent settings (`/inboxes/:id/agent`) are not exposed: MillionSend runs no agent of its own. + +An inbox never expires unless `expiresIn` says so, and its `name` takes up to 320 +characters. `domainId` takes a `domains.list` row instead of `emailAddress`: the +`shared: true` row works like leaving both out, and one of your own domains needs a +`username`. + +On the shared domain, an inbox unused for 90 days (no mail in or out, no API call on it) +is deleted, which fires `inbox.expired`, then `inbox.deleted`. A chosen `username` or +`fromName` there is reviewed first: reserved names (`postmaster`, `admin`, `support`, +`noreply`, …), a `fwd-` prefix and look-alikes of brands answer `422`, a username in use +`409`, and `429` means today's review budget is spent. `503 name_review_unavailable` +means try again after `retryAfter` seconds: the review is down, or the team's new +subdomain still waits for its own, and then every shared or forwarding create gets it. +Safety rules differ there too: `sendPolicy: "allowlist"` answers `422`, and so does +`"open"` unless the team's `sending_mode` is `open`; `ownerAddresses` don't make senders +trusted; `forwardTo` takes team members only. + +#### Sending from the shared domain + +What the team's shared addresses may send is the `sending_mode` of the `shared: true` row +of `domains.list`: `open` starts conversations, `reply_only` only answers inside a thread +to its own correspondents, and `receive_only` sends nothing. The same answers come back +from `emails.send`, replies, forwards and drafts: + +- `403 inbox_send_policy`: the mode doesn't allow this send (or the inbox's name looks + like a brand, which only replies even under `open`); +- `429 inbox_send_cap_reached`: the team's daily cap for the shared domain, with a + `retryAfter` that runs to the next UTC midnight; +- `422 validation_error`: more than 10 distinct recipients across `to`, `cc` and `bcc`. + +`emails.send` takes a shared address in `open` mode only: the inbox's exact +`email_address` (no +tag), a display name left out or equal to its `from_name`, and no +`replyTo`, `attachments`, `headers` or `scheduledAt`. A key restricted to a domain gets +`403 restricted_api_key`. Batch items and broadcasts never send from the shared domain. +Switch on `name` and `statusCode`; the messages may change. + ### Contacts Contacts are team-global — one list per team, no audiences. @@ -149,7 +348,7 @@ const { data } = await ms.contacts.batch.create(items, { }); data.data; // [{ index, id, status: "created" | "updated" | "skipped" }] data.counts; // { created, updated, skipped, failed } -data.errors; // permissive only +data.errors; // permissive only: [{ index, statusCode, name, message }], as strict mode would answer // Bulk lookup (MillionSend extension): up to 1000 contacts by id or email in one request, // in request order; unknown entries are listed, not errors — one request against the rate limit @@ -213,6 +412,9 @@ await ms.broadcasts.cancel(id); // scheduled only await ms.broadcasts.remove(id); // draft only ``` +`subject` takes up to 998 characters. A broadcast never sends from the shared domain: +`create({ send: true })` and `send` answer `403 inbox_send_policy`. + ### Suppressions ```ts @@ -236,9 +438,27 @@ await ms.domains.list(); await ms.domains.get(id); await ms.domains.verify(id); await ms.domains.update({ id, clickTracking: false, trackingSubdomain: null }); // null clears +await ms.domains.update({ id, receivingCatchAll: true }); // accept mail for addresses without an inbox await ms.domains.remove(id); ``` +On an instance with a shared inbox domain, the team's subdomain of it is the last row of +`domains.list` (`shared: true`; MillionSend v0.14.0 and later). It is verified, has no DNS +records, and its id is the `domainId` of the team's shared inboxes. Its `sending_mode` +(`open`, `reply_only` or `receive_only`) says what those inboxes may send, and +`sending_mode_reason` (`plan` or `instance`, `null` under `open`) why it is held below +`open`. It is read-only: `update`, `verify` and `remove` answer `403 forbidden`, and an API +key can't be restricted to it. It comes after every own domain whatever its `created_at`, +alone on the next page when they exactly fill one. A team whose new subdomain still waits +for its name review has no such row yet. Older servers send no `shared`: read it as `false`. + +It sends only from its inboxes' own addresses, so leave it out when picking a domain to send from: + +```ts +const { data } = await ms.domains.list({ limit: 100 }); +const sender = data?.data.find((d) => d.status === "verified" && !d.shared); +``` + ### Webhooks ```ts @@ -259,19 +479,32 @@ const { data } = await ms.webhooks.rotate(id, { overlapHours: 24 }); // or { sig data.signing_secret; data.previous_secret_expires_at; // null when the old secret is dropped at once ``` -Subscribable events include `email.*`, `deliverability.*`, `quota.*`, `contact.*` -(`created`, `updated`, `deleted`, `unsubscribed`, `resubscribed`, `topic_opt_in`, -`topic_opt_out`) and `suppression.added` / `suppression.removed`. +Subscribable events include `email.*` (with `email.received`), `deliverability.*`, +`quota.*`, `contact.*` (`created`, `updated`, `deleted`, `unsubscribed`, +`resubscribed`, `topic_opt_in`, `topic_opt_out`), `suppression.added` / +`suppression.removed`, and the `inbox.*` events (`inbox.created`, `inbox.updated`, +`inbox.deleted`, `inbox.expired`, `inbox.thread.created`, `inbox.thread.folder.updated`, +`inbox.thread.labels.updated`, `inbox.email.received`, `inbox.email.sent`, +`inbox.email.quarantined`, `inbox.draft.created`, `inbox.draft.updated`, +`inbox.draft.sent`, `inbox.draft.deleted`). Inbox events reach only endpoints that +list them by name. `inboxIds` limits an endpoint's inbox events and `email.received` +to those inboxes (`null` = every inbox). Inbox events from sends made in the dashboard +carry `data.source: "dashboard"`. ### API keys ```ts const { data } = await ms.apiKeys.create({ name: "ci", permission: "sending_access", domainId }); data.token; // shown once +await ms.apiKeys.create({ name: "signup-agent", permission: "full_access", inboxId }); // one inbox only await ms.apiKeys.list(); await ms.apiKeys.remove(id); ``` +An inbox-scoped key reads only its inbox and sends only from its address; any other +route answers `403 restricted_api_key`. `domainId` takes one of your own domains: the +`shared: true` row of `domains.list` answers `422 validation_error`. + ### Templates `id` arguments accept the template id or its alias. @@ -340,8 +573,15 @@ match `resend`. Notes: answered with `422`. `templates.publish` is a no-op (every save is live). - **MillionSend extensions** (no Resend counterpart): `segments`, `deliverability`, `usage`, `emails.getInsights`, `contacts.batch`, - `contacts.preferencesLink`, `webhooks.rotate`, and the `origin` field on - suppressions. + `contacts.preferencesLink`, `webhooks.rotate`, the `origin` field on + suppressions, `emails.receiving.reply` / `remove`, `inboxes.safety`, + `inboxes.codes`, `inboxes.quarantine`, `inboxes.drafts.reject`, the `username` and + `domainId` of `inboxes.create`, and `error.retryAfter`. +- **`emails.receiving.forward(id, payload)`** forwards server-side, from your + inbox or domain, instead of Resend's client-side re-send of the raw message. +- **Inboxes** follow Resend's preview shapes with ids as positional arguments + (`ms.inboxes.threads.emails.reply(inboxId, threadId, emailId, payload)`), and + return the full object on create. ## License diff --git a/package.json b/package.json index a5e89ee..bfbc5a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "millionsend", - "version": "0.8.0", + "version": "0.9.0", "description": "Official MillionSend SDK for Node.js — a Resend-compatible email API you can self-host.", "type": "module", "license": "MIT", diff --git a/src/error.ts b/src/error.ts index a9b32a5..59d6987 100644 --- a/src/error.ts +++ b/src/error.ts @@ -10,24 +10,34 @@ export interface ErrorResponse { message: string; /** HTTP status; null when the request never reached the API. */ statusCode: number | null; + /** + * Seconds the response's `retry-after` header asks to wait before trying + * again; null when it sent none (and on client-side failures). + */ + retryAfter: number | null; } /** Every method resolves to this — it never throws for an API error. */ export type Result = { data: T; error: null } | { data: null; error: ErrorResponse }; /** Coerce an arbitrary parsed error body into the canonical shape. */ -export function toErrorResponse(body: unknown, status: number): ErrorResponse { +export function toErrorResponse( + body: unknown, + status: number, + retryAfter: number | null, +): ErrorResponse { if (body && typeof body === "object") { const b = body as Record; const name = typeof b.name === "string" ? b.name : "application_error"; const message = typeof b.message === "string" ? b.message : `Request failed with status ${status}`; const statusCode = typeof b.statusCode === "number" ? b.statusCode : status; - return { name, message, statusCode }; + return { name, message, statusCode, retryAfter }; } return { name: "application_error", message: `Request failed with status ${status}`, statusCode: status, + retryAfter, }; } diff --git a/src/http.ts b/src/http.ts index 704a42c..1492de6 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,9 +1,11 @@ import { type ErrorResponse, type Result, toErrorResponse } from "./error.js"; -import type { BatchValidation } from "./types.js"; +import type { BatchValidation, Draft, InboxSendOutcome } from "./types.js"; const DEFAULT_BASE_URL = "https://api.millionsend.com"; const DEFAULT_TIMEOUT_MS = 30_000; -const SDK_VERSION = "0.8.0"; +/** Time a long-poll answer may take to arrive after the server's own `wait` deadline. */ +const LONG_POLL_MARGIN_MS = 10_000; +const SDK_VERSION = "0.9.0"; export interface MillionSendOptions { /** @@ -27,7 +29,7 @@ export interface MillionSendOptions { } export interface RequestOptions { - /** POST-only idempotency key (emails.send, batch.send). */ + /** POST-only idempotency key (emails.send, batch.send, replies, forwards, drafts.send). */ idempotencyKey?: string; } @@ -64,6 +66,23 @@ function isInsecureHttpUrl(url: string): boolean { return !LOOPBACK_HOSTS.has(parsed.hostname) && !parsed.hostname.startsWith("127."); } +/** + * Whether a path has an empty, "." or ".." segment: URL normalization + * resolves those away, so an id such as ".." would send the request to + * another resource (DELETE /inboxes/{id}/labels/.. deletes the inbox). + */ +function hasDotSegment(path: string): boolean { + return path + .split("/") + .slice(1) + .some((segment) => segment === "" || segment === "." || segment === ".."); +} + +/** `retry-after` in seconds; null when absent or not a whole number of seconds (the API sends only those). */ +function retryAfterSeconds(header: string | null): number | null { + return header !== null && /^\s*\d+\s*$/.test(header) ? Number(header) : null; +} + function queryString(query: Record | undefined): string { if (!query) return ""; const params = new URLSearchParams(); @@ -109,14 +128,42 @@ export class HttpClient { this.userAgent = options.userAgent ? `${base} ${options.userAgent}` : base; } - async request({ + async request(req: DoRequest): Promise> { + return (await this.call(req)).result; + } + + /** + * A send a person may have to approve (replies, forwards, drafts.send): an + * HTTP 202 answer is the draft the API held for approval, any other success + * the sent email. + */ + async requestOutcome(req: DoRequest): Promise>> { + const { status, result } = await this.call(req); + if (result.error) return result; + const data: InboxSendOutcome = + status === 202 + ? { kind: "draft", draft: result.data as Draft } + : { kind: "sent", email: result.data as T }; + return { data, error: null }; + } + + private async call({ method, path, body, query, idempotencyKey, headers: extraHeaders, - }: DoRequest): Promise> { + }: DoRequest): Promise<{ status: number | null; result: Result }> { + if (hasDotSegment(path)) { + const error: ErrorResponse = { + name: "validation_error", + message: 'An id in the request path is empty, "." or "..".', + statusCode: null, + retryAfter: null, + }; + return { status: null, result: { data: null, error } }; + } const url = `${this.baseUrl}${path}${queryString(query)}`; const headers: Record = { Authorization: `Bearer ${this.#apiKey}`, @@ -126,7 +173,12 @@ export class HttpClient { for (const [key, value] of Object.entries(extraHeaders ?? {})) { if (value !== undefined) headers[key] = value; } - const init: RequestInit = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) }; + // A `wait` query asks the API to hold its answer up to that many seconds + // (long-poll), so the deadline has to outlast it. + const wait = Number(query?.wait ?? 0); + const timeoutMs = + wait > 0 ? Math.max(this.timeoutMs, wait * 1000 + LONG_POLL_MARGIN_MS) : this.timeoutMs; + const init: RequestInit = { method, headers, signal: AbortSignal.timeout(timeoutMs) }; if (body !== undefined && method !== "GET" && method !== "DELETE") { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); @@ -142,8 +194,9 @@ export class HttpClient { name: "application_error", message: cause instanceof Error ? cause.message : String(cause), statusCode: null, + retryAfter: null, }; - return { data: null, error }; + return { status: null, result: { data: null, error } }; } const text = await response.text(); @@ -156,9 +209,11 @@ export class HttpClient { } } + const status = response.status; if (!response.ok) { - return { data: null, error: toErrorResponse(parsed, response.status) }; + const retryAfter = retryAfterSeconds(response.headers.get("retry-after")); + return { status, result: { data: null, error: toErrorResponse(parsed, status, retryAfter) } }; } - return { data: parsed as T, error: null }; + return { status, result: { data: parsed as T, error: null } }; } } diff --git a/src/index.ts b/src/index.ts index d3e19ae..a8b3701 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { Contacts } from "./resources/contacts.js"; import { Deliverability } from "./resources/deliverability.js"; import { Domains } from "./resources/domains.js"; import { Batch, Emails } from "./resources/emails.js"; +import { Inboxes } from "./resources/inboxes.js"; import { Segments } from "./resources/segments.js"; import { Suppressions } from "./resources/suppressions.js"; import { Templates } from "./resources/templates.js"; @@ -34,6 +35,7 @@ import { Webhooks } from "./resources/webhooks.js"; export class MillionSend { readonly emails: Emails; readonly batch: Batch; + readonly inboxes: Inboxes; readonly contacts: Contacts; readonly contactProperties: ContactProperties; readonly broadcasts: Broadcasts; @@ -57,6 +59,7 @@ export class MillionSend { const http = new HttpClient(key, options); this.emails = new Emails(http); this.batch = new Batch(http); + this.inboxes = new Inboxes(http); this.contacts = new Contacts(http); this.contactProperties = new ContactProperties(http); this.broadcasts = new Broadcasts(http); diff --git a/src/query.ts b/src/query.ts index 5eaadc2..b35b995 100644 --- a/src/query.ts +++ b/src/query.ts @@ -7,3 +7,13 @@ export function listQuery( if (!options) return undefined; return { limit: options.limit, after: options.after, before: options.before }; } + +/** A boolean filter as the API reads it: "true" or "false"; undefined stays off the wire. */ +export function bool(value: boolean | undefined): string | undefined { + return value === undefined ? undefined : String(value); +} + +/** A list filter as the API reads it: comma-separated. An empty list stays off the wire, since `labels=` is not "no labels". */ +export function csv(values: readonly string[] | undefined): string | undefined { + return values?.length ? values.join(",") : undefined; +} diff --git a/src/resources/api-keys.ts b/src/resources/api-keys.ts index 1e0e9a5..e1dd2d5 100644 --- a/src/resources/api-keys.ts +++ b/src/resources/api-keys.ts @@ -14,6 +14,7 @@ import type { export class ApiKeys { constructor(private readonly http: HttpClient) {} + /** POST /api-keys — a `domainId` must be one of your own domains, not the `shared: true` row. */ create(payload: CreateApiKeyOptions): Promise> { return this.http.request({ method: "POST", @@ -22,6 +23,7 @@ export class ApiKeys { name: payload.name, permission: payload.permission, domain_id: payload.domainId ?? payload.domain_id, + inbox_id: payload.inboxId, }, }); } diff --git a/src/resources/broadcasts.ts b/src/resources/broadcasts.ts index d02a38c..65d43fe 100644 --- a/src/resources/broadcasts.ts +++ b/src/resources/broadcasts.ts @@ -31,7 +31,10 @@ function toWire(o: UpdateBroadcastOptions) { export class Broadcasts { constructor(private readonly http: HttpClient) {} - /** POST /broadcasts — a draft unless `send: true` (optionally with `scheduledAt`). */ + /** + * POST /broadcasts — a draft unless `send: true` (optionally with `scheduledAt`). + * Sending from the shared domain answers 403 `inbox_send_policy`, here and on `send`. + */ create(payload: CreateBroadcastOptions): Promise> { return this.http.request({ method: "POST", diff --git a/src/resources/domains.ts b/src/resources/domains.ts index b70566b..6ea3fe6 100644 --- a/src/resources/domains.ts +++ b/src/resources/domains.ts @@ -12,7 +12,10 @@ import type { UpdateDomainOptions, } from "../types.js"; -/** Sending domains and their DNS records. */ +/** + * Sending domains and their DNS records. The team's subdomain of the shared + * inbox domain is listed too, read-only, as the last row (`shared: true`). + */ export class Domains { constructor(private readonly http: HttpClient) {} @@ -32,6 +35,7 @@ export class Domains { }); } + /** GET /domains — own domains newest first, then the shared row. */ list(options?: ListOptions): Promise>> { return this.http.request({ method: "GET", path: "/domains", query: listQuery(options) }); } @@ -40,7 +44,10 @@ export class Domains { return this.http.request({ method: "GET", path: `/domains/${encodeURIComponent(id)}` }); } - /** PATCH /domains/:id — tracking settings; `trackingSubdomain: null` clears it. */ + /** + * PATCH /domains/:id — tracking and catch-all settings; `trackingSubdomain: null` clears it. + * The shared row answers 403 `forbidden` here, on `verify` and on `remove`. + */ update(payload: UpdateDomainOptions): Promise> { return this.http.request({ method: "PATCH", @@ -51,6 +58,7 @@ export class Domains { tracking_subdomain: payload.trackingSubdomain, tls: payload.tls, capabilities: payload.capabilities, + receiving_catch_all: payload.receivingCatchAll, }, }); } diff --git a/src/resources/emails.ts b/src/resources/emails.ts index 254590d..bf6e945 100644 --- a/src/resources/emails.ts +++ b/src/resources/emails.ts @@ -15,6 +15,7 @@ import type { UpdateEmailOptions, UpdateEmailResponse, } from "../types.js"; +import { Receiving } from "./receiving.js"; function toWire(o: SendEmailOptions) { return { @@ -42,9 +43,18 @@ function toWire(o: SendEmailOptions) { } export class Emails { - constructor(private readonly http: HttpClient) {} + readonly receiving: Receiving; + + constructor(private readonly http: HttpClient) { + this.receiving = new Receiving(http); + } - /** POST /emails — supports an Idempotency-Key. */ + /** + * POST /emails — supports an Idempotency-Key. The shared domain sends only in + * `sending_mode: "open"`, from an inbox's exact address (display name omitted + * or its `from_name`), without `replyTo`, `attachments`, `headers` or + * `scheduledAt`, to at most 10 recipients. + */ send( payload: SendEmailOptions, options: RequestOptions = {}, @@ -109,7 +119,10 @@ export class Emails { export class Batch { constructor(private readonly http: HttpClient) {} - /** POST /emails/batch — 1–100 emails in one call; supports an Idempotency-Key and `batchValidation`. */ + /** + * POST /emails/batch — 1–100 emails in one call; supports an Idempotency-Key and `batchValidation`. + * An item from the shared domain is refused (403 `inbox_send_policy`). + */ send( payload: SendEmailOptions[], options: BatchRequestOptions = {}, diff --git a/src/resources/inboxes.ts b/src/resources/inboxes.ts new file mode 100644 index 0000000..fbc8b11 --- /dev/null +++ b/src/resources/inboxes.ts @@ -0,0 +1,449 @@ +import type { Result } from "../error.js"; +import type { HttpClient, RequestOptions } from "../http.js"; +import { bool, csv, listQuery } from "../query.js"; +import type { + CreateDraftOptions, + CreateInboxOptions, + CreateLabelOptions, + Draft, + DraftListItem, + ForwardThreadEmailOptions, + GetThreadEmailOptions, + Inbox, + InboxListItem, + InboxSafetyOptions, + InboxSafetyResponse, + InboxSendOutcome, + LabelAck, + LabelList, + LatestCodeOptions, + List, + ListInboxesOptions, + ListOptions, + ListThreadEmailsOptions, + ListThreadsOptions, + QuarantinedEmail, + QuarantineRelease, + ReleaseQuarantinedOptions, + RemoveDraftResponse, + RemoveInboxResponse, + RemoveLabelResponse, + RemoveThreadResponse, + ReplyThreadEmailOptions, + SearchThreadsOptions, + SentDraft, + SentEmail, + ThreadEmail, + ThreadListItem, + ThreadSearchResult, + ThreadSummary, + UpdateDraftOptions, + UpdateInboxOptions, + UpdateLabelOptions, + UpdateThreadOptions, + VerificationCode, +} from "../types.js"; +import { forwardToWire, replyToWire } from "./receiving.js"; + +/** `/inboxes/`, each segment percent-encoded (the literal ones are plain words). */ +function inboxPath(...segments: string[]): string { + return `/inboxes/${segments.map(encodeURIComponent).join("/")}`; +} + +function safetyToWire(s: InboxSafetyOptions) { + return { + accept_from: s.acceptFrom, + allowlist: s.allowlist, + owner_addresses: s.ownerAddresses, + screening: s.screening, + extract_codes: s.extractCodes, + send_policy: s.sendPolicy, + require_approval: s.requireApproval, + private_codes: s.privateCodes, + send_cap: s.sendCap, + forward_to: s.forwardTo, + }; +} + +function threadsQuery(o: SearchThreadsOptions | undefined) { + if (!o) return undefined; + return { + ...listQuery(o), + folders: csv(o.folders), + labels: csv(o.labels), + read: bool(o.read), + include_quarantined: o.includeQuarantined ? "true" : undefined, + query: o.query, + from: csv(o.from), + to: csv(o.to), + cc: csv(o.cc), + bcc: csv(o.bcc), + has_attachment: bool(o.hasAttachment), + start_date: o.startDate, + end_date: o.endDate, + }; +} + +function draftToWire(o: CreateDraftOptions) { + return { + to: o.to, + cc: o.cc, + bcc: o.bcc, + subject: o.subject, + html: o.html, + text: o.text, + thread_id: o.threadId, + reply_to_email_id: o.replyToEmailId, + }; +} + +/** Who may write in, what the inbox may send, and what happens before an agent reads (MillionSend extension). */ +export class InboxSafetySettings { + constructor(private readonly http: HttpClient) {} + + /** GET /inboxes/:id/safety */ + get(inboxId: string): Promise> { + return this.http.request({ method: "GET", path: inboxPath(inboxId, "safety") }); + } + + /** PATCH /inboxes/:id/safety — omitted fields keep their value; inbox-scoped keys get a 403. */ + update(inboxId: string, patch: InboxSafetyOptions): Promise> { + return this.http.request({ + method: "PATCH", + path: inboxPath(inboxId, "safety"), + body: safetyToWire(patch), + }); + } +} + +/** Verification codes and sign-in links pulled from received mail (MillionSend extension). */ +export class InboxCodes { + constructor(private readonly http: HttpClient) {} + + /** GET /inboxes/:id/codes/latest — the newest delivered code or link; `wait` long-polls for one. */ + latest(inboxId: string, options: LatestCodeOptions = {}): Promise> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "codes", "latest"), + query: { + from: options.from, + wait: options.wait, + max_age: options.maxAge, + since: options.since, + }, + }); + } +} + +/** Mail held by the inbox's rules, kept until a person releases it (MillionSend extension). */ +export class InboxQuarantine { + constructor(private readonly http: HttpClient) {} + + /** GET /inboxes/:id/quarantine — newest first; full-access team keys only. */ + list(inboxId: string, options?: ListOptions): Promise>> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "quarantine"), + query: listQuery(options), + }); + } + + /** POST /inboxes/:id/quarantine/:emailId/release — delivers it; `trustSender` allowlists the sender. */ + release( + inboxId: string, + emailId: string, + options: ReleaseQuarantinedOptions = {}, + ): Promise> { + return this.http.request({ + method: "POST", + path: inboxPath(inboxId, "quarantine", emailId, "release"), + body: { trust_sender: options.trustSender }, + }); + } +} + +/** Per-inbox labels, up to 100. */ +export class InboxLabels { + constructor(private readonly http: HttpClient) {} + + /** GET /inboxes/:id/labels — unpaginated, oldest first. */ + list(inboxId: string): Promise> { + return this.http.request({ method: "GET", path: inboxPath(inboxId, "labels") }); + } + + create(inboxId: string, payload: CreateLabelOptions): Promise> { + return this.http.request({ + method: "POST", + path: inboxPath(inboxId, "labels"), + body: { name: payload.name, color: payload.color }, + }); + } + + update(inboxId: string, labelId: string, payload: UpdateLabelOptions): Promise> { + return this.http.request({ + method: "PATCH", + path: inboxPath(inboxId, "labels", labelId), + body: { name: payload.name, color: payload.color }, + }); + } + + /** DELETE /inboxes/:id/labels/:labelId — also removes it from every thread. */ + remove(inboxId: string, labelId: string): Promise> { + return this.http.request({ method: "DELETE", path: inboxPath(inboxId, "labels", labelId) }); + } +} + +/** The messages of a thread, both directions. */ +export class InboxThreadEmails { + constructor(private readonly http: HttpClient) {} + + /** GET /inboxes/:id/threads/:threadId/emails — oldest first; `after` pages toward newer. */ + list( + inboxId: string, + threadId: string, + options?: ListThreadEmailsOptions, + ): Promise>> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "threads", threadId, "emails"), + query: { + ...listQuery(options), + include_quarantined: options?.includeQuarantined ? "true" : undefined, + }, + }); + } + + get( + inboxId: string, + threadId: string, + emailId: string, + options: GetThreadEmailOptions = {}, + ): Promise> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "threads", threadId, "emails", emailId), + query: { include_quarantined: options.includeQuarantined ? "true" : undefined }, + }); + } + + /** POST …/emails/:emailId/reply — sent, or held as a draft when the inbox's rules ask for approval. */ + reply( + inboxId: string, + threadId: string, + emailId: string, + payload: ReplyThreadEmailOptions, + options: RequestOptions = {}, + ): Promise>> { + return this.http.requestOutcome({ + method: "POST", + path: inboxPath(inboxId, "threads", threadId, "emails", emailId, "reply"), + body: replyToWire(payload), + idempotencyKey: options.idempotencyKey, + }); + } + + /** POST …/emails/:emailId/forward — the original quoted below an optional note, with its attachments. */ + forward( + inboxId: string, + threadId: string, + emailId: string, + payload: ForwardThreadEmailOptions, + options: RequestOptions = {}, + ): Promise>> { + return this.http.requestOutcome({ + method: "POST", + path: inboxPath(inboxId, "threads", threadId, "emails", emailId, "forward"), + body: forwardToWire(payload), + idempotencyKey: options.idempotencyKey, + }); + } +} + +/** Conversations in an inbox, each in one folder. */ +export class InboxThreads { + readonly emails: InboxThreadEmails; + + constructor(private readonly http: HttpClient) { + this.emails = new InboxThreadEmails(http); + } + + /** GET /inboxes/:id/threads — most recently active first. */ + list(inboxId: string, options?: ListThreadsOptions): Promise>> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "threads"), + query: threadsQuery(options), + }); + } + + /** GET /inboxes/:id/threads/search — subject, sender and recipients (bodies are sealed at rest). */ + search( + inboxId: string, + options?: SearchThreadsOptions, + ): Promise>> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "threads", "search"), + query: threadsQuery(options), + }); + } + + get(inboxId: string, threadId: string): Promise> { + return this.http.request({ method: "GET", path: inboxPath(inboxId, "threads", threadId) }); + } + + /** PATCH /inboxes/:id/threads/:threadId — read state, folder, or one more label. */ + update( + inboxId: string, + threadId: string, + payload: UpdateThreadOptions, + ): Promise> { + return this.http.request({ + method: "PATCH", + path: inboxPath(inboxId, "threads", threadId), + body: { read: payload.read, folder: payload.folder, label_id: payload.labelId }, + }); + } + + /** DELETE /inboxes/:id/threads/:threadId — moves it to trash, purged after 30 days. */ + remove(inboxId: string, threadId: string): Promise> { + return this.http.request({ method: "DELETE", path: inboxPath(inboxId, "threads", threadId) }); + } +} + +/** Drafts, standalone or replies; some wait for a person to approve them. */ +export class InboxDrafts { + constructor(private readonly http: HttpClient) {} + + /** GET /inboxes/:id/drafts — unsent drafts, most recently updated first. */ + list(inboxId: string, options?: ListOptions): Promise>> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "drafts"), + query: listQuery(options), + }); + } + + /** POST /inboxes/:id/drafts — a second reply draft for the same email returns the existing one. */ + create(inboxId: string, payload: CreateDraftOptions): Promise> { + return this.http.request({ + method: "POST", + path: inboxPath(inboxId, "drafts"), + body: draftToWire(payload), + }); + } + + get(inboxId: string, draftId: string): Promise> { + return this.http.request({ method: "GET", path: inboxPath(inboxId, "drafts", draftId) }); + } + + /** PATCH /inboxes/:id/drafts/:draftId — `null` clears a field. */ + update(inboxId: string, draftId: string, payload: UpdateDraftOptions): Promise> { + return this.http.request({ + method: "PATCH", + path: inboxPath(inboxId, "drafts", draftId), + body: draftToWire(payload), + }); + } + + remove(inboxId: string, draftId: string): Promise> { + return this.http.request({ method: "DELETE", path: inboxPath(inboxId, "drafts", draftId) }); + } + + /** + * POST /inboxes/:id/drafts/:draftId/send — held again as a draft when the + * rules ask for approval. An agent's key cannot send a draft that waits for + * approval (403 approval_required); a person or a team key approves it. + */ + send( + inboxId: string, + draftId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.http.requestOutcome({ + method: "POST", + path: inboxPath(inboxId, "drafts", draftId, "send"), + idempotencyKey: options.idempotencyKey, + }); + } + + /** POST /inboxes/:id/drafts/:draftId/reject — approvers only (MillionSend extension). */ + reject(inboxId: string, draftId: string): Promise> { + return this.http.request({ + method: "POST", + path: inboxPath(inboxId, "drafts", draftId, "reject"), + }); + } +} + +/** Inboxes: an address that receives mail, read by an agent or by people. */ +export class Inboxes { + readonly safety: InboxSafetySettings; + readonly codes: InboxCodes; + readonly quarantine: InboxQuarantine; + readonly labels: InboxLabels; + readonly threads: InboxThreads; + readonly drafts: InboxDrafts; + + constructor(private readonly http: HttpClient) { + this.safety = new InboxSafetySettings(http); + this.codes = new InboxCodes(http); + this.quarantine = new InboxQuarantine(http); + this.labels = new InboxLabels(http); + this.threads = new InboxThreads(http); + this.drafts = new InboxDrafts(http); + } + + /** + * POST /inboxes — without `emailAddress` or `domainId`, an address on the + * instance's shared domain: `username`, or random. Shared and forwarding + * creates answer 503 `name_review_unavailable` (see `retryAfter`) while the + * team's subdomain awaits its name review, and so does a chosen `username` or + * `fromName` there while the review is down; a spent review budget answers 429. + */ + create(payload: CreateInboxOptions = {}): Promise> { + return this.http.request({ + method: "POST", + path: "/inboxes", + body: { + email_address: payload.emailAddress, + domain_id: payload.domainId, + username: payload.username, + name: payload.name, + from_name: payload.fromName, + forwarding: payload.forwarding, + kind: payload.kind, + expires_in: payload.expiresIn, + safety: payload.safety && safetyToWire(payload.safety), + }, + }); + } + + /** GET /inboxes — newest first. */ + list(options?: ListInboxesOptions): Promise>> { + return this.http.request({ + method: "GET", + path: "/inboxes", + query: { ...listQuery(options), kind: options?.kind }, + }); + } + + /** GET /inboxes/:idOrEmail — by id or by address. */ + get(idOrEmail: string): Promise> { + return this.http.request({ method: "GET", path: inboxPath(idOrEmail) }); + } + + /** PATCH /inboxes/:id — a new `fromName` on the shared domain is reviewed like on `create` (503, 429). */ + update(id: string, payload: UpdateInboxOptions): Promise> { + return this.http.request({ + method: "PATCH", + path: inboxPath(id), + body: { name: payload.name, from_name: payload.fromName, expires_in: payload.expiresIn }, + }); + } + + /** DELETE /inboxes/:id — removes its threads, drafts and labels, and revokes its keys. */ + remove(id: string): Promise> { + return this.http.request({ method: "DELETE", path: inboxPath(id) }); + } +} diff --git a/src/resources/receiving.ts b/src/resources/receiving.ts new file mode 100644 index 0000000..adf569b --- /dev/null +++ b/src/resources/receiving.ts @@ -0,0 +1,147 @@ +import type { Result } from "../error.js"; +import type { HttpClient, RequestOptions } from "../http.js"; +import { bool, listQuery } from "../query.js"; +import type { + ForwardReceivedEmailOptions, + GetReceivedEmailOptions, + InboxSendOutcome, + List, + ListOptions, + ListReceivedEmailsOptions, + ReceivedAttachment, + ReceivedAttachmentDetail, + ReceivedEmail, + ReceivedEmailList, + ReceivedEmailSent, + RemoveEmailResponse, + ReplyReceivedEmailOptions, +} from "../types.js"; + +/** Reply body on the wire; the inbox thread reply sends the same keys minus `from`/`inbox_id`. */ +export function replyToWire(o: ReplyReceivedEmailOptions) { + return { + html: o.html, + text: o.text, + cc: o.cc, + bcc: o.bcc, + subject: o.subject, + reply_all: o.replyAll, + requires_approval: o.requiresApproval, + from: o.from, + inbox_id: o.inboxId, + }; +} + +/** Forward body on the wire; the inbox thread forward sends the same keys minus `from`/`inbox_id`/`mode`. */ +export function forwardToWire(o: ForwardReceivedEmailOptions) { + return { + to: o.to, + cc: o.cc, + bcc: o.bcc, + html: o.html, + text: o.text, + subject: o.subject, + requires_approval: o.requiresApproval, + from: o.from, + inbox_id: o.inboxId, + mode: o.mode, + }; +} + +const emailPath = (id: string) => `/emails/receiving/${encodeURIComponent(id)}`; + +/** A received email's attachments, each with a signed download link valid for one hour. */ +export class ReceivingAttachments { + constructor(private readonly http: HttpClient) {} + + /** GET /emails/receiving/:emailId/attachments — every attachment unless `limit` is given. */ + list(emailId: string, options?: ListOptions): Promise>> { + return this.http.request({ + method: "GET", + path: `${emailPath(emailId)}/attachments`, + query: listQuery(options), + }); + } + + /** GET /emails/receiving/:emailId/attachments/:id */ + get(emailId: string, id: string): Promise> { + return this.http.request({ + method: "GET", + path: `${emailPath(emailId)}/attachments/${encodeURIComponent(id)}`, + }); + } +} + +/** + * Mail received at the team's domains and inboxes. Everything in it was + * written by the sender, so treat its content as untrusted. + */ +export class Receiving { + readonly attachments: ReceivingAttachments; + + constructor(private readonly http: HttpClient) { + this.attachments = new ReceivingAttachments(http); + } + + /** GET /emails/receiving — newest first; `wait` long-polls up to 50 s for a match. */ + list(options: ListReceivedEmailsOptions = {}): Promise> { + return this.http.request({ + method: "GET", + path: "/emails/receiving", + query: { + ...listQuery(options), + wait: options.wait, + since: options.since, + inbox_id: options.inboxId, + to: options.to, + from: options.from, + subject_contains: options.subjectContains, + unread: bool(options.unread), + include_quarantined: options.includeQuarantined ? "true" : undefined, + quarantined: options.quarantined ? "true" : undefined, + }, + }); + } + + /** GET /emails/receiving/:id — html, text, headers, verdicts and a signed link to the raw message. */ + get(id: string, options: GetReceivedEmailOptions = {}): Promise> { + return this.http.request({ + method: "GET", + path: emailPath(id), + query: { html_format: options.htmlFormat }, + }); + } + + /** POST /emails/receiving/:id/reply — sent, or held as a draft when the inbox's rules ask for approval. */ + reply( + id: string, + payload: ReplyReceivedEmailOptions, + options: RequestOptions = {}, + ): Promise>> { + return this.http.requestOutcome({ + method: "POST", + path: `${emailPath(id)}/reply`, + body: replyToWire(payload), + idempotencyKey: options.idempotencyKey, + }); + } + + /** POST /emails/receiving/:id/forward — inline, or as an attached .eml (`mode: "attachment"`). */ + forward( + id: string, + payload: ForwardReceivedEmailOptions, + options: RequestOptions = {}, + ): Promise>> { + return this.http.requestOutcome({ + method: "POST", + path: `${emailPath(id)}/forward`, + body: forwardToWire(payload), + idempotencyKey: options.idempotencyKey, + }); + } + + /** DELETE /emails/receiving/:id — erases the message now instead of at the end of retention. */ + remove(id: string): Promise> { + return this.http.request({ method: "DELETE", path: emailPath(id) }); + } +} diff --git a/src/resources/webhooks.ts b/src/resources/webhooks.ts index c3e4e80..2914973 100644 --- a/src/resources/webhooks.ts +++ b/src/resources/webhooks.ts @@ -27,6 +27,7 @@ export class Webhooks { endpoint: payload.endpoint, events: payload.events, signing_secret: payload.signingSecret, + inbox_ids: payload.inboxIds, }, }); } @@ -43,7 +44,12 @@ export class Webhooks { return this.http.request({ method: "PATCH", path: `/webhooks/${encodeURIComponent(id)}`, - body: { endpoint: payload.endpoint, events: payload.events, status: payload.status }, + body: { + endpoint: payload.endpoint, + events: payload.events, + status: payload.status, + inbox_ids: payload.inboxIds, + }, }); } diff --git a/src/types.ts b/src/types.ts index 495e064..b88ab80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,11 +20,23 @@ export interface BatchError { message: string; } +/** A contacts batch failure also names what strict mode would answer for the item. */ +export interface BatchContactError extends BatchError { + /** Absent on instances before v0.12.0. */ + statusCode?: number; + /** Absent on instances before v0.12.0. */ + name?: string; +} + // ---- shared list envelope ------------------------------------------------ export interface ListOptions { limit?: number; - /** Keyset cursor: return items after this id. Mutually exclusive with `before`. */ + /** + * Keyset cursor: the last id of a page; returns the next page (older items + * on the newest-first lists). Mutually exclusive with `before`. + */ after?: string; + /** The first id of a page; returns the previous page (newer items on the newest-first lists). */ before?: string; } @@ -46,8 +58,11 @@ export interface Attachment { } export interface SendEmailOptions { + /** An address on a verified domain, or an inbox's exact address on the shared domain (open mode only). */ from: string; + /** An address here or in cc/bcc on a domain that can never receive mail (`example.com`, `.test`, …) answers 422. */ to: Recipients; + /** Up to 998 characters. */ subject: string; html?: string; text?: string; @@ -339,7 +354,7 @@ export interface BatchContactsResponse { data: BatchContactResult[]; counts: { created: number; updated: number; skipped: number; failed: number }; /** Present in permissive mode: the items that were not written. */ - errors?: BatchError[]; + errors?: BatchContactError[]; } /** Address a contact by id, email or Resend's `contactId` (email wins). */ @@ -435,7 +450,9 @@ export interface RemoveTopicResponse { export interface CreateBroadcastOptions { name?: string; segmentId?: string; + /** Never the shared domain: sending from it answers 403 `inbox_send_policy`. */ from: string; + /** Up to 998 characters. */ subject: string; html?: string; text?: string; @@ -598,6 +615,9 @@ export interface DomainCapabilities { receiving: string; } +/** What the team's addresses on the shared domain may send: start conversations, reply in threads, or nothing. */ +export type DomainSendingMode = "open" | "reply_only" | "receive_only" | (string & {}); + export interface CreateDomainOptions { name: string; /** Each deployment serves one region and rejects any other; omit to use it. */ @@ -619,6 +639,11 @@ export interface UpdateDomainOptions { /** Accepted for Resend payload compatibility; MillionSend ignores them. */ tls?: "enforced" | "opportunistic"; capabilities?: Partial; + /** + * Accept mail for addresses that have no inbox (they land in received mail). + * Off, those addresses are refused. Turning it on needs a paid plan on Cloud. + */ + receivingCatchAll?: boolean; } export interface DomainRecord { @@ -641,6 +666,17 @@ export interface DomainListItem { click_tracking: boolean; tracking_subdomain: string | null; capabilities: DomainCapabilities; + /** Whether mail to addresses without an inbox is accepted; absent on instances before inboxes. */ + receiving_catch_all?: boolean; + /** + * The team's read-only subdomain of the shared inbox domain, listed last; it + * sends only from its inboxes' own addresses. Absent before v0.14.0: read it as false. + */ + shared?: boolean; + /** Shared row only. */ + sending_mode?: DomainSendingMode; + /** Shared row only: what holds sending below `open`; null in open mode. */ + sending_mode_reason?: "plan" | "instance" | (string & {}) | null; } export interface CreateDomainResponse extends DomainListItem { @@ -680,6 +716,22 @@ export type WebhookEvent = | "contact.topic_opt_out" | "suppression.added" | "suppression.removed" + | "email.received" + // Inbox events reach only endpoints that list them; "all events" never includes them. + | "inbox.created" + | "inbox.updated" + | "inbox.deleted" + | "inbox.expired" + | "inbox.thread.created" + | "inbox.thread.folder.updated" + | "inbox.thread.labels.updated" + | "inbox.email.received" + | "inbox.email.sent" + | "inbox.email.quarantined" + | "inbox.draft.created" + | "inbox.draft.updated" + | "inbox.draft.sent" + | "inbox.draft.deleted" | (string & {}); export type WebhookStatus = "enabled" | "disabled"; @@ -689,12 +741,16 @@ export interface CreateWebhookOptions { events: WebhookEvent[]; /** Carry over an existing `whsec_…` secret so the receiver keeps verifying unchanged; omit to mint one. */ signingSecret?: string; + /** Deliver inbox events and `email.received` only for these inboxes; null or omitted = every inbox. */ + inboxIds?: string[] | null; } export interface UpdateWebhookOptions { endpoint?: string; events?: WebhookEvent[]; status?: WebhookStatus; + /** null delivers for every inbox again. */ + inboxIds?: string[] | null; } export interface CreateWebhookResponse { @@ -709,6 +765,8 @@ export interface WebhookListItem { created_at: string; status: WebhookStatus; events: string[] | null; + /** The inboxes this endpoint is limited to; null = every inbox. Absent on instances before inboxes. */ + inbox_ids?: string[] | null; } export interface Webhook extends WebhookListItem { @@ -750,10 +808,15 @@ export interface CreateApiKeyOptions { name: string; /** Defaults to full_access. */ permission?: ApiKeyPermission; - /** Restrict a sending_access key to one domain. */ + /** Restrict a sending_access key to one domain: a `shared: false` row of `domains.list`. */ domainId?: string | null; /** Resend's spelling of `domainId`; either works. */ domain_id?: string | null; + /** + * Restrict a full_access key to one inbox: it reads that inbox and sends + * only from its address. Up to 5 per inbox; cannot be combined with a domain. + */ + inboxId?: string | null; } export interface CreateApiKeyResponse { @@ -767,6 +830,8 @@ export interface ApiKeyListItem { name: string; created_at: string; last_used_at: string | null; + /** Set on an inbox-scoped key; absent on instances before inboxes. */ + inbox_id?: string | null; } export interface RemoveApiKeyResponse { @@ -846,10 +911,673 @@ export interface Usage { cloud: boolean; /** Effective plan; null when self-hosted. */ plan: "free" | "pro" | "scale" | null; - /** null = unlimited or self-hosted. */ - limits: { emails_per_day: number | null; domains: number | null }; + /** null = unlimited or self-hosted. `inboxes` is absent on instances before inboxes. */ + limits: { emails_per_day: number | null; domains: number | null; inboxes?: number | null }; today: { emails_sent: number; resets_at: string }; team: { id: string; name: string }; /** Dashboard origin, for building links; null when unset. */ app_url: string | null; } + +// ---- received mail ------------------------------------------------------- +// MillionSend's own enums are open sets (the API may grow them); Resend's +// (folder, direction, draft type) never gain values, so they stay closed. +export type SenderTrust = + | "owner" + | "allowlisted" + | "known_thread" + | "external" + | "unauthenticated" + | "failed" + | (string & {}); +export type ScreeningVerdict = + | "pending" + | "clean" + | "suspicious" + | "quarantined" + | "released" + | (string & {}); +export type QuarantineReason = + | "failed_auth" + | "unauthenticated_sender" + | "not_allowlisted" + | "injection" + | "phishing" + | "virus" + | "unscreened" + | "screening_cap" + | (string & {}); +export type SpamVerdict = "pass" | "gray" | "fail" | (string & {}); +export type VirusVerdict = "pass" | "fail" | "processing_failed" | (string & {}); +export type ExtractedKind = "code" | "link" | (string & {}); + +/** The verdict and the sender's trust tier, as lists carry them. */ +export interface ScreeningSummary { + verdict: ScreeningVerdict; + sender_trust: SenderTrust; +} + +/** How an inbox screened one message. Scores are 0–100, null when it was not judged. */ +export interface Screening extends ScreeningSummary { + /** Set while quarantined or after a release. */ + quarantine_reason: QuarantineReason | null; + reasons: string[]; + injection_score: number | null; + phishing_score: number | null; + hidden_text_removed: boolean; +} + +export interface ReceivedEmailAttachment { + id: string; + filename: string | null; + content_type: string; + content_id: string | null; + content_disposition: string | null; + size: number; +} + +/** One inbox a received message landed in (owner views). */ +export interface ReceivedEmailInbox { + inbox_id: string; + thread_id: string; + verdict: ScreeningVerdict; +} + +/** Fields after `attachments` are MillionSend extensions, absent on instances before inboxes. */ +export interface ReceivedEmailListItem { + id: string; + to: string[]; + /** Bare address; the display name is in the detail's `headers.from`. */ + from: string; + created_at: string; + subject: string; + bcc: string[]; + cc: string[]; + reply_to: string[]; + /** The team's envelope recipients: the addresses the message was delivered to. */ + received_for: string[]; + message_id: string; + attachments: ReceivedEmailAttachment[]; + inbox_id?: string | null; + thread_id?: string | null; + /** Owner views only. */ + inboxes?: ReceivedEmailInbox[]; + screening?: ScreeningSummary | null; + spam_verdict?: SpamVerdict | null; + virus_verdict?: VirusVerdict | null; + extracted?: { kinds: ExtractedKind[] }; + /** Present with an inbox context. */ + read?: boolean; +} + +/** A membership held for screening or quarantine; the long-poll reports ids and why only. */ +export interface HeldEmail { + id: string; + /** Null for a catch-all copy held for a virus. */ + inbox_id: string | null; + reason: "pending" | "quarantined"; + /** Why it was quarantined; null while it is still being screened. */ + quarantine_reason: QuarantineReason | null; + created_at: string; +} + +export interface ReceivedEmailList extends List { + /** Long-poll (`wait`) only: held messages that matched since the cursor. */ + held?: HeldEmail[]; +} + +export interface ListReceivedEmailsOptions extends ListOptions { + /** Long-poll: hold the request up to this many seconds (0–50) until a matching email arrives. */ + wait?: number; + /** ISO 8601: only mail received after this instant. */ + since?: string; + /** An unknown inbox answers 404 `not_found` at once, even with `wait`. */ + inboxId?: string; + /** An address the message was delivered to (`received_for`). */ + to?: string; + /** A sender address, or a domain covering its subdomains. */ + from?: string; + subjectContains?: string; + /** Needs `inboxId`, except with an inbox-scoped key, which reads its own inbox. */ + unread?: boolean; + /** Also list held mail (full-access team keys only). */ + includeQuarantined?: boolean; + /** List only held mail (full-access team keys only). */ + quarantined?: boolean; +} + +export interface GetReceivedEmailOptions { + /** `data_uri` (default) inlines `cid:` images; `cid` leaves the references. */ + htmlFormat?: "data_uri" | "cid"; +} + +/** A verification code or sign-in link found in the message (`extract_codes` inboxes). */ +export interface ReceivedExtracted { + kinds: ExtractedKind[]; + code?: string | null; + link?: string | null; + link_domain_matches_sender?: boolean | null; + ambiguous?: boolean; +} + +export interface ReceivedEmail extends ReceivedEmailListItem { + object: "email"; + html: string | null; + html_format: "data_uri" | "cid"; + /** Invisible characters removed; `html` and `raw` stay byte-faithful. */ + text: string | null; + headers: Record | null; + authentication: { spf: string; dkim: string; dmarc: string }; + /** Signed link to the original message, valid for one hour; null once purged or when it carries malware. */ + raw: { download_url: string; expires_at: string } | null; + screening?: Screening | null; + spam_score?: number | null; + virus_signature?: string | null; + /** The new part of the message as plain text: hidden text, quotes and the signature removed. */ + extracted_text?: string | null; + extracted?: ReceivedExtracted; +} + +export interface ReceivedAttachment { + id: string; + filename?: string; + size: number; + content_type: string; + content_disposition: "inline" | "attachment"; + content_id?: string; + /** Signed link to the bytes, valid for one hour; it needs no API key. */ + download_url: string; + expires_at: string; +} + +export interface ReceivedAttachmentDetail extends ReceivedAttachment { + object: "attachment"; +} + +export interface ReplyThreadEmailOptions { + html?: string; + text?: string; + cc?: Recipients; + bcc?: Recipients; + /** Defaults to `Re: ` plus the thread subject. */ + subject?: string; + /** Also copy the message's other recipients. */ + replyAll?: boolean; + /** Hold it as a draft for a person to approve. */ + requiresApproval?: boolean; +} + +export interface ReplyReceivedEmailOptions extends ReplyThreadEmailOptions { + /** An address on a verified team domain; defaults to the inbox the message landed in. */ + from?: string; + /** Reply as this inbox when the message landed in several. */ + inboxId?: string; +} + +export interface ForwardThreadEmailOptions { + to: Recipients; + cc?: Recipients; + bcc?: Recipients; + /** A note above the forwarded message. */ + html?: string; + text?: string; + /** Defaults to `Fwd: ` plus the thread subject. */ + subject?: string; + requiresApproval?: boolean; +} + +export interface ForwardReceivedEmailOptions extends ForwardThreadEmailOptions { + from?: string; + inboxId?: string; + /** `inline` (default) quotes the message; `attachment` attaches it as `forwarded_message.eml`. */ + mode?: "inline" | "attachment"; +} + +/** What `emails.receiving.reply` / `forward` sent. */ +export interface ReceivedEmailSent { + object: "email"; + id: string; + thread_id: string | null; + inbox_id: string | null; +} + +/** + * The outcome of a send from an inbox: sent now, or held as a draft that waits + * for a person (HTTP 202) because the inbox's rules or `requiresApproval` asked. + */ +export type InboxSendOutcome = { kind: "sent"; email: T } | { kind: "draft"; draft: Draft }; + +// ---- inboxes ------------------------------------------------------------- +export type InboxKind = "agent" | "human" | (string & {}); + +/** Fields after `created_at` are MillionSend extensions. */ +export interface Inbox { + object: "inbox"; + id: string; + name: string; + email_address: string; + /** For an address on the shared domain, the id of the team's `shared: true` row of `domains.list`. */ + domain_id: string; + receiving_address: string | null; + from_name: string | null; + /** Threads with an unread message. */ + unread: number; + /** Unsent drafts. */ + drafts: number; + last_received: string | null; + created_at: string; + kind: InboxKind; + /** The address is on the instance's shared domain. */ + shared: boolean; + expires_at: string | null; + /** Messages in quarantine, not released. */ + quarantined: number; + pending_approval: number; +} + +export interface InboxListItem { + id: string; + name: string | null; + email_address: string; + from_name: string | null; + unread: number; + last_received: string | null; + kind: InboxKind; + shared: boolean; + expires_at: string | null; +} + +/** An inbox's safety rules. */ +export interface InboxSafety { + accept_from: "anyone" | "verified" | "allowlist" | (string & {}); + /** `local@domain` (exact) or `*@domain` (the domain and its subdomains). */ + allowlist: string[]; + /** Owners besides the team members' login addresses; on the shared domain they are not trusted senders. */ + owner_addresses: string[]; + /** Score external and unauthenticated mail for prompt injection (agent inboxes). */ + screening: boolean; + extract_codes: boolean; + send_policy: "reply_only" | "allowlist" | "open" | (string & {}); + require_approval: boolean; + private_codes: boolean; + /** Recipients per UTC day sent without approval; null = no inbox cap. */ + send_cap: number | null; + forward_to: string[]; +} + +export interface InboxSafetyResponse extends InboxSafety { + object: "inbox_safety"; + inbox_id: string; +} + +/** A change to an inbox's safety rules; omitted fields keep their value. */ +export interface InboxSafetyOptions { + acceptFrom?: InboxSafety["accept_from"]; + allowlist?: string[]; + ownerAddresses?: string[]; + screening?: boolean; + extractCodes?: boolean; + /** On the shared domain, `allowlist` answers 422, and so does `open` unless the domain's `sending_mode` is `open`. */ + sendPolicy?: InboxSafety["send_policy"]; + requireApproval?: boolean; + privateCodes?: boolean; + sendCap?: number | null; + /** Team members' login addresses or `ownerAddresses` only; on the shared domain, members only. */ + forwardTo?: string[]; +} + +export interface CreateInboxOptions { + /** An address on a verified domain; omit it for an address on the instance's shared domain. */ + emailAddress?: string; + /** + * Instead of `emailAddress`, the id of a `domains.list` row: the `shared: true` + * row works like omitting both; one of your domains needs `username`. + */ + domainId?: string; + /** + * The part before the @. On the shared domain it is optional (random when + * omitted): 1–64 of `a-z 0-9 . _ -`, starting and ending with a letter or + * digit; reserved names and brand look-alikes answer 422, one in use 409. + * Required with the `domainId` of one of your domains. + */ + username?: string; + /** 1–320 characters. */ + name?: string; + /** Display name for sending; a plain name, not `Name `. Reviewed on the shared domain. */ + fromName?: string; + /** Receive at a generated `receiving_address` on the shared domain instead of the domain's MX. */ + forwarding?: boolean; + /** Defaults to "agent". */ + kind?: "agent" | "human"; + /** + * `"1h"` to `"365d"`; omitted or null, the inbox never expires. A shared-domain + * inbox unused for 90 days is deleted either way. + */ + expiresIn?: string | null; + /** Starting safety rules on top of the kind's defaults. */ + safety?: InboxSafetyOptions; +} + +export interface UpdateInboxOptions { + /** 1–320 characters. */ + name?: string; + /** "" removes it. Reviewed on the shared domain. */ + fromName?: string; + /** A new lifetime counted from now, `"1h"` to `"365d"`; null never expires. */ + expiresIn?: string | null; +} + +export interface ListInboxesOptions extends ListOptions { + kind?: InboxKind; +} + +export interface RemoveInboxResponse { + object: "inbox"; + id: string; + deleted: true; +} + +export interface LatestCodeOptions { + /** A sender address, or a domain covering its subdomains. */ + from?: string; + /** Long-poll up to this many seconds (0–50) for a code to arrive. */ + wait?: number; + /** Oldest message to consider, in seconds since delivery or release (60–86400; the API defaults to 600). */ + maxAge?: number; + /** + * ISO 8601 with an offset: only mail received after this instant. Pass the + * previous code's `received_at` so a second sign-in waits for its own code. + */ + since?: string; +} + +export interface VerificationCode { + object: "verification_code"; + /** `timeout` only when `wait` ran out with nothing new. */ + status: "found" | "none" | "ambiguous" | "timeout" | (string & {}); + kind: "otp" | "link" | null; + code: string | null; + url: string | null; + link_domain_matches_sender: boolean | null; + email_id: string | null; + from: string | null; + received_at: string | null; + sender_trust: SenderTrust | null; +} + +export interface QuarantinedEmail { + id: string; + /** Null for a catch-all copy held for a virus, which belongs to no inbox. */ + thread_id: string | null; + from: string; + subject: string | null; + received_at: string; + quarantine_reason: QuarantineReason; + sender_trust: SenderTrust; + injection_score: number | null; + phishing_score: number | null; + virus_signature: string | null; +} + +export interface ReleaseQuarantinedOptions { + /** Also add the sender to the inbox's allowlist. */ + trustSender?: boolean; +} + +export interface QuarantineRelease { + object: "inbox_release"; + inbox_id: string; + email_id: string; + released_at: string; + /** The address added to the allowlist, or null. */ + trusted_sender: string | null; +} + +// ---- inbox labels -------------------------------------------------------- +export interface ThreadLabel { + id: string; + name: string; + /** `#RRGGBB`, uppercase. */ + color: string; +} + +export interface Label extends ThreadLabel { + created_at: string; +} + +/** Labels are not paginated: every label of the inbox, oldest first. */ +export interface LabelList { + object: "list"; + data: Label[]; +} + +/** What creating or updating a label answers: the whole label. */ +export interface LabelAck extends Label { + object: "inbox_label"; +} + +export interface CreateLabelOptions { + name: string; + /** `#RRGGBB`; random when omitted. */ + color?: string; +} + +export interface UpdateLabelOptions { + name?: string; + color?: string; +} + +export interface RemoveLabelResponse { + object: "inbox_label"; + id: string; + deleted: true; +} + +// ---- inbox threads ------------------------------------------------------- +export type ThreadFolder = "inbox" | "archive" | "spam" | "sent" | "trash"; +/** `sent` is assigned by the API and cannot be set. */ +export type MoveThreadFolder = "inbox" | "archive" | "spam" | "trash"; + +export interface ThreadSummary { + object: "inbox_thread"; + id: string; + subject: string | null; + folder: ThreadFolder; + labels: ThreadLabel[]; + read: boolean; + /** MillionSend extension: held messages (owner views; 0 for agent views). */ + quarantined: number; +} + +export interface ThreadListItem { + id: string; + subject: string | null; + /** The latest inbound sender (`Name
`), or the inbox address for outbound-only threads. */ + from: string | null; + to: string[]; + cc: string[]; + bcc: string[]; + labels: ThreadLabel[]; + message_count: number; + has_attachment: boolean; + has_draft: boolean; + read: boolean; + /** With `includeQuarantined` in owner views: messages in quarantine, not released. */ + quarantined?: number; + /** Last activity; the sort key. */ + received_at: string; + folder: ThreadFolder; +} + +export interface ThreadSearchResult extends ThreadListItem { + matched_email_id: string | null; + /** Only the keys that matched, each a list of fragments with `**match**` markers. */ + highlights: Partial>; +} + +export interface GetThreadEmailOptions { + /** Owner views: also read a held message (full-access team keys only). */ + includeQuarantined?: boolean; +} + +export interface ListThreadEmailsOptions extends ListOptions, GetThreadEmailOptions {} + +export interface ListThreadsOptions extends ListOptions { + /** Defaults to `inbox` (`inbox`, `archive`, `sent` with `labels`). */ + folders?: ThreadFolder[]; + /** Label ids, any-match (up to 50). */ + labels?: string[]; + read?: boolean; + /** Owner views: also list threads whose messages are all held. */ + includeQuarantined?: boolean; +} + +export interface SearchThreadsOptions extends ListThreadsOptions { + /** Every word must match subject, sender or recipients; `"phrase"`, `-word`. */ + query?: string; + /** Substrings of the address or name, any-match (up to 20 each). */ + from?: string[]; + to?: string[]; + cc?: string[]; + bcc?: string[]; + hasAttachment?: boolean; + /** `YYYY-MM-DD` (the whole day) or ISO 8601. */ + startDate?: string; + endDate?: string; +} + +export interface UpdateThreadOptions { + /** Marks every message. */ + read?: boolean; + folder?: MoveThreadFolder; + /** Applies a label; labels cannot be removed through the API. */ + labelId?: string; +} + +export interface RemoveThreadResponse { + object: "inbox_thread"; + id: string; + /** False when the thread was already in trash. */ + deleted: boolean; +} + +export interface ThreadEmailAttachment { + /** A UUID on received mail; the position ("0", "1", …) on mail the inbox sent. */ + id: string; + filename: string | null; + size: number | null; +} + +/** Fields after `received_at` are MillionSend extensions. */ +export interface ThreadEmail { + id: string; + direction: "inbound" | "outbound"; + /** `Name
` when the sender has a display name. */ + from: string; + to: string[]; + cc: string[]; + bcc: string[]; + reply_to: string[]; + subject: string | null; + message_id: string | null; + html: string | null; + text: string | null; + attachments: ThreadEmailAttachment[]; + read: boolean; + received_at: string; + /** Inbound only. */ + extracted_text?: string | null; + screening?: Screening; + spam_verdict?: SpamVerdict | null; + virus_verdict?: VirusVerdict | null; + /** Outbound only: the email's last delivery event. */ + status?: string; +} + +export interface SentEmail extends ThreadEmail { + email_id: string; + thread_id: string | null; + inbox_id: string | null; +} + +// ---- inbox drafts -------------------------------------------------------- +export type DraftStatus = + | "draft" + | "pending_approval" + | "sent" + | "rejected" + | "expired" + | (string & {}); + +/** Fields after `updated_at` are MillionSend extensions. */ +export interface Draft { + object: "inbox_draft"; + id: string; + type: "standalone" | "reply"; + to: string[] | null; + cc: string[]; + bcc: string[]; + subject: string | null; + html: string | null; + text: string | null; + thread_id: string | null; + reply_to_email_id: string | null; + /** Set once sent. */ + email_id: string | null; + created_at: string; + updated_at: string; + status: DraftStatus; + /** Why it waits for a person, e.g. `send_policy`, `ramp`, `approval_required`, `reply_to_redirect`. */ + approval_reasons: string[]; + forward: boolean; + decided_by: string | null; + decided_at: string | null; + expires_at: string | null; +} + +export interface DraftListItem { + id: string; + type: "standalone" | "reply"; + to: string[] | null; + cc: string[]; + bcc: string[]; + subject: string | null; + snippet: string | null; + thread_id: string | null; + reply_to_email_id: string | null; + updated_at: string; + status: DraftStatus; + approval_reasons: string[]; +} + +export interface UpdateDraftOptions { + /** null clears. */ + to?: Recipients | null; + /** null or [] clears. */ + cc?: Recipients | null; + bcc?: Recipients | null; + subject?: string | null; + /** Setting `html` or `text` replaces the whole body; the part you omit becomes null. */ + html?: string | null; + text?: string | null; +} + +export interface CreateDraftOptions extends UpdateDraftOptions { + /** A reply draft names both the thread and the email it answers, or neither. */ + threadId?: string; + replyToEmailId?: string; +} + +export interface RemoveDraftResponse { + object: "inbox_draft"; + id: string; + deleted: true; +} + +/** What `drafts.send` sent. */ +export interface SentDraft { + object: "inbox_draft"; + id: string; + thread_id: string | null; + email_id: string; +} diff --git a/test/client.test.ts b/test/client.test.ts index f66853f..09aae05 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -107,6 +107,34 @@ describe("request wiring", () => { expect(fetchImpl).toHaveBeenCalledOnce(); }); + it("stretches the deadline past a long-poll `wait`", async () => { + // Answers after 100 ms unless the request is aborted first. + const fetchImpl = vi.fn( + (_url: string | URL, init?: RequestInit) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve(new Response('{"object":"list","data":[],"has_more":false}')), + 100, + ); + init?.signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(init.signal?.reason); + }); + }), + ); + const ms = new MillionSend("ms_test", { + baseUrl: "https://api.test", + fetch: fetchImpl as unknown as typeof fetch, + timeoutMs: 20, + }); + + const plain = await ms.emails.receiving.list(); + expect(plain.error?.statusCode).toBeNull(); + const polled = await ms.emails.receiving.list({ wait: 1 }); + expect(polled.error).toBeNull(); + expect(polled.data?.data).toEqual([]); + }); + it("maps camelCase inputs to the snake_case wire and omits undefined", async () => { const { ms, calls } = makeClient(); await ms.emails.send({ @@ -159,7 +187,12 @@ describe("request wiring", () => { }); const res = await ms.emails.send({ from: "a@x.dev", to: "b@x.dev", subject: "s", text: "t" }); expect(res.data).toBeNull(); - expect(res.error).toEqual({ statusCode: 422, name: "validation_error", message: "bad" }); + expect(res.error).toEqual({ + statusCode: 422, + name: "validation_error", + message: "bad", + retryAfter: null, + }); }); it("passes the all_recipients_suppressed 422 through by name", async () => { @@ -191,6 +224,28 @@ describe("request wiring", () => { expect(res.error?.message).toMatch(/ECONNREFUSED/); }); + it("never sends a path an empty, '.' or '..' id would turn into another resource's", async () => { + const { ms, calls } = makeClient(); + for (const res of [ + await ms.inboxes.labels.remove("inbox_1", ".."), + await ms.inboxes.threads.remove("inbox_1", "."), + await ms.emails.get(""), + ]) { + expect(res).toEqual({ + data: null, + error: { + name: "validation_error", + message: 'An id in the request path is empty, "." or "..".', + statusCode: null, + retryAfter: null, + }, + }); + } + expect(calls).toEqual([]); + await ms.inboxes.labels.remove("inbox_1", "..."); + expect(calls[0]?.url).toBe("https://api.test/inboxes/inbox_1/labels/..."); + }); + it("falls back to a generic error when the body is not the canonical shape", async () => { const { ms } = makeClient({ status: 500, body: "gateway boom" }); const res = await ms.emails.get("e1"); @@ -198,6 +253,45 @@ describe("request wiring", () => { name: "application_error", message: "Request failed with status 500", statusCode: 500, + retryAfter: null, }); }); + + it("reads retry-after into retryAfter: whole seconds, else null", async () => { + const body = { statusCode: 503, name: "name_review_unavailable", message: "try again shortly" }; + const { ms } = makeClient({ status: 503, body, headers: { "retry-after": "300" } }); + expect((await ms.inboxes.create()).error).toEqual({ ...body, retryAfter: 300 }); + + const { ms: none } = makeClient({ status: 503, body }); + expect((await none.inboxes.create()).error?.retryAfter).toBeNull(); + + const date = { "retry-after": "Fri, 16 Oct 2026 12:00:00 GMT" }; + const { ms: dated } = makeClient({ status: 503, body, headers: date }); + expect((await dated.inboxes.create()).error?.retryAfter).toBeNull(); + }); + + it("a 404 from an unknown route is not_found as JSON, application_error as plain text", async () => { + const fetchImpl = vi.fn( + async () => + new Response("404 Not Found", { status: 404, headers: { "content-type": "text/plain" } }), + ); + const ms = new MillionSend("ms_test", { + baseUrl: "https://api.test", + fetch: fetchImpl as unknown as typeof fetch, + }); + expect((await ms.inboxes.list()).error).toEqual({ + name: "application_error", + message: "Request failed with status 404", + statusCode: 404, + retryAfter: null, + }); + + const body = { + statusCode: 404, + name: "not_found", + message: "The requested endpoint does not exist.", + }; + const { ms: json } = makeClient({ status: 404, body }); + expect((await json.inboxes.list()).error).toEqual({ ...body, retryAfter: null }); + }); }); diff --git a/test/e2e.test.ts b/test/e2e.test.ts index d97f034..2b58ddb 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -22,7 +22,8 @@ run("e2e: contacts lifecycle", () => { const ms = apiKey ? new MillionSend(apiKey) : (null as unknown as MillionSend); it("creates, reads, updates and deletes a contact", async () => { - const email = `sdk-e2e-${Date.now()}@example.com`; + // The API refuses contacts on domains that can never receive mail, example.com included. + const email = `success+sdk-e2e-${Date.now()}@simulator.amazonses.com`; const created = await ms.contacts.create({ email, firstName: "Ada" }); expect(created.error).toBeNull(); diff --git a/test/helper.ts b/test/helper.ts index d585ca4..d2739af 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -9,7 +9,9 @@ export interface Captured { } /** A MillionSend client backed by a mock fetch that records every request. */ -export function makeClient(response: { status?: number; body?: unknown } = {}) { +export function makeClient( + response: { status?: number; body?: unknown; headers?: Record } = {}, +) { const calls: Captured[] = []; const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => { const headers = { ...((init?.headers as Record | undefined) ?? {}) }; @@ -22,7 +24,7 @@ export function makeClient(response: { status?: number; body?: unknown } = {}) { }); return new Response(JSON.stringify(response.body ?? { id: "id_1" }), { status: response.status ?? 200, - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...response.headers }, }); }); const ms = new MillionSend("ms_test", { diff --git a/test/resources.test.ts b/test/resources.test.ts index a23be43..866f8c7 100644 --- a/test/resources.test.ts +++ b/test/resources.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { ScoreBand } from "../src/index.js"; +import type { DomainSendingMode, ScoreBand } from "../src/index.js"; import { makeClient, pathOf } from "./helper.js"; describe("emails", () => { @@ -61,7 +61,12 @@ describe("insights", () => { }); const res = await ms.emails.getInsights("missing"); expect(res.data).toBeNull(); - expect(res.error).toEqual({ statusCode: 404, name: "not_found", message: "Email not found" }); + expect(res.error).toEqual({ + statusCode: 404, + name: "not_found", + message: "Email not found", + retryAfter: null, + }); }); it("tolerates unknown future band/severity/status values (open sets)", async () => { @@ -494,7 +499,7 @@ describe("contacts: segments, topics and batch", () => { const response = { data: [{ object: "contact", index: 0, id: "c1", status: "created" }], counts: { created: 1, updated: 0, skipped: 0, failed: 1 }, - errors: [{ index: 1, message: "email: invalid" }], + errors: [{ index: 1, statusCode: 422, name: "validation_error", message: "email: invalid" }], }; const { ms, calls } = makeClient({ body: response }); const res = await ms.contacts.batch.create( @@ -515,6 +520,7 @@ describe("contacts: segments, topics and batch", () => { ]); expect(res.data).toEqual(response); expect(res.data?.counts.failed).toBe(1); + expect(res.data?.errors?.[0]?.name).toBe("validation_error"); }); it("batch.create without options sends no query and no header", async () => { @@ -910,3 +916,815 @@ describe("usage", () => { expect(res.data).toEqual(fixture); }); }); + +// ---- receiving and inboxes ------------------------------------------------ +const INBOX_ID = "6f1c9a1e-0000-4000-8000-0000000000a1"; +const THREAD_ID = "6f1c9a1e-0000-4000-8000-0000000000b1"; +const EMAIL_ID = "6f1c9a1e-0000-4000-8000-0000000000c1"; +const DRAFT_ID = "6f1c9a1e-0000-4000-8000-0000000000d1"; +/** The team's shared subdomain, and one of its own domains. */ +const DOMAIN_ID = "6f1c9a1e-0000-4000-8000-0000000000e1"; +const OWN_DOMAIN_ID = "6f1c9a1e-0000-4000-8000-0000000000e2"; + +const INBOX = { + object: "inbox", + id: INBOX_ID, + name: "Signup bot", + email_address: "signup-bot-7f3k@acme.agents.example.com", + domain_id: DOMAIN_ID, + receiving_address: null, + from_name: null, + unread: 1, + drafts: 0, + last_received: "2026-10-12T10:05:00.000Z", + created_at: "2026-10-12T10:00:00.000Z", + kind: "agent", + shared: true, + expires_at: null, + quarantined: 0, + pending_approval: 0, +}; + +const DRAFT = { + object: "inbox_draft", + id: DRAFT_ID, + type: "reply", + to: ["success@simulator.amazonses.com"], + cc: [], + bcc: [], + subject: "Re: Confirm your email", + html: null, + text: "Thanks, confirmed.", + thread_id: THREAD_ID, + reply_to_email_id: EMAIL_ID, + email_id: null, + created_at: "2026-10-12T10:06:00.000Z", + updated_at: "2026-10-12T10:06:00.000Z", + status: "pending_approval", + approval_reasons: ["send_policy"], + forward: false, + decided_by: null, + decided_at: null, + expires_at: "2026-10-19T10:06:00.000Z", +}; + +const SENT_EMAIL = { + id: EMAIL_ID, + direction: "outbound", + from: "signup-bot-7f3k@acme.agents.example.com", + to: ["success@simulator.amazonses.com"], + cc: [], + bcc: [], + reply_to: [], + subject: "Re: Confirm your email", + message_id: null, + html: null, + text: "Thanks, confirmed.", + attachments: [], + read: true, + received_at: "2026-10-12T10:06:00.000Z", + email_id: EMAIL_ID, + thread_id: THREAD_ID, + inbox_id: INBOX_ID, + status: "queued", +}; + +describe("emails.receiving", () => { + it("list maps every filter to the wire; flags read as true/false", async () => { + const { ms, calls } = makeClient({ + body: { object: "list", has_more: false, data: [], held: [] }, + }); + const res = await ms.emails.receiving.list({ + limit: 10, + before: EMAIL_ID, + wait: 30, + since: "2026-10-12T10:00:00Z", + inboxId: INBOX_ID, + to: "bot@acme.dev", + from: "example.com", + subjectContains: "code", + unread: false, + includeQuarantined: true, + quarantined: false, + }); + const url = new URL(calls[0]?.url ?? ""); + expect(calls[0]?.method).toBe("GET"); + expect(url.pathname).toBe("/emails/receiving"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + limit: "10", + before: EMAIL_ID, + wait: "30", + since: "2026-10-12T10:00:00Z", + inbox_id: INBOX_ID, + to: "bot@acme.dev", + from: "example.com", + subject_contains: "code", + unread: "false", + include_quarantined: "true", + }); + expect(res.data?.held).toEqual([]); + + await ms.emails.receiving.list(); + expect(pathOf(calls[1])).toBe("/emails/receiving"); + }); + + it("unread goes out without inboxId, for an inbox-scoped key", async () => { + const { ms, calls } = makeClient({ body: { object: "list", has_more: false, data: [] } }); + await ms.emails.receiving.list({ unread: false }); + expect(pathOf(calls[0])).toBe("/emails/receiving?unread=false"); + }); + + it("an unknown inboxId is a 404 not_found, even with wait", async () => { + const notFound = { statusCode: 404, name: "not_found", message: "Inbox not found." }; + const { ms } = makeClient({ status: 404, body: notFound }); + const res = await ms.emails.receiving.list({ inboxId: INBOX_ID, wait: 30 }); + expect(res).toEqual({ data: null, error: { ...notFound, retryAfter: null } }); + }); + + it("get passes html_format and keeps the extension fields", async () => { + const detail = { + object: "email", + id: EMAIL_ID, + extracted_text: "Your verification code is 482913", + extracted: { + kinds: ["code"], + code: "482913", + link: null, + link_domain_matches_sender: null, + ambiguous: false, + }, + screening: { + verdict: "clean", + sender_trust: "external", + quarantine_reason: null, + reasons: [], + injection_score: 2, + phishing_score: null, + hidden_text_removed: false, + }, + future_field: { nested: true }, + }; + const { ms, calls } = makeClient({ body: detail }); + const res = await ms.emails.receiving.get(EMAIL_ID, { htmlFormat: "cid" }); + expect(calls[0]).toMatchObject({ + method: "GET", + url: `https://api.test/emails/receiving/${EMAIL_ID}?html_format=cid`, + }); + expect(res.data).toEqual(detail); + expect(res.data?.extracted?.code).toBe("482913"); + + await ms.emails.receiving.get(EMAIL_ID); + expect(pathOf(calls[1])).toBe(`/emails/receiving/${EMAIL_ID}`); + }); + + it("reply sends the snake_case body with an Idempotency-Key and reads 200 as sent", async () => { + const sent = { object: "email", id: EMAIL_ID, thread_id: THREAD_ID, inbox_id: INBOX_ID }; + const { ms, calls } = makeClient({ body: sent }); + const res = await ms.emails.receiving.reply( + EMAIL_ID, + { + text: "Thanks, confirmed.", + html: "

Thanks

", + subject: "Re: hi", + cc: "ops@acme.dev", + bcc: ["audit@acme.dev"], + replyAll: true, + requiresApproval: false, + from: "bot@acme.dev", + inboxId: INBOX_ID, + }, + { idempotencyKey: "reply-1" }, + ); + expect(calls[0]).toMatchObject({ + method: "POST", + url: `https://api.test/emails/receiving/${EMAIL_ID}/reply`, + }); + expect(calls[0]?.body).toEqual({ + text: "Thanks, confirmed.", + html: "

Thanks

", + subject: "Re: hi", + cc: "ops@acme.dev", + bcc: ["audit@acme.dev"], + reply_all: true, + requires_approval: false, + from: "bot@acme.dev", + inbox_id: INBOX_ID, + }); + expect(calls[0]?.headers["Idempotency-Key"]).toBe("reply-1"); + expect(res).toEqual({ data: { kind: "sent", email: sent }, error: null }); + }); + + it("a 202 answer is the draft held for approval", async () => { + const { ms } = makeClient({ status: 202, body: DRAFT }); + const res = await ms.emails.receiving.reply(EMAIL_ID, { text: "hi" }); + expect(res.error).toBeNull(); + if (res.data?.kind !== "draft") throw new Error("expected a draft"); + expect(res.data.draft).toEqual(DRAFT); + expect(res.data.draft.approval_reasons).toEqual(["send_policy"]); + }); + + it("forward sends to, mode and the note; errors pass through", async () => { + const { ms, calls } = makeClient({ + body: { object: "email", id: EMAIL_ID, thread_id: null, inbox_id: null }, + }); + const res = await ms.emails.receiving.forward( + EMAIL_ID, + { to: ["ops@acme.dev"], text: "FYI", mode: "attachment", requiresApproval: true }, + { idempotencyKey: "fwd-1" }, + ); + expect(calls[0]).toMatchObject({ + method: "POST", + url: `https://api.test/emails/receiving/${EMAIL_ID}/forward`, + }); + expect(calls[0]?.body).toEqual({ + to: ["ops@acme.dev"], + text: "FYI", + mode: "attachment", + requires_approval: true, + }); + expect(calls[0]?.headers["Idempotency-Key"]).toBe("fwd-1"); + expect(res.data?.kind).toBe("sent"); + + const quarantined = { + statusCode: 403, + name: "email_quarantined", + message: "This email is quarantined; a person can release it in the dashboard", + }; + const { ms: refused } = makeClient({ status: 403, body: quarantined }); + const err = await refused.emails.receiving.forward(EMAIL_ID, { to: "ops@acme.dev" }); + expect(err).toEqual({ data: null, error: { ...quarantined, retryAfter: null } }); + }); + + it("remove erases; attachments list and get", async () => { + const { ms, calls } = makeClient(); + await ms.emails.receiving.remove(EMAIL_ID); + expect(calls[0]).toMatchObject({ + method: "DELETE", + url: `https://api.test/emails/receiving/${EMAIL_ID}`, + }); + await ms.emails.receiving.attachments.list(EMAIL_ID, { limit: 5 }); + expect(calls[1]).toMatchObject({ + method: "GET", + url: `https://api.test/emails/receiving/${EMAIL_ID}/attachments?limit=5`, + }); + await ms.emails.receiving.attachments.get(EMAIL_ID, "att-1"); + expect(calls[2]).toMatchObject({ + method: "GET", + url: `https://api.test/emails/receiving/${EMAIL_ID}/attachments/att-1`, + }); + }); +}); + +describe("inboxes", () => { + it("create maps every field, safety included; no argument sends {}", async () => { + const { ms, calls } = makeClient({ status: 201, body: INBOX }); + const res = await ms.inboxes.create({ + emailAddress: "support@acme.dev", + name: "Support", + fromName: "Acme Support", + forwarding: false, + kind: "human", + expiresIn: null, + safety: { + acceptFrom: "allowlist", + allowlist: ["*@acme.dev"], + ownerAddresses: ["ops@acme.dev"], + screening: false, + extractCodes: true, + sendPolicy: "allowlist", + requireApproval: true, + privateCodes: false, + sendCap: null, + forwardTo: ["ops@acme.dev"], + }, + }); + expect(calls[0]).toMatchObject({ method: "POST", url: "https://api.test/inboxes" }); + expect(calls[0]?.body).toEqual({ + email_address: "support@acme.dev", + name: "Support", + from_name: "Acme Support", + forwarding: false, + kind: "human", + expires_in: null, + safety: { + accept_from: "allowlist", + allowlist: ["*@acme.dev"], + owner_addresses: ["ops@acme.dev"], + screening: false, + extract_codes: true, + send_policy: "allowlist", + require_approval: true, + private_codes: false, + send_cap: null, + forward_to: ["ops@acme.dev"], + }, + }); + expect(res.data).toEqual(INBOX); + + await ms.inboxes.create(); + expect(calls[1]?.body).toEqual({}); + }); + + it("create sends username and domain_id only when given", async () => { + const { ms, calls } = makeClient({ status: 201, body: INBOX }); + await ms.inboxes.create({ username: "ada" }); + expect(calls[0]?.body).toEqual({ username: "ada" }); + await ms.inboxes.create({ domainId: DOMAIN_ID }); + expect(calls[1]?.body).toEqual({ domain_id: DOMAIN_ID }); + await ms.inboxes.create({ domainId: OWN_DOMAIN_ID, username: "support" }); + expect(calls[2]?.body).toEqual({ domain_id: OWN_DOMAIN_ID, username: "support" }); + }); + + it("a name review that can't run is a 503 name_review_unavailable with retryAfter", async () => { + const unavailable = { + statusCode: 503, + name: "name_review_unavailable", + message: "This name couldn't be reviewed right now: try again shortly", + }; + const { ms } = makeClient({ status: 503, body: unavailable, headers: { "retry-after": "60" } }); + expect(await ms.inboxes.create({ username: "ada" })).toEqual({ + data: null, + error: { ...unavailable, retryAfter: 60 }, + }); + }); + + it("list, get by address, update and remove", async () => { + const { ms, calls } = makeClient({ body: INBOX }); + await ms.inboxes.list({ limit: 2, after: INBOX_ID, kind: "agent" }); + expect(calls[0]).toMatchObject({ + method: "GET", + url: `https://api.test/inboxes?limit=2&after=${INBOX_ID}&kind=agent`, + }); + await ms.inboxes.get("Support@acme.dev"); + expect(calls[1]).toMatchObject({ + method: "GET", + url: "https://api.test/inboxes/Support%40acme.dev", + }); + await ms.inboxes.update(INBOX_ID, { name: "Bot", fromName: "", expiresIn: "7d" }); + expect(calls[2]).toMatchObject({ + method: "PATCH", + url: `https://api.test/inboxes/${INBOX_ID}`, + }); + expect(calls[2]?.body).toEqual({ name: "Bot", from_name: "", expires_in: "7d" }); + await ms.inboxes.remove(INBOX_ID); + expect(calls[3]).toMatchObject({ + method: "DELETE", + url: `https://api.test/inboxes/${INBOX_ID}`, + }); + }); + + it("tolerates unknown keys and enum values", async () => { + const future = { ...INBOX, kind: "team", brand_new: [1, 2] }; + const { ms } = makeClient({ body: future }); + const res = await ms.inboxes.get(INBOX_ID); + expect(res.data).toEqual(future); + expect(res.data?.kind).toBe("team"); + }); + + it("safety get and partial update", async () => { + const { ms, calls } = makeClient({ body: { object: "inbox_safety", inbox_id: INBOX_ID } }); + await ms.inboxes.safety.get(INBOX_ID); + expect(calls[0]).toMatchObject({ + method: "GET", + url: `https://api.test/inboxes/${INBOX_ID}/safety`, + }); + await ms.inboxes.safety.update(INBOX_ID, { sendCap: 25, acceptFrom: "verified" }); + expect(calls[1]).toMatchObject({ + method: "PATCH", + url: `https://api.test/inboxes/${INBOX_ID}/safety`, + }); + expect(calls[1]?.body).toEqual({ accept_from: "verified", send_cap: 25 }); + }); + + it("codes.latest maps from, wait, max_age and since", async () => { + const found = { + object: "verification_code", + status: "found", + kind: "otp", + code: "482913", + url: null, + link_domain_matches_sender: null, + email_id: EMAIL_ID, + from: "hello@example.com", + received_at: "2026-10-12T10:05:00.000Z", + sender_trust: "external", + }; + const { ms, calls } = makeClient({ body: found }); + const res = await ms.inboxes.codes.latest(INBOX_ID, { + from: "example.com", + wait: 20, + maxAge: 600, + }); + expect(calls[0]).toMatchObject({ + method: "GET", + url: `https://api.test/inboxes/${INBOX_ID}/codes/latest?from=example.com&wait=20&max_age=600`, + }); + expect(res.data).toEqual(found); + await ms.inboxes.codes.latest(INBOX_ID); + expect(pathOf(calls[1])).toBe(`/inboxes/${INBOX_ID}/codes/latest`); + await ms.inboxes.codes.latest(INBOX_ID, { since: "2026-10-16T12:00:00.000Z" }); + const url = new URL(calls[2]?.url ?? ""); + expect(Object.fromEntries(url.searchParams)).toEqual({ since: "2026-10-16T12:00:00.000Z" }); + }); + + it("quarantine list and release", async () => { + const release = { + object: "inbox_release", + inbox_id: INBOX_ID, + email_id: EMAIL_ID, + released_at: "2026-10-12T10:07:00.000Z", + trusted_sender: "hello@example.com", + }; + const { ms, calls } = makeClient({ body: release }); + await ms.inboxes.quarantine.list(INBOX_ID, { limit: 3 }); + expect(calls[0]).toMatchObject({ + method: "GET", + url: `https://api.test/inboxes/${INBOX_ID}/quarantine?limit=3`, + }); + const res = await ms.inboxes.quarantine.release(INBOX_ID, EMAIL_ID, { trustSender: true }); + expect(calls[1]).toMatchObject({ + method: "POST", + url: `https://api.test/inboxes/${INBOX_ID}/quarantine/${EMAIL_ID}/release`, + }); + expect(calls[1]?.body).toEqual({ trust_sender: true }); + expect(res.data).toEqual(release); + await ms.inboxes.quarantine.release(INBOX_ID, EMAIL_ID); + expect(calls[2]?.body).toEqual({}); + }); + + it("labels list, create, update and remove", async () => { + const { ms, calls } = makeClient({ body: { object: "inbox_label", id: "l1" } }); + await ms.inboxes.labels.list(INBOX_ID); + expect(calls[0]).toMatchObject({ + method: "GET", + url: `https://api.test/inboxes/${INBOX_ID}/labels`, + }); + await ms.inboxes.labels.create(INBOX_ID, { name: "Billing", color: "#FF8800" }); + expect(calls[1]).toMatchObject({ + method: "POST", + url: `https://api.test/inboxes/${INBOX_ID}/labels`, + }); + expect(calls[1]?.body).toEqual({ name: "Billing", color: "#FF8800" }); + await ms.inboxes.labels.update(INBOX_ID, "l1", { name: "Invoices" }); + expect(calls[2]).toMatchObject({ + method: "PATCH", + url: `https://api.test/inboxes/${INBOX_ID}/labels/l1`, + }); + expect(calls[2]?.body).toEqual({ name: "Invoices" }); + await ms.inboxes.labels.remove(INBOX_ID, "l1"); + expect(calls[3]).toMatchObject({ + method: "DELETE", + url: `https://api.test/inboxes/${INBOX_ID}/labels/l1`, + }); + }); + + it("threads list and search send lists as CSV and drop empty ones", async () => { + const { ms, calls } = makeClient({ body: { object: "list", has_more: false, data: [] } }); + await ms.inboxes.threads.list(INBOX_ID, { + folders: ["inbox", "archive"], + labels: [], + read: false, + includeQuarantined: true, + limit: 20, + }); + const listed = new URL(calls[0]?.url ?? ""); + expect(listed.pathname).toBe(`/inboxes/${INBOX_ID}/threads`); + expect(Object.fromEntries(listed.searchParams)).toEqual({ + limit: "20", + folders: "inbox,archive", + read: "false", + include_quarantined: "true", + }); + + await ms.inboxes.threads.search(INBOX_ID, { + query: '"invoice 42" -spam', + from: ["ada", "example.com"], + to: ["bot@acme.dev"], + cc: ["ops"], + bcc: ["audit"], + hasAttachment: true, + startDate: "2026-10-01", + endDate: "2026-10-12", + labels: ["l1", "l2"], + }); + const searched = new URL(calls[1]?.url ?? ""); + expect(searched.pathname).toBe(`/inboxes/${INBOX_ID}/threads/search`); + expect(Object.fromEntries(searched.searchParams)).toEqual({ + labels: "l1,l2", + query: '"invoice 42" -spam', + from: "ada,example.com", + to: "bot@acme.dev", + cc: "ops", + bcc: "audit", + has_attachment: "true", + start_date: "2026-10-01", + end_date: "2026-10-12", + }); + + await ms.inboxes.threads.list(INBOX_ID); + expect(pathOf(calls[2])).toBe(`/inboxes/${INBOX_ID}/threads`); + }); + + it("thread items carry quarantined only when the API sends it", async () => { + const item = { + id: THREAD_ID, + subject: "Confirm your email", + from: "Acme ", + to: ["signup-bot-7f3k@acme.agents.example.com"], + cc: [], + bcc: [], + labels: [], + message_count: 3, + has_attachment: false, + has_draft: false, + read: false, + received_at: "2026-10-12T10:05:00.000Z", + folder: "inbox", + }; + const list = { object: "list", has_more: false, data: [{ ...item, quarantined: 2 }, item] }; + const { ms } = makeClient({ body: list }); + const res = await ms.inboxes.threads.list(INBOX_ID, { includeQuarantined: true }); + expect(res.data).toEqual(list); + expect(res.data?.data[0]?.quarantined).toBe(2); + expect(res.data?.data[1]?.quarantined).toBeUndefined(); + }); + + it("threads get, update and remove", async () => { + const { ms, calls } = makeClient({ body: { object: "inbox_thread", id: THREAD_ID } }); + await ms.inboxes.threads.get(INBOX_ID, THREAD_ID); + expect(calls[0]).toMatchObject({ + method: "GET", + url: `https://api.test/inboxes/${INBOX_ID}/threads/${THREAD_ID}`, + }); + await ms.inboxes.threads.update(INBOX_ID, THREAD_ID, { + read: true, + folder: "archive", + labelId: "l1", + }); + expect(calls[1]).toMatchObject({ + method: "PATCH", + url: `https://api.test/inboxes/${INBOX_ID}/threads/${THREAD_ID}`, + }); + expect(calls[1]?.body).toEqual({ read: true, folder: "archive", label_id: "l1" }); + await ms.inboxes.threads.remove(INBOX_ID, THREAD_ID); + expect(calls[2]).toMatchObject({ + method: "DELETE", + url: `https://api.test/inboxes/${INBOX_ID}/threads/${THREAD_ID}`, + }); + }); + + it("thread emails list, get, reply and forward", async () => { + const base = `https://api.test/inboxes/${INBOX_ID}/threads/${THREAD_ID}/emails`; + const { ms, calls } = makeClient({ body: SENT_EMAIL }); + await ms.inboxes.threads.emails.list(INBOX_ID, THREAD_ID, { after: EMAIL_ID }); + expect(calls[0]).toMatchObject({ method: "GET", url: `${base}?after=${EMAIL_ID}` }); + await ms.inboxes.threads.emails.get(INBOX_ID, THREAD_ID, EMAIL_ID); + expect(calls[1]).toMatchObject({ method: "GET", url: `${base}/${EMAIL_ID}` }); + + const replied = await ms.inboxes.threads.emails.reply( + INBOX_ID, + THREAD_ID, + EMAIL_ID, + { text: "Thanks, confirmed.", replyAll: true, requiresApproval: true }, + { idempotencyKey: "r-1" }, + ); + expect(calls[2]).toMatchObject({ method: "POST", url: `${base}/${EMAIL_ID}/reply` }); + expect(calls[2]?.body).toEqual({ + text: "Thanks, confirmed.", + reply_all: true, + requires_approval: true, + }); + expect(calls[2]?.headers["Idempotency-Key"]).toBe("r-1"); + expect(replied.data).toEqual({ kind: "sent", email: SENT_EMAIL }); + + await ms.inboxes.threads.emails.forward( + INBOX_ID, + THREAD_ID, + EMAIL_ID, + { to: "ops@acme.dev", subject: "Fwd: hi" }, + { idempotencyKey: "f-1" }, + ); + expect(calls[3]).toMatchObject({ method: "POST", url: `${base}/${EMAIL_ID}/forward` }); + expect(calls[3]?.body).toEqual({ to: "ops@acme.dev", subject: "Fwd: hi" }); + expect(calls[3]?.headers["Idempotency-Key"]).toBe("f-1"); + + const { ms: held } = makeClient({ status: 202, body: DRAFT }); + const res = await held.inboxes.threads.emails.forward(INBOX_ID, THREAD_ID, EMAIL_ID, { + to: "success@simulator.amazonses.com", + }); + expect(res.data).toEqual({ kind: "draft", draft: DRAFT }); + }); + + it("thread emails list and get take includeQuarantined", async () => { + const base = `https://api.test/inboxes/${INBOX_ID}/threads/${THREAD_ID}/emails`; + const { ms, calls } = makeClient({ body: SENT_EMAIL }); + await ms.inboxes.threads.emails.list(INBOX_ID, THREAD_ID, { + limit: 2, + includeQuarantined: true, + }); + expect(calls[0]).toMatchObject({ + method: "GET", + url: `${base}?limit=2&include_quarantined=true`, + }); + await ms.inboxes.threads.emails.get(INBOX_ID, THREAD_ID, EMAIL_ID, { + includeQuarantined: true, + }); + expect(calls[1]).toMatchObject({ + method: "GET", + url: `${base}/${EMAIL_ID}?include_quarantined=true`, + }); + }); + + it("drafts list, create, get, update, remove and reject", async () => { + const base = `https://api.test/inboxes/${INBOX_ID}/drafts`; + const { ms, calls } = makeClient({ status: 201, body: DRAFT }); + await ms.inboxes.drafts.list(INBOX_ID, { limit: 5 }); + expect(calls[0]).toMatchObject({ method: "GET", url: `${base}?limit=5` }); + + const created = await ms.inboxes.drafts.create(INBOX_ID, { + to: "success@simulator.amazonses.com", + text: "Thanks, confirmed.", + threadId: THREAD_ID, + replyToEmailId: EMAIL_ID, + }); + expect(calls[1]).toMatchObject({ method: "POST", url: base }); + expect(calls[1]?.body).toEqual({ + to: "success@simulator.amazonses.com", + text: "Thanks, confirmed.", + thread_id: THREAD_ID, + reply_to_email_id: EMAIL_ID, + }); + expect(created.data).toEqual(DRAFT); + + await ms.inboxes.drafts.get(INBOX_ID, DRAFT_ID); + expect(calls[2]).toMatchObject({ method: "GET", url: `${base}/${DRAFT_ID}` }); + await ms.inboxes.drafts.update(INBOX_ID, DRAFT_ID, { + cc: null, + subject: null, + html: "

x

", + }); + expect(calls[3]).toMatchObject({ method: "PATCH", url: `${base}/${DRAFT_ID}` }); + expect(calls[3]?.body).toEqual({ cc: null, subject: null, html: "

x

" }); + await ms.inboxes.drafts.remove(INBOX_ID, DRAFT_ID); + expect(calls[4]).toMatchObject({ method: "DELETE", url: `${base}/${DRAFT_ID}` }); + await ms.inboxes.drafts.reject(INBOX_ID, DRAFT_ID); + expect(calls[5]).toMatchObject({ method: "POST", url: `${base}/${DRAFT_ID}/reject` }); + expect(calls[5]?.body).toBeUndefined(); + }); + + it("drafts.send posts no body with an Idempotency-Key; 200 sent, 202 held", async () => { + const sent = { object: "inbox_draft", id: DRAFT_ID, thread_id: THREAD_ID, email_id: EMAIL_ID }; + const { ms, calls } = makeClient({ body: sent }); + const res = await ms.inboxes.drafts.send(INBOX_ID, DRAFT_ID, { idempotencyKey: "send-1" }); + expect(calls[0]).toMatchObject({ + method: "POST", + url: `https://api.test/inboxes/${INBOX_ID}/drafts/${DRAFT_ID}/send`, + }); + expect(calls[0]?.body).toBeUndefined(); + expect(calls[0]?.headers["Idempotency-Key"]).toBe("send-1"); + expect(res.data).toEqual({ kind: "sent", email: sent }); + + const { ms: held } = makeClient({ status: 202, body: DRAFT }); + expect((await held.inboxes.drafts.send(INBOX_ID, DRAFT_ID)).data).toEqual({ + kind: "draft", + draft: DRAFT, + }); + + const agent = { + statusCode: 403, + name: "approval_required", + message: "This draft waits for a person to approve it", + }; + const { ms: refused } = makeClient({ status: 403, body: agent }); + expect(await refused.inboxes.drafts.send(INBOX_ID, DRAFT_ID)).toEqual({ + data: null, + error: { ...agent, retryAfter: null }, + }); + }); +}); + +describe("inbox fields on existing resources", () => { + it("webhooks carry inbox_ids on create and update", async () => { + const { ms, calls } = makeClient({ body: { object: "webhook", id: "w1" } }); + await ms.webhooks.create({ + endpoint: "https://acme.dev/hooks", + events: ["email.received", "inbox.email.received"], + inboxIds: [INBOX_ID], + }); + expect(calls[0]?.body).toEqual({ + endpoint: "https://acme.dev/hooks", + events: ["email.received", "inbox.email.received"], + inbox_ids: [INBOX_ID], + }); + await ms.webhooks.update("w1", { inboxIds: null }); + expect(calls[1]?.body).toEqual({ inbox_ids: null }); + }); + + it("apiKeys.create restricts a key to an inbox", async () => { + const { ms, calls } = makeClient({ body: { id: "k1", token: "ms_secret" } }); + await ms.apiKeys.create({ name: "agent", permission: "full_access", inboxId: INBOX_ID }); + expect(calls[0]?.body).toEqual({ + name: "agent", + permission: "full_access", + inbox_id: INBOX_ID, + }); + }); + + it("domains.update toggles catch-all", async () => { + const { ms, calls } = makeClient({ body: { object: "domain", receiving_catch_all: false } }); + const res = await ms.domains.update({ id: "d1", receivingCatchAll: false }); + expect(calls[0]?.body).toEqual({ receiving_catch_all: false }); + expect(res.data?.receiving_catch_all).toBe(false); + }); +}); + +describe("domains: the shared row", () => { + const OWN = { + id: OWN_DOMAIN_ID, + name: "acme.dev", + status: "verified", + created_at: "2026-10-14T09:00:00.000Z", + region: "us-east-1", + open_tracking: true, + click_tracking: true, + tracking_subdomain: "links", + capabilities: { sending: "enabled", receiving: "enabled" }, + receiving_catch_all: false, + shared: false, + }; + const SHARED = { + id: DOMAIN_ID, + name: "acme.agents.example.com", + status: "verified", + created_at: "2026-10-12T10:00:00.000Z", + region: "us-east-1", + open_tracking: false, + click_tracking: false, + tracking_subdomain: null, + capabilities: { sending: "enabled", receiving: "enabled" }, + receiving_catch_all: false, + shared: true, + sending_mode: "reply_only", + sending_mode_reason: "plan", + }; + + it("list returns own domains, then the shared row", async () => { + const list = { object: "list", has_more: false, data: [OWN, SHARED] }; + const { ms } = makeClient({ body: list }); + const res = await ms.domains.list(); + expect(res.data).toEqual(list); + const [own, shared] = res.data?.data ?? []; + expect(own?.shared).toBe(false); + expect(own?.sending_mode).toBeUndefined(); + expect(own?.sending_mode_reason).toBeUndefined(); + expect(shared?.shared).toBe(true); + expect(shared?.sending_mode).toBe("reply_only"); + expect(shared?.sending_mode_reason).toBe("plan"); + }); + + it("a domain from a server before v0.14.0 has no shared key, read as false", async () => { + const { shared: _, ...older } = OWN; + const { ms } = makeClient({ body: older }); + const res = await ms.domains.get(OWN_DOMAIN_ID); + expect(res.data).toEqual(older); + expect(res.data?.shared ?? false).toBe(false); + }); + + it("get returns the shared row with no records; open mode has a null reason", async () => { + const detail = { + object: "domain", + ...SHARED, + sending_mode: "open", + sending_mode_reason: null, + records: [], + }; + const { ms, calls } = makeClient({ body: detail }); + const res = await ms.domains.get(DOMAIN_ID); + expect(calls[0]).toMatchObject({ method: "GET", url: `https://api.test/domains/${DOMAIN_ID}` }); + expect(res.data).toEqual(detail); + expect(res.data?.records).toEqual([]); + expect(res.data?.sending_mode_reason).toBeNull(); + }); + + it("tolerates a sending_mode it does not know (open set)", async () => { + const { ms } = makeClient({ body: { ...SHARED, sending_mode: "paused_for_review" } }); + const res = await ms.domains.get(DOMAIN_ID); + expect(res.data?.sending_mode).toBe("paused_for_review"); + const future: DomainSendingMode = "paused_for_review"; + expect(future).toBe("paused_for_review"); + }); + + it("changing the shared row is a 403 forbidden", async () => { + const forbidden = { + statusCode: 403, + name: "forbidden", + message: + "acme.agents.example.com is your team's subdomain of the shared domain: it can't be changed or deleted", + }; + const { ms } = makeClient({ status: 403, body: forbidden }); + expect(await ms.domains.update({ id: DOMAIN_ID, clickTracking: true })).toEqual({ + data: null, + error: { ...forbidden, retryAfter: null }, + }); + }); +});