From 1f36fb7a932c089aa2f57aea627cc34343d730f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Mon, 12 Oct 2026 03:19:53 -0300 Subject: [PATCH 1/5] Receiving and inboxes: emails.receiving and the inboxes tree (0.9.0) - emails.receiving: list (wait long-poll and the inbox, address, subject, unread and quarantine 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, on an HTTP 202, { kind: "draft", draft }. - A request with a wait query gets a deadline that outlasts it. - WebhookEvent names email.received and the 14 inbox.* events; webhooks take inboxIds, apiKeys.create takes inboxId, domains.update takes receivingCatchAll. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + README.md | 138 +++++++- package.json | 2 +- src/http.ts | 45 ++- src/index.ts | 3 + src/query.ts | 10 + src/resources/api-keys.ts | 1 + src/resources/domains.ts | 3 +- src/resources/emails.ts | 7 +- src/resources/inboxes.ts | 424 +++++++++++++++++++++++ src/resources/receiving.ts | 147 ++++++++ src/resources/webhooks.ts | 8 +- src/types.ts | 666 ++++++++++++++++++++++++++++++++++++- test/client.test.ts | 28 ++ test/resources.test.ts | 635 +++++++++++++++++++++++++++++++++++ 15 files changed, 2105 insertions(+), 20 deletions(-) create mode 100644 src/resources/inboxes.ts create mode 100644 src/resources/receiving.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcc838..ab1b25c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 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. +- `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`. Existing types are otherwise unchanged. Needs a MillionSend version with inboxes; older instances answer 404 on the new paths. + ## 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 502a09c..d3e6227 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ No method throws for an API error — every call resolves to `{ data, error }`. `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 hold), `inbox_send_cap_reached` +(429, the inbox's daily cap), `approval_required` (403), `email_quarantined` (403), +`email_blocked` (410, a download of an email that carries malware) and `too_many_waits` +(429, too many concurrent long-polls). + ```ts const { data, error } = await ms.emails.get(id); if (error?.name === "not_found") { /* … */ } @@ -114,6 +119,111 @@ data.data; // [{ id }] for the accepted emails data.errors; // permissive only: [{ index, message }] for the rejected ones ``` +### 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, 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`. + +### 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: a random address on the shared domain + expiresIn: "24h", // `h` or `d`; null = never (custom domains only) + safety: { acceptFrom: "anyone" }, +}); +await ms.inboxes.create({ emailAddress: "support@acme.dev", name: "Support", kind: "human" }); +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 + +// 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.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 +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@example.com", 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 }`. +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. + ### Contacts Contacts are team-global — one list per team, no audiences. @@ -236,6 +346,7 @@ 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); ``` @@ -259,19 +370,30 @@ 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). ### 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`. + ### Templates `id` arguments accept the template id or its alias. @@ -340,8 +462,14 @@ 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`, and `inboxes.safety`, + `inboxes.codes`, `inboxes.quarantine` and `inboxes.drafts.reject`. +- **`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/http.ts b/src/http.ts index 704a42c..3c98447 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; } @@ -109,14 +111,33 @@ 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 }> { const url = `${this.baseUrl}${path}${queryString(query)}`; const headers: Record = { Authorization: `Bearer ${this.#apiKey}`, @@ -126,7 +147,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); @@ -143,7 +169,7 @@ export class HttpClient { message: cause instanceof Error ? cause.message : String(cause), statusCode: null, }; - return { data: null, error }; + return { status: null, result: { data: null, error } }; } const text = await response.text(); @@ -156,9 +182,10 @@ export class HttpClient { } } + const status = response.status; if (!response.ok) { - return { data: null, error: toErrorResponse(parsed, response.status) }; + return { status, result: { data: null, error: toErrorResponse(parsed, status) } }; } - 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 a629f13..6563e8b 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..ea7518a 100644 --- a/src/resources/api-keys.ts +++ b/src/resources/api-keys.ts @@ -22,6 +22,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/domains.ts b/src/resources/domains.ts index b70566b..f16f7fd 100644 --- a/src/resources/domains.ts +++ b/src/resources/domains.ts @@ -40,7 +40,7 @@ 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. */ update(payload: UpdateDomainOptions): Promise> { return this.http.request({ method: "PATCH", @@ -51,6 +51,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..a7bd068 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,7 +43,11 @@ 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. */ send( diff --git a/src/resources/inboxes.ts b/src/resources/inboxes.ts new file mode 100644 index 0000000..07b3e30 --- /dev/null +++ b/src/resources/inboxes.ts @@ -0,0 +1,424 @@ +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, + Inbox, + InboxListItem, + InboxSafetyOptions, + InboxSafetyResponse, + InboxSendOutcome, + LabelAck, + LabelList, + LatestCodeOptions, + List, + ListInboxesOptions, + ListOptions, + 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 }, + }); + } +} + +/** 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?: ListOptions, + ): Promise>> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "threads", threadId, "emails"), + query: listQuery(options), + }); + } + + get(inboxId: string, threadId: string, emailId: string): Promise> { + return this.http.request({ + method: "GET", + path: inboxPath(inboxId, "threads", threadId, "emails", emailId), + }); + } + + /** 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`, a random address on the instance's shared domain. */ + create(payload: CreateInboxOptions = {}): Promise> { + return this.http.request({ + method: "POST", + path: "/inboxes", + body: { + email_address: payload.emailAddress, + 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) }); + } + + 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..61a2ab7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -619,6 +619,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 +646,8 @@ 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; } export interface CreateDomainResponse extends DomainListItem { @@ -680,6 +687,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 +712,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 +736,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 { @@ -754,6 +783,11 @@ export interface CreateApiKeyOptions { 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 +801,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 +882,636 @@ 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 only. */ +export interface HeldEmail { + id: string; + inbox_id: string; + 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; + 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`. */ + 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 subdomain (not listed by /domains). */ + 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. */ + 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; + sendPolicy?: InboxSafety["send_policy"]; + requireApproval?: boolean; + privateCodes?: boolean; + sendCap?: number | null; + /** Team members' login addresses or `ownerAddresses` only. */ + forwardTo?: string[]; +} + +export interface CreateInboxOptions { + /** Omit for a random address on the instance's shared domain. */ + emailAddress?: string; + name?: string; + /** Display name for sending; a plain name, not `Name `. */ + 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"; + /** `h` or `d` (e.g. "24h", "7d"); null = never (custom domains only). */ + expiresIn?: string | null; + /** Starting safety rules on top of the kind's defaults. */ + safety?: InboxSafetyOptions; +} + +export interface UpdateInboxOptions { + name?: string; + /** "" removes it. */ + fromName?: string; + 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 (60–86400; the API defaults to 600). */ + maxAge?: number; +} + +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; + thread_id: string; + from: string; + subject: string; + 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[]; +} + +/** Resend answers `{ object, id }`; MillionSend may add the label's fields. */ +export interface LabelAck extends Partial