diff --git a/README.md b/README.md index 9efb7e8..6e70846 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ leave it unchanged. ## Request options -`Emails.send`, `Batch.send` and `Contacts::Batch.create` take a trailing options hash, in -either of two shapes: +`Emails.send`, `Batch.send`, `Contacts::Batch.create` and the receiving and inbox replies, +forwards and `Drafts.send` take a trailing options hash, in either of two shapes: ```ruby Millionsend::Emails.send(payload, idempotency_key: "order-42") # flat @@ -82,7 +82,33 @@ Millionsend::Emails.send(payload, options: { idempotency_key: "order-42" }) # r `batch_validation` is `"strict"` by default (one invalid item rejects the whole batch) or `"permissive"` (the valid subset is processed and the failures come back under `errors:` -as `[{ index:, message: }]`). +as `[{ index:, message: }]`; `Contacts::Batch.create` adds each failure's `statusCode:` and +`name:`). + +## Pagination + +Lists answer `{ object: "list", data: [...], has_more: }`, and the SDK never pages for you. +Most lists are newest first: `after:` takes the last id of a page and pages toward older +items, `before:` takes the first id and pages toward newer ones (a `before:` page is still +newest first). Threads come most recently active first, a thread's emails oldest first +(`after:` pages toward newer), drafts most recently updated first; labels and topics are +unpaginated. + +```ruby +# Incremental sync: only what is newer than the newest id already stored +page = Millionsend::Emails.list(limit: 100, before: newest_id) +until page[:data].empty? + store(page[:data]) + newest_id = page[:data].first[:id] + break unless page[:has_more] + + page = Millionsend::Emails.list(limit: 100, before: newest_id) +end +``` + +A cursor whose item was deleted raises `ValidationError` (`NotFoundError` on the `Inboxes` +lists): start again without a cursor and stop at the ids you already have. Servers before +v0.13.0 listed oldest first; walking every page with `after:` returns every item on both. ## Resources @@ -108,6 +134,150 @@ The send payload accepts `from`, `to`, `subject`, `html`, `text`, `cc`, `bcc`, ` (as is `Batch.create`), mirroring Resend. `Emails.update` also takes resend-ruby's single hash: `Emails.update(email_id: id, scheduled_at: ...)`. +`subject` takes up to 998 characters here, in batches and in broadcasts (inbox replies, +forwards and drafts take 2,000). A `to`, `cc` or `bcc` address whose domain can never receive +mail (`example.com`, `.test`, `.invalid`, a TLD that doesn't exist, …) raises `ValidationError`, +on replies, forwards and drafts too, and so does such an address on a new contact. To test +sending without emailing anyone, use `success@simulator.amazonses.com`. + +`from` may be an inbox's address on the [shared domain](#the-shared-domain) while the shared row +in `Domains.list` has `sending_mode: "open"`: the inbox's exact `email_address` (no `+tag`), +no display name or its `from_name`, no `reply_to`, `attachments`, `headers` or `scheduled_at`, +and at most 10 distinct recipients across `to`, `cc` and `bcc`. `Batch.send` and broadcasts +never send from the shared domain: `InboxSendPolicyError`, or that item's entry in a +permissive batch's `errors:`. + +### Receiving + +Mail your domains receive: Resend's `Emails::Receiving` plus MillionSend's long-poll, server-side +reply and forward, and erase. Inbox-scoped keys never see mail held for screening or in quarantine. + +```ruby +page = Millionsend::Emails::Receiving.list(limit: 20) # GET /emails/receiving +email = Millionsend::Emails::Receiving.get(id, html_format: "cid") # GET /emails/receiving/:id +email[:extracted_text] # the visible text: hidden text and invisible characters removed +email[:screening] # { verdict:, sender_trust:, quarantine_reason:, injection_score:, ... } +email[:extracted] # { kinds:, code:, link:, ... } on verification mail +Millionsend::Emails::Receiving::Attachments.list(id) # each with a signed download_url (1 hour) +Millionsend::Emails::Receiving::Attachments.get(id, attachment_id) # also get(email_id:, id:) as in resend-ruby +Millionsend::Emails::Receiving.remove(id) # erase now (full-access keys) + +# Long-poll: wait up to 50 s for the next match +page = Millionsend::Emails::Receiving.list(inbox_id: inbox[:id], from: "github.com", wait: 30) +page[:data] # what arrived +page[:held] # [{ id:, inbox_id:, reason:, quarantine_reason:, created_at: }] — matches held for screening or quarantine + +# Reply and forward from the server, threaded +res = Millionsend::Emails::Receiving.reply(id, { text: "Thanks!" }, idempotency_key: "reply-#{id}") +res = Millionsend::Emails::Receiving.forward(id, { to: "ops@acme.dev", mode: "attachment" }) # the original as an .eml +``` + +List options go on the query as given: `limit`, `after`, `before`, `wait` (0–50), `since`, +`inbox_id` (an unknown one raises `NotFoundError` at once, even with `wait`), `to`, `from` (an +address, or a domain covering its subdomains), `subject_contains`, `unread` (with `inbox_id`, +or alone on an inbox-scoped key), and `include_quarantined` or `quarantined` (held mail too, or +only; full-access keys). `reply` also takes `subject`, `cc`, `bcc`, `reply_all`, +`requires_approval`, `from` and `inbox_id`. + +`reply` and `forward` return `{ kind: "sent", data: { object: "email", id:, thread_id:, inbox_id: } }`, +or `{ kind: "draft", data: }` when an inbox's rules hold the message for a person to +approve (HTTP 202); the draft's `approval_reasons` say why. + +### Inboxes (beta) + +Addresses whose mail lands in threads, for an agent (`kind: "agent"`, the default: screening, code +extraction and send rules on) or for people (`kind: "human"`). Mirrors Resend's Inboxes beta; +`Safety`, `Codes` and `Quarantine` are MillionSend extensions. + +```ruby +inbox = Millionsend::Inboxes.create(name: "Signup bot", username: "signup-bot") # signup-bot@. +inbox = Millionsend::Inboxes.create(email_address: "support@acme.dev", kind: "human") +inbox = Millionsend::Inboxes.create(domain_id: domain[:id], username: "billing") # billing@ +Millionsend::Inboxes.list(kind: "agent") +Millionsend::Inboxes.get("support@acme.dev") # by id or address +Millionsend::Inboxes.update(inbox[:id], name: "Support", from_name: "Acme Support", expires_in: nil) # nil: never expires +Millionsend::Inboxes.remove(inbox[:id]) # also revokes its inbox-scoped keys + +# Safety rules: who may write in and what an agent may send +Millionsend::Inboxes::Safety.get(inbox[:id]) +Millionsend::Inboxes::Safety.update(inbox[:id], accept_from: "allowlist", allowlist: ["*@stripe.com"], send_cap: nil) + +# Verification codes and sign-in links (links are never fetched); waits up to 50 s +code = Millionsend::Inboxes::Codes.latest(inbox[:id], from: "github.com", wait: 30, max_age: 600) +code[:status] # "found" | "none" | "ambiguous" | "timeout" +code[:code] # "482913", or code[:url] for a sign-in link +# A second sign-in waits for its own code: only mail received after the one already used counts +Millionsend::Inboxes::Codes.latest(inbox[:id], from: "github.com", wait: 30, since: code[:received_at]) + +# Quarantine: held mail is kept and listed; agents see it only once a person releases it +Millionsend::Inboxes::Quarantine.list(inbox[:id]) +Millionsend::Inboxes::Quarantine.release(inbox[:id], email_id, trust_sender: true) # also allowlists the sender + +# Threads and their messages +Millionsend::Inboxes::Threads.list(inbox[:id], folders: ["inbox", "archive"], read: false) +Millionsend::Inboxes::Threads.search(inbox[:id], query: "invoice", from: ["stripe.com"]) +Millionsend::Inboxes::Threads.get(inbox[:id], thread_id) +Millionsend::Inboxes::Threads.update(inbox[:id], thread_id, read: true, folder: "archive", label_id: label_id) +Millionsend::Inboxes::Threads.remove(inbox[:id], thread_id) # to trash, purged after 30 days +Millionsend::Inboxes::Threads::Emails.list(inbox[:id], thread_id) # oldest first; include_quarantined: true for held mail too +Millionsend::Inboxes::Threads::Emails.get(inbox[:id], thread_id, email_id) +res = Millionsend::Inboxes::Threads::Emails.reply(inbox[:id], thread_id, email_id, { text: "On it." }, idempotency_key: "k1") +Millionsend::Inboxes::Threads::Emails.forward(inbox[:id], thread_id, email_id, { to: "ops@acme.dev" }) + +# Labels +label = Millionsend::Inboxes::Labels.create(inbox[:id], name: "Billing", color: "#FFAA00") +Millionsend::Inboxes::Labels.list(inbox[:id]) +Millionsend::Inboxes::Labels.update(inbox[:id], label[:id], name: "Invoices") +Millionsend::Inboxes::Labels.remove(inbox[:id], label[:id]) + +# Drafts +draft = Millionsend::Inboxes::Drafts.create(inbox[:id], to: ["ada@x.dev"], subject: "Hello", text: "Hi Ada") +Millionsend::Inboxes::Drafts.list(inbox[:id]) +Millionsend::Inboxes::Drafts.get(inbox[:id], draft[:id]) +Millionsend::Inboxes::Drafts.update(inbox[:id], draft[:id], subject: nil) # nil clears +Millionsend::Inboxes::Drafts.send(inbox[:id], draft[:id], idempotency_key: "draft-1") +Millionsend::Inboxes::Drafts.reject(inbox[:id], draft[:id]) # approvers only +Millionsend::Inboxes::Drafts.remove(inbox[:id], draft[:id]) +``` + +Thread replies, forwards and `Drafts.send` return `{ kind: "sent", data: ... }`, or +`{ kind: "draft", data: }` when the inbox's rules hold the message for approval; an +agent's reply to a Reply-To at another organization is held with `approval_reasons` including +`"reply_to_redirect"`. An inbox-scoped key or an MCP token cannot send a draft that waits for +approval (`ApprovalRequiredError`); a person in the dashboard or a full-access team key can. +Arrays in list and search options (`folders`, `labels`, `from`, `to`, `cc`, `bcc`) go out as one +comma-separated value. With `include_quarantined: true` (full-access team keys), `Threads.list` +and `search` items carry `quarantined:`, the count of held messages. Inbox names take 1–320 +characters. Resend's hosted agent settings (`/inboxes/:id/agent`) are not exposed: MillionSend +runs no agent. + +#### The shared domain + +Without `email_address`, an inbox gets an address on the team's subdomain of the instance's +shared domain, which needs no DNS. `username` picks the part before the @ (1–64 of +`a-z 0-9 . _ -`, starting and ending with a letter or digit), and it is random when omitted. +`domain_id` takes the id of a `Domains.list` row in place of `email_address`: the shared row +works like leaving it out, and one of your own domains needs `username`. Reserved names +(`postmaster`, `admin`, `support`, …), a `fwd-` prefix and brand look-alikes raise +`ValidationError`, and so does an address already in use, with status 409. + +A chosen `username` or a shared inbox's `from_name` is reviewed first. While that review is +down, `create` and `update` raise `NameReviewUnavailableError` (503, `retry_after` 60), and so +does every shared or forwarding `create` while a new team's subdomain still waits for its own +review (`retry_after` 300); nothing was created, so retry once `e.retry_after` seconds have +passed. Too many names reviewed or refused in a day raise `RateLimitExceededError` until the next +UTC midnight. Inboxes never expire unless `expires_in` says so (`"1h"` to `"365d"`), but a +shared-domain inbox left unused for 90 days is deleted. + +What a shared address may send follows the shared row's `sending_mode`: `"open"` starts +conversations (`Emails.send` too), `"reply_only"` replies inside its threads, and +`"receive_only"` sends nothing; an inbox whose name looks like a brand only replies, even under +`"open"`. Refusals raise `InboxSendPolicyError` (403), `InboxSendCapReachedError` (429, the +team's daily cap, with `retry_after` running to the next UTC midnight) or `ValidationError` +(more than 10 recipients). On a shared inbox, `Safety.update` accepts `send_policy: "open"` only +while the mode is `"open"` and refuses `"allowlist"`; `owner_addresses` don't make senders +trusted there, and `forward_to` copies go to members' sign-in addresses only. + ### Contacts Contacts are team-global — one list per team, no audiences. @@ -246,9 +416,22 @@ Millionsend::Domains.get(domain[:id]) Millionsend::Domains.list Millionsend::Domains.verify(domain[:id]) # re-check DNS now Millionsend::Domains.update(domain[:id], open_tracking: false, click_tracking: true, tracking_subdomain: nil) # nil clears +Millionsend::Domains.update(domain[:id], receiving_catch_all: true) # keep mail to addresses without an inbox (paid plans on Cloud) Millionsend::Domains.remove(domain[:id]) ``` +`Domains.list` ends with the team's subdomain of the [shared domain](#the-shared-domain), after +all of your own domains; there is none when the instance has no shared domain, or while a new +team's subdomain waits for its name review. Every row carries `shared:` (absent before server +v0.14.0, so `nil` reads as false); the shared row adds `sending_mode` (`"open"`, `"reply_only"` +or `"receive_only"`) and `sending_mode_reason` (`"plan"`, `"instance"`, or `nil` when open). It +has no DNS records (`records: []`), and `update`, `verify` and `remove` raise `ForbiddenError` on +it. It only sends from an inbox's own address, so skip it when picking a sender domain: + +```ruby +sender = Millionsend::Domains.list[:data].find { |d| d[:status] == "verified" && !d[:shared] } +``` + ### Webhooks ```ruby @@ -261,6 +444,7 @@ hook[:signing_secret] Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret and previous_secret_expires_at Millionsend::Webhooks.list Millionsend::Webhooks.update(hook[:id], events: ["email.opened"], status: "disabled") +Millionsend::Webhooks.update(hook[:id], inbox_ids: [inbox[:id]]) # inbox events for these inboxes only; nil: every inbox Millionsend::Webhooks.remove(hook[:id]) # Rotate the signing secret (MillionSend extension) — POST /webhooks/:id/rotate @@ -278,13 +462,22 @@ Events: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounce `email.complained`, `email.opened`, `email.clicked`, `contact.created`, `contact.updated`, `contact.deleted`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`, `contact.topic_opt_out`, `suppression.added`, `suppression.removed`, `deliverability.warning`, -`deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`. +`deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`, `email.received`, 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 are opt-in: an +endpoint gets them only when its `events` list names them. A shared-domain inbox deleted after +90 unused days sends `inbox.expired`, then `inbox.deleted`, and inbox events from a send in the +dashboard carry `source: "dashboard"` in their `data`. ### API keys ```ruby -key = Millionsend::ApiKeys.create(name: "ci", permission: "sending_access", domain_id: domain[:id]) +key = Millionsend::ApiKeys.create(name: "ci", permission: "sending_access", domain_id: domain[:id]) # a shared: false row key[:token] # only returned here +agent_key = Millionsend::ApiKeys.create(name: "signup-bot", permission: "full_access", inbox_id: inbox[:id]) +# reads and sends for that one inbox only; list rows carry inbox_id Millionsend::ApiKeys.list Millionsend::ApiKeys.remove(key[:id]) ``` @@ -363,15 +556,52 @@ end ``` Subclasses: `ValidationError`, `AllRecipientsSuppressedError`, `NotFoundError`, -`MissingApiKeyError`, `InvalidApiKeyError`, `RestrictedApiKeyError`, `SendingPausedError`, -`BroadcastsPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`, -`InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InternalServerError`, and -`ApplicationError` (the fallback for unknown names). Client-side and -transport failures that never reached the API raise `ApplicationError` with `#status_code == nil`. +`MissingApiKeyError`, `InvalidApiKeyError`, `RestrictedApiKeyError`, `ForbiddenError`, +`SendingPausedError`, `BroadcastsPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`, +`InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InternalServerError`, the +receiving and inbox errors `InboxSendPolicyError`, `InboxSendCapReachedError`, +`ApprovalRequiredError`, `EmailQuarantinedError`, `EmailBlockedError`, `TooManyWaitsError` and +`NameReviewUnavailableError`, and `ApplicationError` (the fallback for other names, such as +`plan_limit_reached`). Client-side and transport failures that never reached the API raise +`ApplicationError` with `#status_code == nil`. An unknown route raises `NotFoundError`; servers +before v0.13.0 answered it with text, which raises `ApplicationError` with status 404. `Emails.send` and `Batch.send` raise `AllRecipientsSuppressedError` (`all_recipients_suppressed`, 422) when every `to` recipient is on the suppression list or opted out of the send's `topic_id`. +### Retries + +The SDK never retries. `e.retry_after` is the response's `retry-after` header in seconds (`nil` +without one). Worth retrying once it has passed: `NameReviewUnavailableError` (60 or 300 s), +`TooManyWaitsError`, and `RateLimitExceededError` when `retry_after` is at most 60 (the +per-minute limits). Not worth retrying in the same process: `InboxSendCapReachedError`, the +daily name-review budget (a `RateLimitExceededError` whose `retry_after` runs to the next UTC +midnight), and any 403, 404, 409 or 422. + +Retry a send with the same `idempotency_key` (`Emails.send`, `Batch.send`, replies, forwards and +`Drafts.send`): a retried batch gets its stored response even if a recipient was refused or +suppressed since. A held draft (`kind: "draft"`) is not a failure, so never resend it. +`Inboxes.create` takes no idempotency key: after a 503 or 429 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 worked, and `Inboxes.get(address)` finds it. + +## Server versions + +On a self-hosted instance, each feature needs at least this MillionSend version: + +| Server | Features | +| ------- | -------- | +| v0.8.0 | `Emails::Receiving` | +| v0.10.0 | `Inboxes` | +| v0.11.0 | `ValidationError` for recipients that can never receive mail; a retried batch replays its stored response | +| v0.12.0 | `username`; inboxes that never expire by default; sends from shared-domain inboxes; name review (`NameReviewUnavailableError`, the daily budget); `statusCode`/`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; `NotFoundError` at once for an unknown `inbox_id`; `unread` on inbox-scoped keys; JSON 404 for unknown routes | +| v0.14.0 | The shared row in `Domains.list` (`shared`, `sending_mode`, `sending_mode_reason`); `domain_id` on `Inboxes.create`; `subject` up to 998 characters | + +An older server ignores options it doesn't know: before v0.14.0 `domain_id` (check the created +inbox's `email_address`), before v0.13.0 `since` (a code already used can come back), and before +v0.12.0 `username` (the address is random). + ## Migrating from Resend ```diff @@ -401,10 +631,11 @@ works as in resend-ruby. Notes: part of this SDK. `Millionsend::Segments` is the dynamic-filter feature (`/segments`), not Resend's audiences alias. - Not in the API (yet), so not here: broadcast recipients/clicked links, email sharing and - metrics, contact imports, receiving, automations, logs, OAuth grants, webhook event replay. + metrics, contact imports, automations, logs, OAuth grants, webhook event replay. - MillionSend extensions with no Resend counterpart: `Segments`, `Contacts::Batch`, `Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`, - `Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`. + `Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`, the receiving long-poll, + `Emails::Receiving.reply`/`forward`/`remove`, and `Inboxes::Safety`, `Codes` and `Quarantine`. ## License diff --git a/lib/millionsend.rb b/lib/millionsend.rb index 637c891..9991ae3 100644 --- a/lib/millionsend.rb +++ b/lib/millionsend.rb @@ -5,6 +5,7 @@ require "millionsend/error" require "millionsend/request" require "millionsend/emails" +require "millionsend/receiving" require "millionsend/batch" require "millionsend/contacts" require "millionsend/contact_properties" @@ -18,13 +19,14 @@ require "millionsend/templates" require "millionsend/usage" require "millionsend/deliverability" +require "millionsend/inboxes" # Ruby client for the MillionSend HTTP API. Configure once, then call the # resource modules: # # Millionsend.api_key = "ms_..." # Millionsend.base_url = "https://mail.acme.dev" -# Millionsend::Emails.send(from: "onboarding@acme.dev", to: "you@example.com", +# Millionsend::Emails.send(from: "onboarding@acme.dev", to: "success@simulator.amazonses.com", # subject: "Hi", html: "it works") # # api_key falls back to the MILLIONSEND_API_KEY env var; base_url to diff --git a/lib/millionsend/api_keys.rb b/lib/millionsend/api_keys.rb index 68096b3..da46a06 100644 --- a/lib/millionsend/api_keys.rb +++ b/lib/millionsend/api_keys.rb @@ -5,7 +5,9 @@ module Millionsend module ApiKeys class << self # POST /api-keys — name, permission ("full_access" default or - # "sending_access"), domain_id. Returns { id:, token: }. + # "sending_access"), domain_id (a Domains.list row with shared: false), + # inbox_id (a full_access key that reads and sends for that one inbox). + # Returns { id:, token: }. def create(params) Millionsend::Request.new(method: :post, path: "/api-keys", body: params).perform end diff --git a/lib/millionsend/batch.rb b/lib/millionsend/batch.rb index 7eb308d..3bcda4c 100644 --- a/lib/millionsend/batch.rb +++ b/lib/millionsend/batch.rb @@ -8,7 +8,9 @@ class << self # idempotency_key and batch_validation ("strict", the default, rejects the # whole batch on one invalid item; "permissive" sends the valid subset and # lists the rest under errors[]) — either flat or under options:, like - # Emails.send. + # Emails.send. An item from a shared-domain address is refused + # (inbox_send_policy): the whole batch in strict mode, that item in + # permissive mode. def send(list, options = {}) Millionsend::Request.new( method: :post, path: "/emails/batch", body: list, **Millionsend::Util.request_options(options) diff --git a/lib/millionsend/broadcasts.rb b/lib/millionsend/broadcasts.rb index 242e78a..895033f 100644 --- a/lib/millionsend/broadcasts.rb +++ b/lib/millionsend/broadcasts.rb @@ -2,7 +2,8 @@ module Millionsend # Broadcasts — one email sent to a segment, a topic's subscribers, or every - # contact (pass neither segment_id nor topic_id). + # contact (pass neither segment_id nor topic_id). Never from the shared + # domain: create(send: true) and send answer 403 inbox_send_policy. module Broadcasts class << self # POST /broadcasts diff --git a/lib/millionsend/contacts.rb b/lib/millionsend/contacts.rb index 54c2a5c..5753760 100644 --- a/lib/millionsend/contacts.rb +++ b/lib/millionsend/contacts.rb @@ -103,7 +103,8 @@ class << self # "skip", "upsert") for emails that already belong to a contact, # batch_validation ("strict" default, "permissive" writes the valid # subset and lists failures under errors[]), idempotency_key. Returns - # { data: [{ index:, id:, status: }], counts: {...}, errors: [...] }. + # { data: [{ index:, id:, status: }], counts: {...}, errors: [{ index:, + # statusCode:, name:, message: }] }. def create(list, options = {}) options = options[:options] if options.is_a?(Hash) && options.key?(:options) Millionsend::Request.new( diff --git a/lib/millionsend/domains.rb b/lib/millionsend/domains.rb index 656cffb..2554cd6 100644 --- a/lib/millionsend/domains.rb +++ b/lib/millionsend/domains.rb @@ -1,7 +1,13 @@ # frozen_string_literal: true module Millionsend - # Sending domains and their DNS records. + # Sending domains and their DNS records. Rows carry shared: (absent before + # server v0.14.0, so nil reads as false). The team's subdomain of the shared + # inbox domain is a read-only row with shared: true, sending_mode: ("open", + # "reply_only" or "receive_only"), sending_mode_reason: ("plan", "instance" + # or nil when open) and records: []; update, verify and remove raise + # ForbiddenError on it. It only sends from an inbox's own address, so skip + # it when picking a sender domain. module Domains class << self # POST /domains — name, region, custom_return_path, open_tracking, @@ -15,13 +21,15 @@ def get(id) Millionsend::Request.new(method: :get, path: member_path(id)).perform end - # GET /domains — accepts limit:/after:/before:. + # GET /domains — accepts limit:/after:/before:. The shared row comes + # last, after all of the team's own domains. def list(options = {}) Millionsend::Request.new(method: :get, path: "/domains", query: Millionsend::Util.list_query(options)).perform end # PATCH /domains/:id — open_tracking, click_tracking, tracking_subdomain - # (nil or "" clears it). + # (nil or "" clears it), receiving_catch_all (mail to addresses without + # an inbox is kept rather than refused; paid plans on Cloud). def update(id, params) Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform end diff --git a/lib/millionsend/emails.rb b/lib/millionsend/emails.rb index a66d283..5569743 100644 --- a/lib/millionsend/emails.rb +++ b/lib/millionsend/emails.rb @@ -13,6 +13,12 @@ class << self # `options: { idempotency_key: "k" }`. No keyword parameters are declared # on purpose — Ruby 3 keyword separation would otherwise reject the # bare-keyword call shape. + # + # from may be an inbox's address on the shared domain while that domain's + # Domains.list row has sending_mode "open": the inbox's exact + # email_address (no +tag), no display name or its from_name, no + # reply_to, attachments, headers or scheduled_at, and at most 10 distinct + # recipients across to, cc and bcc. def send(params = {}, options = {}) Millionsend::Request.new( method: :post, path: "/emails", body: params, **Millionsend::Util.request_options(options) diff --git a/lib/millionsend/error.rb b/lib/millionsend/error.rb index 3d7c517..a1f0ff8 100644 --- a/lib/millionsend/error.rb +++ b/lib/millionsend/error.rb @@ -3,20 +3,22 @@ module Millionsend # Base class for everything this SDK raises. Mirrors the API's wire error body # { statusCode, name, message }; #status_code is nil for client-side and - # transport failures that never reached the API. + # transport failures that never reached the API. #retry_after is the + # response's retry-after header in seconds, nil when it sent none. class Error < StandardError - attr_reader :status_code, :name + attr_reader :status_code, :name, :retry_after - def initialize(message, status_code = nil, name = nil) + def initialize(message, status_code = nil, name = nil, retry_after = nil) super(message) @status_code = status_code @name = name + @retry_after = retry_after end # Build the right subclass from a non-2xx response, keyed on the stable # `name` discriminant. Falls back to ApplicationError for unknown names or a # body that is not the canonical error shape. - def self.from_response(status, body) + def self.from_response(status, body, retry_after = nil) if body.is_a?(Hash) name = body[:name].is_a?(String) ? body[:name] : "application_error" message = body[:message].is_a?(String) ? body[:message] : "Request failed with status #{status}" @@ -26,7 +28,7 @@ def self.from_response(status, body) message = "Request failed with status #{status}" code = status end - (ERROR_TYPES[name] || ApplicationError).new(message, code, name) + (ERROR_TYPES[name] || ApplicationError).new(message, code, name, retry_after) end end @@ -36,12 +38,20 @@ class NotFoundError < Error; end class MissingApiKeyError < Error; end class InvalidApiKeyError < Error; end class RestrictedApiKeyError < Error; end + class ForbiddenError < Error; end class SendingPausedError < Error; end class BroadcastsPausedError < Error; end class RateLimitExceededError < Error; end class DailyQuotaExceededError < Error; end class InvalidIdempotentRequestError < Error; end class ConcurrentIdempotentRequestsError < Error; end + class InboxSendPolicyError < Error; end + class InboxSendCapReachedError < Error; end + class ApprovalRequiredError < Error; end + class EmailQuarantinedError < Error; end + class EmailBlockedError < Error; end + class TooManyWaitsError < Error; end + class NameReviewUnavailableError < Error; end class InternalServerError < Error; end class ApplicationError < Error; end @@ -52,12 +62,20 @@ class ApplicationError < Error; end "missing_api_key" => MissingApiKeyError, "invalid_api_key" => InvalidApiKeyError, "restricted_api_key" => RestrictedApiKeyError, + "forbidden" => ForbiddenError, "sending_paused" => SendingPausedError, "broadcasts_paused" => BroadcastsPausedError, "rate_limit_exceeded" => RateLimitExceededError, "daily_quota_exceeded" => DailyQuotaExceededError, "invalid_idempotent_request" => InvalidIdempotentRequestError, "concurrent_idempotent_requests" => ConcurrentIdempotentRequestsError, + "inbox_send_policy" => InboxSendPolicyError, + "inbox_send_cap_reached" => InboxSendCapReachedError, + "approval_required" => ApprovalRequiredError, + "email_quarantined" => EmailQuarantinedError, + "email_blocked" => EmailBlockedError, + "too_many_waits" => TooManyWaitsError, + "name_review_unavailable" => NameReviewUnavailableError, "internal_server_error" => InternalServerError, "application_error" => ApplicationError, }.freeze diff --git a/lib/millionsend/inboxes.rb b/lib/millionsend/inboxes.rb new file mode 100644 index 0000000..cadfc66 --- /dev/null +++ b/lib/millionsend/inboxes.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +module Millionsend + # Agent and human inboxes (Resend's Inboxes beta plus MillionSend's + # extensions): addresses whose mail lands in threads, with labels, drafts, + # safety rules, quarantine and code extraction. Lists that take arrays + # (folders:, labels:, from:...) send them comma-separated. + module Inboxes + class << self + # POST /inboxes — every field optional. The address is email_address on + # one of your domains, or else one on the shared domain (no DNS) named + # by username (random when omitted). domain_id, the id of a Domains.list + # row, stands in for email_address: the shared row works like leaving it + # out, and one of your own domains needs username, the part before the + # @. Also name (1-320 characters), from_name, forwarding, kind ("agent", + # the default, or "human"), expires_in ("1h" to "365d"; omitted or nil + # never expires) and safety (any subset of the rules). Returns the inbox. + def create(params = {}) + Millionsend::Request.new(method: :post, path: "/inboxes", body: params).perform + end + + # GET /inboxes — limit:/after:/before: and kind:. Newest first. + def list(options = {}) + Millionsend::Request.new(method: :get, path: "/inboxes", query: Millionsend::Util.csv_query(options)).perform + end + + # GET /inboxes/:id_or_email + def get(id_or_email) + Millionsend::Request.new(method: :get, path: member_path(id_or_email)).perform + end + + # PATCH /inboxes/:id — name (1-320 characters), from_name ("" removes + # it), expires_in (nil never expires). Returns the inbox. + def update(id, params) + Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform + end + + # DELETE /inboxes/:id — its threads, drafts and labels go with it, and + # its inbox-scoped keys are revoked. + def remove(id) + Millionsend::Request.new(method: :delete, path: member_path(id)).perform + end + + def member_path(id) + "/inboxes/#{Millionsend::Util.encode(id)}" + end + end + + # Who may write in and what an agent may send (MillionSend extension). + module Safety + class << self + # GET /inboxes/:id/safety + def get(inbox_id) + Millionsend::Request.new(method: :get, path: "#{Inboxes.member_path(inbox_id)}/safety").perform + end + + # PATCH /inboxes/:id/safety — any subset of accept_from, allowlist, + # owner_addresses, screening, extract_codes, send_policy, + # require_approval, private_codes, send_cap (nil: no cap) and + # forward_to. Returns every rule. + def update(inbox_id, params) + Millionsend::Request.new(method: :patch, path: "#{Inboxes.member_path(inbox_id)}/safety", body: params).perform + end + end + end + + # Verification codes and sign-in links found in received mail + # (MillionSend extension). Links are never fetched. + module Codes + class << self + # GET /inboxes/:id/codes/latest — from: (an address, or a domain + # covering its subdomains), wait: (0-50 s), max_age: (60-86400 s since + # delivery, default 600), since: (an ISO 8601 time with an offset or + # Z; only mail received after it counts, so passing the previous + # code's received_at makes a second sign-in wait for its own code). + # Always 200; status is "found", "none", "ambiguous" or "timeout". + def latest(inbox_id, options = {}) + Millionsend::Request.new( + method: :get, path: "#{Inboxes.member_path(inbox_id)}/codes/latest", + query: Millionsend::Util.csv_query(options) + ).perform + end + end + end + + # Mail the inbox's rules held back (MillionSend extension): kept and + # listed, but never shown to agents until a person releases it. + module Quarantine + class << self + # GET /inboxes/:id/quarantine — limit:/after:/before:. Newest first. + def list(inbox_id, options = {}) + Millionsend::Request.new( + method: :get, path: "#{Inboxes.member_path(inbox_id)}/quarantine", + query: Millionsend::Util.list_query(options) + ).perform + end + + # POST /inboxes/:id/quarantine/:email_id/release — trust_sender: true + # also adds the sender to the allowlist. + def release(inbox_id, email_id, params = {}) + path = "#{Inboxes.member_path(inbox_id)}/quarantine/#{Millionsend::Util.encode(email_id)}/release" + Millionsend::Request.new(method: :post, path: path, body: params).perform + end + end + end + + # Labels of one inbox, applied to threads one at a time. + module Labels + class << self + # GET /inboxes/:id/labels — every label, oldest first (unpaginated). + def list(inbox_id) + Millionsend::Request.new(method: :get, path: "#{Inboxes.member_path(inbox_id)}/labels").perform + end + + # POST /inboxes/:id/labels — name, color ("#RRGGBB"; random when omitted). + def create(inbox_id, params) + Millionsend::Request.new(method: :post, path: "#{Inboxes.member_path(inbox_id)}/labels", body: params).perform + end + + # PATCH /inboxes/:id/labels/:label_id — name and/or color. + def update(inbox_id, label_id, params) + Millionsend::Request.new(method: :patch, path: member_path(inbox_id, label_id), body: params).perform + end + + # DELETE /inboxes/:id/labels/:label_id — also takes it off every thread. + def remove(inbox_id, label_id) + Millionsend::Request.new(method: :delete, path: member_path(inbox_id, label_id)).perform + end + + private + + def member_path(inbox_id, label_id) + "#{Inboxes.member_path(inbox_id)}/labels/#{Millionsend::Util.encode(label_id)}" + end + end + end + + # Conversations of one inbox, in both directions. + module Threads + class << self + # GET /inboxes/:id/threads — folders: (["inbox"] by default), labels:, + # read:, include_quarantined: (items then carry quarantined:, the + # count of held messages; full-access team keys), + # limit:/after:/before:. Most recently active first. + def list(inbox_id, options = {}) + Millionsend::Request.new( + method: :get, path: "#{Inboxes.member_path(inbox_id)}/threads", query: Millionsend::Util.csv_query(options) + ).perform + end + + # GET /inboxes/:id/threads/search — list's options plus query:, from:, + # to:, cc:, bcc:, has_attachment:, start_date:, end_date:. Searches + # subjects, senders and recipients. + def search(inbox_id, options = {}) + Millionsend::Request.new( + method: :get, path: "#{Inboxes.member_path(inbox_id)}/threads/search", + query: Millionsend::Util.csv_query(options) + ).perform + end + + # GET /inboxes/:id/threads/:thread_id + def get(inbox_id, thread_id) + Millionsend::Request.new(method: :get, path: member_path(inbox_id, thread_id)).perform + end + + # PATCH /inboxes/:id/threads/:thread_id — read, folder ("inbox", + # "archive", "spam" or "trash"), label_id. + def update(inbox_id, thread_id, params) + Millionsend::Request.new(method: :patch, path: member_path(inbox_id, thread_id), body: params).perform + end + + # DELETE /inboxes/:id/threads/:thread_id — moves it to trash, purged + # after 30 days; deleted is false when it was there already. + def remove(inbox_id, thread_id) + Millionsend::Request.new(method: :delete, path: member_path(inbox_id, thread_id)).perform + end + + def member_path(inbox_id, thread_id) + "#{Inboxes.member_path(inbox_id)}/threads/#{Millionsend::Util.encode(thread_id)}" + end + end + + # The messages of one thread. + module Emails + class << self + # GET .../threads/:thread_id/emails — limit:/after:/before: and + # include_quarantined: (full-access team keys). Oldest first; after: + # pages toward newer. + def list(inbox_id, thread_id, options = {}) + Millionsend::Request.new( + method: :get, path: "#{Threads.member_path(inbox_id, thread_id)}/emails", + query: Millionsend::Util.csv_query(options) + ).perform + end + + # GET .../threads/:thread_id/emails/:email_id — include_quarantined: + # (full-access team keys) reads a held message. + def get(inbox_id, thread_id, email_id, options = {}) + Millionsend::Request.new( + method: :get, path: member_path(inbox_id, thread_id, email_id), + query: { include_quarantined: options[:include_quarantined] } + ).perform + end + + # POST .../emails/:email_id/reply — html/text, cc, bcc, subject, + # reply_all, requires_approval. Returns { kind: "sent", data: + # }, or { kind: "draft", data: } when the + # inbox's rules held it for approval (202). The trailing hash takes + # idempotency_key, flat or under options:. + def reply(inbox_id, thread_id, email_id, params, options = {}) + Millionsend::Request.new( + method: :post, path: "#{member_path(inbox_id, thread_id, email_id)}/reply", body: params, + **Millionsend::Util.request_options(options) + ).perform_outcome + end + + # POST .../emails/:email_id/forward — to (required), cc, bcc, a + # html/text note, subject, requires_approval. Same result and options + # as reply. + def forward(inbox_id, thread_id, email_id, params, options = {}) + Millionsend::Request.new( + method: :post, path: "#{member_path(inbox_id, thread_id, email_id)}/forward", body: params, + **Millionsend::Util.request_options(options) + ).perform_outcome + end + + private + + def member_path(inbox_id, thread_id, email_id) + "#{Threads.member_path(inbox_id, thread_id)}/emails/#{Millionsend::Util.encode(email_id)}" + end + end + end + end + + # Drafts, standalone or replies: sent on demand or held for approval. + module Drafts + class << self + # GET /inboxes/:id/drafts — limit:/after:/before:. Unsent drafts, most + # recently updated first. + def list(inbox_id, options = {}) + Millionsend::Request.new( + method: :get, path: "#{Inboxes.member_path(inbox_id)}/drafts", query: Millionsend::Util.list_query(options) + ).perform + end + + # POST /inboxes/:id/drafts — to, cc, bcc, subject, html, text, plus + # thread_id with reply_to_email_id for a reply draft. An unsent reply + # draft to the same email comes back instead of a new one. + def create(inbox_id, params) + Millionsend::Request.new(method: :post, path: "#{Inboxes.member_path(inbox_id)}/drafts", body: params).perform + end + + # GET /inboxes/:id/drafts/:draft_id — also after it was sent. + def get(inbox_id, draft_id) + Millionsend::Request.new(method: :get, path: member_path(inbox_id, draft_id)).perform + end + + # PATCH /inboxes/:id/drafts/:draft_id — nil clears to, cc, bcc, + # subject, html or text; setting html or text replaces both. + def update(inbox_id, draft_id, params) + Millionsend::Request.new(method: :patch, path: member_path(inbox_id, draft_id), body: params).perform + end + + # DELETE /inboxes/:id/drafts/:draft_id + def remove(inbox_id, draft_id) + Millionsend::Request.new(method: :delete, path: member_path(inbox_id, draft_id)).perform + end + + # POST /inboxes/:id/drafts/:draft_id/send — no body. Returns { kind: + # "sent", data: { object:, id:, thread_id:, email_id: } }, or { kind: + # "draft", data: } when the rules hold it for approval (202). + # The trailing hash takes idempotency_key, flat or under options:. + def send(inbox_id, draft_id, options = {}) + Millionsend::Request.new( + method: :post, path: "#{member_path(inbox_id, draft_id)}/send", **Millionsend::Util.request_options(options) + ).perform_outcome + end + + # POST /inboxes/:id/drafts/:draft_id/reject — approvers only; returns + # the draft with status "rejected". + def reject(inbox_id, draft_id) + Millionsend::Request.new(method: :post, path: "#{member_path(inbox_id, draft_id)}/reject").perform + end + + private + + def member_path(inbox_id, draft_id) + "#{Inboxes.member_path(inbox_id)}/drafts/#{Millionsend::Util.encode(draft_id)}" + end + end + end + end +end diff --git a/lib/millionsend/receiving.rb b/lib/millionsend/receiving.rb new file mode 100644 index 0000000..ff7d2af --- /dev/null +++ b/lib/millionsend/receiving.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Millionsend + module Emails + # Received mail: Resend's emails.receiving surface plus MillionSend's + # long-poll, server-side reply and forward, and erase. Inbox-scoped keys + # never see mail held for screening or in quarantine. + module Receiving + class << self + # GET /emails/receiving. Options go on the query as given: limit:, + # after:, before:, plus wait: (long-poll up to 50 s; the answer then + # also lists held: [{ id:, inbox_id:, reason:, quarantine_reason:, + # created_at: }] for matches held since the cursor), since:, + # inbox_id: (an unknown one is not_found at once, even with wait:), + # to:, from: (an address, or a domain covering its subdomains), + # subject_contains:, unread: (with inbox_id:, or alone on an + # inbox-scoped key), include_quarantined: and quarantined: (held mail + # too, or only; full-access keys). + def list(options = {}) + Millionsend::Request.new( + method: :get, path: "/emails/receiving", query: Millionsend::Util.csv_query(options) + ).perform + end + + # GET /emails/receiving/:id — html_format: "data_uri" (default) or + # "cid". Adds screening, the spam and virus verdicts, extracted_text + # and, for verification mail, extracted: { code:, link: }. + def get(id, options = {}) + Millionsend::Request.new(method: :get, path: member_path(id), query: { html_format: options[:html_format] }).perform + end + + # POST /emails/receiving/:id/reply — text/html, subject, cc, bcc, + # reply_all, requires_approval, from, inbox_id. Returns { kind: "sent", + # data: { object:, id:, thread_id:, inbox_id: } }, or { kind: "draft", + # data: } when the inbox's rules held it for approval (202). + # The trailing hash takes idempotency_key, flat or under options:. + def reply(id, params, options = {}) + Millionsend::Request.new( + method: :post, path: "#{member_path(id)}/reply", body: params, **Millionsend::Util.request_options(options) + ).perform_outcome + end + + # POST /emails/receiving/:id/forward — to (required), cc, bcc, a + # text/html note, subject, requires_approval, from, inbox_id and mode + # ("inline", the default, or "attachment" for the original as an .eml). + # Same result and options as reply. + def forward(id, params, options = {}) + Millionsend::Request.new( + method: :post, path: "#{member_path(id)}/forward", body: params, **Millionsend::Util.request_options(options) + ).perform_outcome + end + + # DELETE /emails/receiving/:id — erases the message now (full-access + # keys that are not scoped to an inbox). + def remove(id) + Millionsend::Request.new(method: :delete, path: member_path(id)).perform + end + + def member_path(id) + "/emails/receiving/#{Millionsend::Util.encode(id)}" + end + end + + # Attachments of a received email, each with a signed download_url valid + # for one hour. + module Attachments + class << self + # GET /emails/receiving/:email_id/attachments — limit:/after:/before:. + # Also takes resend-ruby's single hash: list(email_id:, limit:). + def list(email_id, options = {}) + if email_id.is_a?(Hash) + options = email_id.reject { |k, _| k == :email_id } + email_id = email_id[:email_id] + end + Millionsend::Request.new( + method: :get, path: "#{Receiving.member_path(email_id)}/attachments", + query: Millionsend::Util.list_query(options) + ).perform + end + + # GET /emails/receiving/:email_id/attachments/:id. Also takes + # resend-ruby's get(email_id:, id:). + def get(email_id, id = nil) + email_id, id = email_id.values_at(:email_id, :id) if email_id.is_a?(Hash) + path = "#{Receiving.member_path(email_id)}/attachments/#{Millionsend::Util.encode(id)}" + Millionsend::Request.new(method: :get, path: path).perform + end + end + end + end + end +end diff --git a/lib/millionsend/request.rb b/lib/millionsend/request.rb index 7654f40..aeb70c4 100644 --- a/lib/millionsend/request.rb +++ b/lib/millionsend/request.rb @@ -36,6 +36,19 @@ def initialize(method:, path:, body: nil, query: nil, idempotency_key: nil, batc end def perform + perform_with_status.last + end + + # Reply, forward and draft send answer 200 once the email went out and 202 + # when the inbox's rules held it as a draft for a person to approve. + def perform_outcome + status, body = perform_with_status + { kind: status == 202 ? "draft" : "sent", data: body } + end + + private + + def perform_with_status api_key = Millionsend.api_key if api_key.nil? || api_key.to_s.empty? # Client-side failure: same class and stable name as a transport error, @@ -61,9 +74,13 @@ def perform handle(response) end - private - def build_uri + # URL normalization resolves an empty, "." or ".." segment away, so an id + # such as ".." would send the request to another resource + # (DELETE /inboxes/{id}/labels/.. deletes the inbox). + dot = @path.split("/", -1).drop(1).any? { |segment| ["", ".", ".."].include?(segment) } + raise ArgumentError, 'an id in the request path is empty, "." or ".."' if dot + base = Millionsend.base_url.to_s.sub(%r{/+\z}, "") uri = URI.parse("#{base}#{@path}") if !Millionsend.allow_insecure_http && Millionsend::Util.insecure_http?(uri) @@ -96,9 +113,12 @@ def build_request(uri, api_key) def handle(response) status = response.code.to_i body = parse(response.body) - raise Millionsend::Error.from_response(status, body) unless (200..299).cover?(status) + unless (200..299).cover?(status) + retry_after = Integer(response["retry-after"], 10, exception: false) + raise Millionsend::Error.from_response(status, body, retry_after) + end - body + [status, body] end def parse(text) diff --git a/lib/millionsend/util.rb b/lib/millionsend/util.rb index 04f4483..2e7dab8 100644 --- a/lib/millionsend/util.rb +++ b/lib/millionsend/util.rb @@ -33,6 +33,13 @@ def list_query(options) { limit: options[:limit], after: options[:after], before: options[:before] } end + # A list or search query as given (Ruby's snake_case is the wire's), with + # arrays comma-joined: the inbox endpoints read CSV and answer 422 to a + # repeated param. nil values are dropped when the query string is built. + def csv_query(options) + (options || {}).transform_values { |v| v.is_a?(Array) ? v.join(",") : v } + end + # ?include= for the contact lists: the names comma-joined, or nil (dropped # from the query) when none were given. def include_query(include) diff --git a/lib/millionsend/version.rb b/lib/millionsend/version.rb index 40b2a0a..4bb43aa 100644 --- a/lib/millionsend/version.rb +++ b/lib/millionsend/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Millionsend - VERSION = "0.8.0" + VERSION = "0.9.0" end diff --git a/lib/millionsend/webhooks.rb b/lib/millionsend/webhooks.rb index 65b5f4e..3f226ed 100644 --- a/lib/millionsend/webhooks.rb +++ b/lib/millionsend/webhooks.rb @@ -5,7 +5,8 @@ module Millionsend module Webhooks class << self # POST /webhooks — endpoint, events[], signing_secret (optional; pass an - # existing whsec_ value to keep a receiver verifying unchanged). + # existing whsec_ value to keep a receiver verifying unchanged), + # inbox_ids (inbox events and email.received for these inboxes only). def create(params) Millionsend::Request.new(method: :post, path: "/webhooks", body: params).perform end @@ -21,7 +22,8 @@ def list(options = {}) Millionsend::Request.new(method: :get, path: "/webhooks", query: Millionsend::Util.list_query(options)).perform end - # PATCH /webhooks/:id — endpoint, events, status ("enabled"/"disabled"). + # PATCH /webhooks/:id — endpoint, events, status ("enabled"/"disabled"), + # inbox_ids (nil: every inbox). def update(id, params) Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform end diff --git a/millionsend.gemspec b/millionsend.gemspec index b7c5f47..5869cc4 100644 --- a/millionsend.gemspec +++ b/millionsend.gemspec @@ -8,8 +8,9 @@ Gem::Specification.new do |spec| spec.summary = "Official Ruby SDK for MillionSend — a self-hostable, Resend-compatible email API." spec.description = "Ruby client for the MillionSend HTTP API: emails, batch, contacts, " \ "contact properties, topics, broadcasts, segments, suppressions, domains, " \ - "webhooks, API keys, templates and usage. Wire-compatible with Resend and " \ - "mirror-shaped after resend-ruby, so migrating is mostly an import swap." + "webhooks, API keys, templates, usage, received mail and inboxes. " \ + "Wire-compatible with Resend and mirror-shaped after resend-ruby, so " \ + "migrating is mostly an import swap." spec.authors = ["MillionSend"] spec.homepage = "https://github.com/MillionSend/millionsend-ruby" spec.license = "MIT" diff --git a/spec/e2e_spec.rb b/spec/e2e_spec.rb index 1cecc75..63d54d5 100644 --- a/spec/e2e_spec.rb +++ b/spec/e2e_spec.rb @@ -20,7 +20,7 @@ end it "creates, reads, updates and deletes a contact" do - email = "sdk-e2e-#{Time.now.to_i}@example.com" + email = "success+sdk-e2e-#{Time.now.to_i}@simulator.amazonses.com" created = Millionsend::Contacts.create(email: email, first_name: "Ada") expect(created[:id]).not_to be_nil diff --git a/spec/millionsend_spec.rb b/spec/millionsend_spec.rb index f63f050..4266eac 100644 --- a/spec/millionsend_spec.rb +++ b/spec/millionsend_spec.rb @@ -138,6 +138,7 @@ def ok(body = '{"id":"e1"}') expect(err.status_code).to eq(422) expect(err.name).to eq("validation_error") expect(err.message).to eq("bad") + expect(err.retry_after).to be_nil end end @@ -164,6 +165,14 @@ def ok(body = '{"id":"e1"}') "concurrent_idempotent_requests" => Millionsend::ConcurrentIdempotentRequestsError, "internal_server_error" => Millionsend::InternalServerError, "all_recipients_suppressed" => Millionsend::AllRecipientsSuppressedError, + "forbidden" => Millionsend::ForbiddenError, + "inbox_send_policy" => Millionsend::InboxSendPolicyError, + "inbox_send_cap_reached" => Millionsend::InboxSendCapReachedError, + "approval_required" => Millionsend::ApprovalRequiredError, + "email_quarantined" => Millionsend::EmailQuarantinedError, + "email_blocked" => Millionsend::EmailBlockedError, + "too_many_waits" => Millionsend::TooManyWaitsError, + "name_review_unavailable" => Millionsend::NameReviewUnavailableError, "unknown_future_name" => Millionsend::ApplicationError, }.each do |name, klass| err = Millionsend::Error.from_response(400, { statusCode: 400, name: name, message: "m" }) @@ -172,6 +181,27 @@ def ok(body = '{"id":"e1"}') end end + it "carries the retry-after header in seconds as retry_after" do + stub_request(:post, "https://api.test/inboxes").to_return( + status: 503, + body: '{"statusCode":503,"name":"name_review_unavailable","message":"Try again in a few minutes"}', + headers: { "Content-Type" => "application/json", "retry-after" => "300" } + ) + expect { Millionsend::Inboxes.create(username: "support-bot") } + .to raise_error(Millionsend::NameReviewUnavailableError) do |err| + expect([err.status_code, err.name, err.retry_after]).to eq([503, "name_review_unavailable", 300]) + end + end + + it "answers an older server's text/plain 404 as ApplicationError with status_code 404" do + stub_request(:get, "https://api.test/emails/e1") + .to_return(status: 404, body: "404 Not Found", headers: { "Content-Type" => "text/plain" }) + + expect { Millionsend::Emails.get("e1") }.to raise_error(Millionsend::ApplicationError) do |err| + expect([err.status_code, err.name]).to eq([404, "application_error"]) + end + end + it "falls back to ApplicationError for a non-canonical error body" do stub_request(:get, "https://api.test/emails/e1").to_return(status: 500, body: "gateway boom") @@ -190,6 +220,19 @@ def ok(body = '{"id":"e1"}') end end + it "never sends a path an empty, \".\" or \"..\" id would turn into another resource's" do + stub_request(:delete, "https://api.test/inboxes/ib1/labels/...").to_return(status: 200, body: "{}") + + [ + -> { Millionsend::Inboxes::Labels.remove("ib1", "..") }, + -> { Millionsend::Inboxes::Threads.remove("ib1", ".") }, + -> { Millionsend::Emails.get("") }, + ].each { |call| expect(&call).to raise_error(ArgumentError, /empty/) } + expect(a_request(:any, /api\.test/)).not_to have_been_made + Millionsend::Inboxes::Labels.remove("ib1", "...") + expect(a_request(:delete, "https://api.test/inboxes/ib1/labels/...")).to have_been_made + end + it "raises ApplicationError with a stable name when no API key is configured" do Millionsend.api_key = nil with_env("MILLIONSEND_API_KEY" => nil) do diff --git a/spec/resources_spec.rb b/spec/resources_spec.rb index dba2ee8..2ac7537 100644 --- a/spec/resources_spec.rb +++ b/spec/resources_spec.rb @@ -317,7 +317,8 @@ def list_ok it "Batch.create posts a bare array to /contacts/batch with on_conflict and the validation header" do stub_request(:post, "https://api.test/contacts/batch").with(query: { "on_conflict" => "upsert" }).to_return(ok( '{"data":[{"object":"contact","index":0,"id":"c1","status":"created"}],' \ - '"counts":{"created":1,"updated":0,"skipped":0,"failed":1},"errors":[{"index":1,"message":"email: invalid"}]}' + '"counts":{"created":1,"updated":0,"skipped":0,"failed":1},' \ + '"errors":[{"index":1,"statusCode":422,"name":"validation_error","message":"email: invalid"}]}' )) res = Millionsend::Contacts::Batch.create( [{ email: "c@x.dev" }, { email: "nope" }], @@ -330,7 +331,7 @@ def list_ok body: [{ "email" => "c@x.dev" }, { "email" => "nope" }])) expect(res[:data]).to eq([{ object: "contact", index: 0, id: "c1", status: "created" }]) expect(res[:counts]).to eq({ created: 1, updated: 0, skipped: 0, failed: 1 }) - expect(res[:errors]).to eq([{ index: 1, message: "email: invalid" }]) + expect(res[:errors]).to eq([{ index: 1, statusCode: 422, name: "validation_error", message: "email: invalid" }]) end it "Batch.create omits on_conflict and the header by default, also under options:" do @@ -646,6 +647,31 @@ def list_ok Millionsend::Domains.remove("d1") expect(WebMock).to have_requested(:delete, "https://api.test/domains/d1") end + + it "lists the team's shared subdomain last with its sending mode, and it is read-only" do + own = { id: "d1", name: "acme.dev", status: "verified", created_at: "2026-10-13T09:00:00.000Z", region: "us-east-1", + open_tracking: true, click_tracking: true, tracking_subdomain: nil, + capabilities: { sending: "enabled", receiving: "disabled" }, receiving_catch_all: false, shared: false } + shared = { id: "sd1", name: "acme.agents.example", status: "verified", created_at: "2026-10-12T09:00:00.000Z", + region: "us-east-1", open_tracking: false, click_tracking: false, tracking_subdomain: nil, + capabilities: { sending: "enabled", receiving: "enabled" }, receiving_catch_all: false, + shared: true, sending_mode: "reply_only", sending_mode_reason: "plan" } + stub_request(:get, "https://api.test/domains") + .to_return(ok(JSON.generate({ object: "list", has_more: false, data: [own, shared] }))) + expect(Millionsend::Domains.list[:data]).to eq([own, shared]) + + open_row = shared.merge(object: "domain", sending_mode: "open", sending_mode_reason: nil, records: []) + stub_request(:get, "https://api.test/domains/sd1").to_return(ok(JSON.generate(open_row))) + expect(Millionsend::Domains.get("sd1")).to include(shared: true, sending_mode_reason: nil, records: []) + + stub_request(:patch, "https://api.test/domains/sd1").to_return( + status: 403, headers: { "Content-Type" => "application/json" }, + body: JSON.generate(statusCode: 403, name: "forbidden", + message: "acme.agents.example is your team's subdomain of the shared domain: it can't be changed or deleted") + ) + expect { Millionsend::Domains.update("sd1", { open_tracking: true }) } + .to raise_error(Millionsend::ForbiddenError) { |e| expect(e.status_code).to eq(403) } + end end describe Millionsend::Webhooks do @@ -786,4 +812,380 @@ def list_ok expect(WebMock).to have_requested(:get, "https://api.test/usage") end end + + # Fixtures in the shapes of the inbox wire contract: Resend's fields plus + # MillionSend's extension keys. + def draft_body(status = "pending_approval") + { object: "inbox_draft", id: "d1", type: "reply", to: ["ada@x.dev"], cc: [], bcc: [], subject: "Re: Hi", + html: nil, text: "Thanks", thread_id: "t1", reply_to_email_id: "m1", email_id: nil, + created_at: "2026-10-12T10:00:00.000Z", updated_at: "2026-10-12T10:00:00.000Z", + status: status, approval_reasons: ["send_policy"], forward: false, decided_by: nil, decided_at: nil, + expires_at: "2026-10-19T10:00:00.000Z" } + end + + def inbox_body + { object: "inbox", id: "i1", name: "Signup bot", email_address: "signup-bot-7f3k@acme.agents.example", + domain_id: "sd1", receiving_address: nil, from_name: nil, unread: 2, drafts: 1, + last_received: "2026-10-12T10:00:00.000Z", created_at: "2026-10-12T09:00:00.000Z", + kind: "agent", shared: true, expires_at: nil, quarantined: 1, pending_approval: 1 } + end + + def thread_body + { object: "inbox_thread", id: "t1", subject: "Confirm your email", folder: "inbox", + labels: [{ id: "l1", name: "Billing", color: "#FFAA00" }], read: false, quarantined: 0 } + end + + describe Millionsend::Emails::Receiving do + it "list puts every filter on the query and returns the long-poll's held ids" do + query = { "limit" => "10", "before" => "r0", "wait" => "30", "since" => "2026-10-12T00:00:00Z", + "inbox_id" => "i1", "to" => "bot@x.dev", "from" => "stripe.com", "subject_contains" => "code", + "unread" => "true", "include_quarantined" => "true" } + body = { object: "list", has_more: false, data: [], + held: [{ id: "r1", inbox_id: "i1", reason: "quarantined", quarantine_reason: "not_allowlisted", + created_at: "2026-10-12T10:00:00.000Z" }] } + stub_request(:get, "https://api.test/emails/receiving").with(query: query).to_return(ok(JSON.generate(body))) + + res = Millionsend::Emails::Receiving.list(limit: 10, before: "r0", wait: 30, since: "2026-10-12T00:00:00Z", + inbox_id: "i1", to: "bot@x.dev", from: "stripe.com", + subject_contains: "code", unread: true, include_quarantined: true) + expect(res[:held]).to eq(body[:held]) + + stub_request(:get, "https://api.test/emails/receiving").to_return(list_ok) + Millionsend::Emails::Receiving.list + expect(WebMock).to have_requested(:get, "https://api.test/emails/receiving").with(query: {}) + end + + it "list answers an unknown inbox_id with not_found, even with wait, and sends unread alone" do + stub_request(:get, "https://api.test/emails/receiving").with(query: { "inbox_id" => "i9", "wait" => "30" }).to_return( + status: 404, body: '{"statusCode":404,"name":"not_found","message":"Inbox not found."}', + headers: { "Content-Type" => "application/json" } + ) + expect { Millionsend::Emails::Receiving.list(inbox_id: "i9", wait: 30) } + .to raise_error(Millionsend::NotFoundError) { |e| expect(e.status_code).to eq(404) } + + stub_request(:get, "https://api.test/emails/receiving").with(query: { "unread" => "false" }).to_return(list_ok) + Millionsend::Emails::Receiving.list(unread: false) + expect(WebMock).to have_requested(:get, "https://api.test/emails/receiving").with(query: { "unread" => "false" }) + end + + it "get passes html_format and keeps the extension fields, unknown values included" do + body = { object: "email", id: "r1", from: "noreply@github.com", subject: "Your code", html_format: "cid", + inbox_id: "i1", thread_id: "t1", spam_verdict: "pass", virus_verdict: nil, spam_score: 3, + screening: { verdict: "clean", sender_trust: "external", quarantine_reason: nil, reasons: [], + injection_score: 2, phishing_score: nil, hidden_text_removed: true }, + extracted_text: "Your code is 482913", + extracted: { kinds: ["code"], code: "482913", link: nil, link_domain_matches_sender: nil, ambiguous: false }, + brand_new_field: { verdict: "cosmic" } } + stub_request(:get, "https://api.test/emails/receiving/r1").with(query: { "html_format" => "cid" }) + .to_return(ok(JSON.generate(body))) + expect(Millionsend::Emails::Receiving.get("r1", html_format: "cid")).to eq(body) + + stub_request(:get, "https://api.test/emails/receiving/r1").to_return(ok('{"object":"email","id":"r1"}')) + Millionsend::Emails::Receiving.get("r1") + expect(WebMock).to have_requested(:get, "https://api.test/emails/receiving/r1").with(query: {}) + end + + it "reply posts the body with an Idempotency-Key and tells a sent reply from a drafted one" do + sent = { object: "email", id: "e9", thread_id: "t1", inbox_id: "i1" } + stub_request(:post, "https://api.test/emails/receiving/r1/reply").to_return(ok(JSON.generate(sent))) + params = { text: "Thanks", subject: "Re: Hi", cc: ["c@x.dev"], bcc: "b@x.dev", reply_all: true, + requires_approval: false, from: "bot@acme.dev", inbox_id: "i1" } + res = Millionsend::Emails::Receiving.reply("r1", params, idempotency_key: "rk-1") + expect(WebMock).to have_requested(:post, "https://api.test/emails/receiving/r1/reply").with( + headers: { "Idempotency-Key" => "rk-1" }, + body: { "text" => "Thanks", "subject" => "Re: Hi", "cc" => ["c@x.dev"], "bcc" => "b@x.dev", "reply_all" => true, + "requires_approval" => false, "from" => "bot@acme.dev", "inbox_id" => "i1" } + ) + expect(res).to eq({ kind: "sent", data: sent }) + + stub_request(:post, "https://api.test/emails/receiving/r1/reply") + .to_return(status: 202, body: JSON.generate(draft_body), headers: { "Content-Type" => "application/json" }) + res = Millionsend::Emails::Receiving.reply("r1", { text: "Thanks" }, options: { idempotency_key: "rk-2" }) + expect(res[:kind]).to eq("draft") + expect(res[:data][:approval_reasons]).to eq(["send_policy"]) + end + + it "forward posts to, mode and the note, and a refusal keeps its error name" do + stub_request(:post, "https://api.test/emails/receiving/r1/forward").to_return(ok('{"object":"email","id":"e9"}')) + Millionsend::Emails::Receiving.forward("r1", { to: ["ops@acme.dev"], text: "FYI", mode: "attachment" }) + expect(WebMock).to(have_requested(:post, "https://api.test/emails/receiving/r1/forward") + .with(body: { "to" => ["ops@acme.dev"], "text" => "FYI", "mode" => "attachment" }) { |req| !req.headers.key?("Idempotency-Key") }) + + stub_request(:post, "https://api.test/emails/receiving/r2/forward").to_return( + status: 403, body: '{"statusCode":403,"name":"email_quarantined","message":"This email is quarantined"}', + headers: { "Content-Type" => "application/json" } + ) + expect { Millionsend::Emails::Receiving.forward("r2", { to: "ops@acme.dev" }) } + .to raise_error(Millionsend::EmailQuarantinedError) { |e| expect([e.status_code, e.name]).to eq([403, "email_quarantined"]) } + end + + it "remove erases with DELETE /emails/receiving/:id" do + stub_request(:delete, "https://api.test/emails/receiving/r1").to_return(ok('{"object":"email","id":"r1","deleted":true}')) + expect(Millionsend::Emails::Receiving.remove("r1")[:deleted]).to eq(true) + end + + it "Attachments list and get take positional ids or resend-ruby's hashes" do + stub_request(:get, "https://api.test/emails/receiving/r1/attachments").with(query: { "limit" => "5" }).to_return(list_ok) + Millionsend::Emails::Receiving::Attachments.list("r1", limit: 5) + Millionsend::Emails::Receiving::Attachments.list(email_id: "r1", limit: 5) + expect(WebMock).to have_requested(:get, "https://api.test/emails/receiving/r1/attachments") + .with(query: { "limit" => "5" }).twice + + stub_request(:get, "https://api.test/emails/receiving/r1/attachments/a1").to_return(ok) + Millionsend::Emails::Receiving::Attachments.get("r1", "a1") + Millionsend::Emails::Receiving::Attachments.get(email_id: "r1", id: "a1") + expect(WebMock).to have_requested(:get, "https://api.test/emails/receiving/r1/attachments/a1").twice + end + end + + describe Millionsend::Inboxes do + it "create posts every field, and an empty create posts {} for a shared-domain address" do + stub_request(:post, "https://api.test/inboxes").to_return(ok(JSON.generate(inbox_body))) + res = Millionsend::Inboxes.create(email_address: "bot@acme.dev", name: "Bot", from_name: "Acme Bot", forwarding: false, + kind: "human", expires_in: "7d", safety: { accept_from: "allowlist", send_cap: nil }) + expect(WebMock).to(have_requested(:post, "https://api.test/inboxes").with do |req| + req.body == '{"email_address":"bot@acme.dev","name":"Bot","from_name":"Acme Bot","forwarding":false,' \ + '"kind":"human","expires_in":"7d","safety":{"accept_from":"allowlist","send_cap":null}}' + end) + expect(res).to eq(inbox_body) + + Millionsend::Inboxes.create + expect(WebMock).to have_requested(:post, "https://api.test/inboxes").with(body: "{}") + end + + it "create sends username and domain_id exactly as given" do + stub_request(:post, "https://api.test/inboxes").to_return(ok(JSON.generate(inbox_body))) + Millionsend::Inboxes.create(username: "leo") + Millionsend::Inboxes.create(domain_id: "sd1") + Millionsend::Inboxes.create(domain_id: "d1", username: "support") + expect(WebMock).to have_requested(:post, "https://api.test/inboxes").with(body: '{"username":"leo"}') + expect(WebMock).to have_requested(:post, "https://api.test/inboxes").with(body: '{"domain_id":"sd1"}') + expect(WebMock).to have_requested(:post, "https://api.test/inboxes").with(body: '{"domain_id":"d1","username":"support"}') + end + + it "covers list/get/update/remove, with an address as the id" do + stub_request(:get, "https://api.test/inboxes").with(query: { "kind" => "agent", "limit" => "2" }).to_return(list_ok) + Millionsend::Inboxes.list(kind: "agent", limit: 2) + expect(WebMock).to have_requested(:get, "https://api.test/inboxes").with(query: { "kind" => "agent", "limit" => "2" }) + + stub_request(:get, "https://api.test/inboxes/bot%2Bsales%40acme.dev").to_return(ok(JSON.generate(inbox_body))) + expect(Millionsend::Inboxes.get("bot+sales@acme.dev")[:quarantined]).to eq(1) + + stub_request(:patch, "https://api.test/inboxes/i1").to_return(ok(JSON.generate(inbox_body))) + Millionsend::Inboxes.update("i1", { name: "Renamed", from_name: "", expires_in: nil }) + expect(WebMock).to(have_requested(:patch, "https://api.test/inboxes/i1") + .with { |req| req.body == '{"name":"Renamed","from_name":"","expires_in":null}' }) + + stub_request(:delete, "https://api.test/inboxes/i1").to_return(ok('{"object":"inbox","id":"i1","deleted":true}')) + expect(Millionsend::Inboxes.remove("i1")[:deleted]).to eq(true) + end + + it "Safety get and update; nil send_cap goes out as null" do + body = { object: "inbox_safety", inbox_id: "i1", accept_from: "verified", allowlist: [], owner_addresses: [], + screening: true, extract_codes: true, send_policy: "reply_only", require_approval: false, + private_codes: true, send_cap: 200, forward_to: [] } + stub_request(:get, "https://api.test/inboxes/i1/safety").to_return(ok(JSON.generate(body))) + expect(Millionsend::Inboxes::Safety.get("i1")).to eq(body) + + stub_request(:patch, "https://api.test/inboxes/i1/safety").to_return(ok(JSON.generate(body))) + Millionsend::Inboxes::Safety.update("i1", { allowlist: ["*@stripe.com"], send_cap: nil }) + expect(WebMock).to(have_requested(:patch, "https://api.test/inboxes/i1/safety") + .with { |req| req.body == '{"allowlist":["*@stripe.com"],"send_cap":null}' }) + end + + it "Codes.latest sends from, wait, max_age and since" do + body = { object: "verification_code", status: "found", kind: "otp", code: "482913", url: nil, + link_domain_matches_sender: nil, email_id: "r1", from: "noreply@github.com", + received_at: "2026-10-12T10:00:00.000Z", sender_trust: "external" } + query = { "from" => "github.com", "wait" => "20", "max_age" => "600" } + stub_request(:get, "https://api.test/inboxes/i1/codes/latest").with(query: query).to_return(ok(JSON.generate(body))) + expect(Millionsend::Inboxes::Codes.latest("i1", from: "github.com", wait: 20, max_age: 600)).to eq(body) + + since = { "since" => "2026-10-16T12:00:00.000Z" } + stub_request(:get, "https://api.test/inboxes/i1/codes/latest").with(query: since).to_return(ok(JSON.generate(body))) + Millionsend::Inboxes::Codes.latest("i1", since: "2026-10-16T12:00:00.000Z") + expect(WebMock).to have_requested(:get, "https://api.test/inboxes/i1/codes/latest").with(query: since) + end + + it "Quarantine list and release, {} by default" do + stub_request(:get, "https://api.test/inboxes/i1/quarantine").with(query: { "limit" => "5" }).to_return(list_ok) + Millionsend::Inboxes::Quarantine.list("i1", limit: 5) + expect(WebMock).to have_requested(:get, "https://api.test/inboxes/i1/quarantine").with(query: { "limit" => "5" }) + + body = { object: "inbox_release", inbox_id: "i1", email_id: "r1", released_at: "2026-10-12T10:00:00.000Z", + trusted_sender: "ada@x.dev" } + stub_request(:post, "https://api.test/inboxes/i1/quarantine/r1/release").to_return(ok(JSON.generate(body))) + Millionsend::Inboxes::Quarantine.release("i1", "r1") + expect(WebMock).to have_requested(:post, "https://api.test/inboxes/i1/quarantine/r1/release").with(body: "{}") + expect(Millionsend::Inboxes::Quarantine.release("i1", "r1", trust_sender: true)).to eq(body) + expect(WebMock).to have_requested(:post, "https://api.test/inboxes/i1/quarantine/r1/release") + .with(body: { "trust_sender" => true }) + end + + it "Labels cover list/create/update/remove" do + stub_request(:get, "https://api.test/inboxes/i1/labels") + .to_return(ok('{"object":"list","data":[{"id":"l1","name":"Billing","color":"#FFAA00","created_at":"2026-10-12T10:00:00.000Z"}]}')) + expect(Millionsend::Inboxes::Labels.list("i1")[:data][0][:color]).to eq("#FFAA00") + + stub_request(:post, "https://api.test/inboxes/i1/labels").to_return(ok('{"object":"inbox_label","id":"l1"}')) + Millionsend::Inboxes::Labels.create("i1", { name: "Billing", color: "#FFAA00" }) + expect(WebMock).to have_requested(:post, "https://api.test/inboxes/i1/labels") + .with(body: { "name" => "Billing", "color" => "#FFAA00" }) + + stub_request(:patch, "https://api.test/inboxes/i1/labels/l1").to_return(ok('{"object":"inbox_label","id":"l1"}')) + Millionsend::Inboxes::Labels.update("i1", "l1", { name: "Invoices" }) + expect(WebMock).to have_requested(:patch, "https://api.test/inboxes/i1/labels/l1").with(body: { "name" => "Invoices" }) + + stub_request(:delete, "https://api.test/inboxes/i1/labels/l1").to_return(ok('{"object":"inbox_label","id":"l1","deleted":true}')) + Millionsend::Inboxes::Labels.remove("i1", "l1") + expect(WebMock).to have_requested(:delete, "https://api.test/inboxes/i1/labels/l1") + end + + it "Threads list and search send arrays as one comma-separated value; quarantined only when asked" do + item = { id: "t1", subject: "Invoice 42", from: "Stripe ", to: ["bot@acme.dev"], cc: [], bcc: [], + labels: [], message_count: 2, has_attachment: true, has_draft: false, read: true, + received_at: "2026-10-12T10:00:00.000Z", folder: "inbox" } + query = { "folders" => "inbox,archive", "labels" => "l1,l2", "read" => "false", "include_quarantined" => "true", + "limit" => "20", "after" => "t0" } + stub_request(:get, "https://api.test/inboxes/i1/threads").with(query: query) + .to_return(ok(JSON.generate({ object: "list", has_more: false, data: [item.merge(quarantined: 2)] }))) + res = Millionsend::Inboxes::Threads.list("i1", folders: %w[inbox archive], labels: %w[l1 l2], read: false, + include_quarantined: true, limit: 20, after: "t0") + expect(WebMock).to have_requested(:get, "https://api.test/inboxes/i1/threads").with(query: query) + expect(res[:data][0][:quarantined]).to eq(2) + + search = { "query" => "\"invoice 42\" -spam", "from" => "stripe.com,ada", "to" => "bot@acme.dev", + "has_attachment" => "true", "start_date" => "2026-10-01", "end_date" => "2026-10-12", "folders" => "sent" } + found = item.merge(matched_email_id: "m2", highlights: { subject: ["**Invoice** 42"] }) + stub_request(:get, "https://api.test/inboxes/i1/threads/search").with(query: search) + .to_return(ok(JSON.generate({ object: "list", has_more: false, data: [found] }))) + res = Millionsend::Inboxes::Threads.search("i1", query: "\"invoice 42\" -spam", from: ["stripe.com", "ada"], + to: ["bot@acme.dev"], has_attachment: true, start_date: "2026-10-01", + end_date: "2026-10-12", folders: ["sent"]) + expect(res[:data][0][:highlights]).to eq({ subject: ["**Invoice** 42"] }) + expect(res[:data][0]).not_to have_key(:quarantined) + end + + it "Threads get/update/remove" do + stub_request(:get, "https://api.test/inboxes/i1/threads/t1").to_return(ok(JSON.generate(thread_body))) + expect(Millionsend::Inboxes::Threads.get("i1", "t1")).to eq(thread_body) + + stub_request(:patch, "https://api.test/inboxes/i1/threads/t1").to_return(ok(JSON.generate(thread_body))) + Millionsend::Inboxes::Threads.update("i1", "t1", { read: true, folder: "archive", label_id: "l1" }) + expect(WebMock).to have_requested(:patch, "https://api.test/inboxes/i1/threads/t1") + .with(body: { "read" => true, "folder" => "archive", "label_id" => "l1" }) + + stub_request(:delete, "https://api.test/inboxes/i1/threads/t1").to_return(ok('{"object":"inbox_thread","id":"t1","deleted":false}')) + expect(Millionsend::Inboxes::Threads.remove("i1", "t1")[:deleted]).to eq(false) + end + + it "Threads::Emails list/get/reply/forward, a reply held as a draft included" do + query = { "after" => "m1", "include_quarantined" => "true" } + stub_request(:get, "https://api.test/inboxes/i1/threads/t1/emails").with(query: query).to_return(list_ok) + Millionsend::Inboxes::Threads::Emails.list("i1", "t1", after: "m1", include_quarantined: true) + expect(WebMock).to have_requested(:get, "https://api.test/inboxes/i1/threads/t1/emails").with(query: query) + + email = { id: "m1", direction: "inbound", from: "Ada ", to: ["bot@acme.dev"], cc: [], bcc: [], reply_to: [], + subject: "Hi", message_id: "", html: nil, text: "Hello", attachments: [{ id: "a1", filename: nil, size: nil }], + read: false, received_at: "2026-10-12T10:00:00.000Z", extracted_text: "Hello", spam_verdict: "gray", + virus_verdict: "processing_failed", + screening: { verdict: "suspicious", sender_trust: "unauthenticated", quarantine_reason: nil, reasons: [], + injection_score: nil, phishing_score: nil, hidden_text_removed: false } } + stub_request(:get, "https://api.test/inboxes/i1/threads/t1/emails/m1").to_return(ok(JSON.generate(email))) + expect(Millionsend::Inboxes::Threads::Emails.get("i1", "t1", "m1")).to eq(email) + expect(WebMock).to have_requested(:get, "https://api.test/inboxes/i1/threads/t1/emails/m1").with(query: {}) + stub_request(:get, "https://api.test/inboxes/i1/threads/t1/emails/m1").with(query: { "include_quarantined" => "true" }) + .to_return(ok(JSON.generate(email))) + Millionsend::Inboxes::Threads::Emails.get("i1", "t1", "m1", include_quarantined: true) + expect(WebMock).to have_requested(:get, "https://api.test/inboxes/i1/threads/t1/emails/m1") + .with(query: { "include_quarantined" => "true" }) + + sent = email.merge(id: "m2", direction: "outbound", read: true, email_id: "m2", thread_id: "t1", inbox_id: "i1", status: "queued") + stub_request(:post, "https://api.test/inboxes/i1/threads/t1/emails/m1/reply").to_return(ok(JSON.generate(sent))) + res = Millionsend::Inboxes::Threads::Emails.reply("i1", "t1", "m1", { text: "Thanks", reply_all: true, requires_approval: true }, + idempotency_key: "tr-1") + expect(WebMock).to have_requested(:post, "https://api.test/inboxes/i1/threads/t1/emails/m1/reply").with( + headers: { "Idempotency-Key" => "tr-1" }, body: { "text" => "Thanks", "reply_all" => true, "requires_approval" => true } + ) + expect(res).to eq({ kind: "sent", data: sent }) + + stub_request(:post, "https://api.test/inboxes/i1/threads/t1/emails/m1/forward") + .to_return(status: 202, body: JSON.generate(draft_body), headers: { "Content-Type" => "application/json" }) + res = Millionsend::Inboxes::Threads::Emails.forward("i1", "t1", "m1", { to: "ops@acme.dev", cc: ["c@acme.dev"], subject: "Fwd: Hi" }) + expect(WebMock).to have_requested(:post, "https://api.test/inboxes/i1/threads/t1/emails/m1/forward") + .with(body: { "to" => "ops@acme.dev", "cc" => ["c@acme.dev"], "subject" => "Fwd: Hi" }) + expect(res).to eq({ kind: "draft", data: draft_body }) + end + + it "Drafts cover list/create/get/update/remove/reject" do + stub_request(:get, "https://api.test/inboxes/i1/drafts").with(query: { "limit" => "3" }).to_return(list_ok) + Millionsend::Inboxes::Drafts.list("i1", limit: 3) + expect(WebMock).to have_requested(:get, "https://api.test/inboxes/i1/drafts").with(query: { "limit" => "3" }) + + stub_request(:post, "https://api.test/inboxes/i1/drafts").to_return(ok(JSON.generate(draft_body("draft")))) + Millionsend::Inboxes::Drafts.create("i1", { to: ["ada@x.dev"], subject: "Re: Hi", text: "Thanks", thread_id: "t1", + reply_to_email_id: "m1" }) + expect(WebMock).to have_requested(:post, "https://api.test/inboxes/i1/drafts").with( + body: { "to" => ["ada@x.dev"], "subject" => "Re: Hi", "text" => "Thanks", "thread_id" => "t1", "reply_to_email_id" => "m1" } + ) + + stub_request(:get, "https://api.test/inboxes/i1/drafts/d1").to_return(ok(JSON.generate(draft_body))) + expect(Millionsend::Inboxes::Drafts.get("i1", "d1")[:status]).to eq("pending_approval") + + stub_request(:patch, "https://api.test/inboxes/i1/drafts/d1").to_return(ok(JSON.generate(draft_body("draft")))) + Millionsend::Inboxes::Drafts.update("i1", "d1", { subject: nil, cc: [], html: "

Thanks

" }) + expect(WebMock).to(have_requested(:patch, "https://api.test/inboxes/i1/drafts/d1") + .with { |req| req.body == '{"subject":null,"cc":[],"html":"

Thanks

"}' }) + + stub_request(:delete, "https://api.test/inboxes/i1/drafts/d1").to_return(ok('{"object":"inbox_draft","id":"d1","deleted":true}')) + Millionsend::Inboxes::Drafts.remove("i1", "d1") + expect(WebMock).to have_requested(:delete, "https://api.test/inboxes/i1/drafts/d1") + + stub_request(:post, "https://api.test/inboxes/i1/drafts/d1/reject").to_return(ok(JSON.generate(draft_body("rejected")))) + expect(Millionsend::Inboxes::Drafts.reject("i1", "d1")[:status]).to eq("rejected") + expect(WebMock).to(have_requested(:post, "https://api.test/inboxes/i1/drafts/d1/reject").with { |req| req.body.to_s.empty? }) + end + + it "Drafts.send posts no body, takes an Idempotency-Key and reports a held draft" do + sent = { object: "inbox_draft", id: "d1", thread_id: "t1", email_id: "e9" } + stub_request(:post, "https://api.test/inboxes/i1/drafts/d1/send").to_return(ok(JSON.generate(sent))) + expect(Millionsend::Inboxes::Drafts.send("i1", "d1", idempotency_key: "ds-1")).to eq({ kind: "sent", data: sent }) + expect(WebMock).to(have_requested(:post, "https://api.test/inboxes/i1/drafts/d1/send") + .with(headers: { "Idempotency-Key" => "ds-1" }) { |req| req.body.to_s.empty? && !req.headers.key?("Content-Type") }) + + stub_request(:post, "https://api.test/inboxes/i1/drafts/d2/send") + .to_return(status: 202, body: JSON.generate(draft_body), headers: { "Content-Type" => "application/json" }) + expect(Millionsend::Inboxes::Drafts.send("i1", "d2")).to eq({ kind: "draft", data: draft_body }) + + stub_request(:post, "https://api.test/inboxes/i1/drafts/d3/send").to_return( + status: 403, body: '{"statusCode":403,"name":"approval_required","message":"A person must approve this draft"}', + headers: { "Content-Type" => "application/json" } + ) + expect { Millionsend::Inboxes::Drafts.send("i1", "d3") } + .to raise_error(Millionsend::ApprovalRequiredError) { |e| expect(e.name).to eq("approval_required") } + end + end + + describe "inbox fields on existing resources" do + it "passes receiving_catch_all, inbox_id and inbox_ids through" do + stub_request(:patch, "https://api.test/domains/d1").to_return(ok('{"object":"domain","id":"d1","receiving_catch_all":true}')) + expect(Millionsend::Domains.update("d1", { receiving_catch_all: true })[:receiving_catch_all]).to eq(true) + expect(WebMock).to have_requested(:patch, "https://api.test/domains/d1").with(body: { "receiving_catch_all" => true }) + + stub_request(:post, "https://api.test/api-keys").to_return(ok('{"id":"k1","token":"ms_x"}')) + Millionsend::ApiKeys.create({ name: "agent", permission: "full_access", inbox_id: "i1" }) + expect(WebMock).to have_requested(:post, "https://api.test/api-keys") + .with(body: { "name" => "agent", "permission" => "full_access", "inbox_id" => "i1" }) + + stub_request(:post, "https://api.test/webhooks").to_return(ok('{"object":"webhook","id":"w1","signing_secret":"whsec_x"}')) + Millionsend::Webhooks.create({ endpoint: "https://x.dev/h", events: ["email.received", "inbox.email.received"], inbox_ids: ["i1"] }) + expect(WebMock).to have_requested(:post, "https://api.test/webhooks").with( + body: { "endpoint" => "https://x.dev/h", "events" => ["email.received", "inbox.email.received"], "inbox_ids" => ["i1"] } + ) + + stub_request(:patch, "https://api.test/webhooks/w1").to_return(ok('{"object":"webhook","id":"w1"}')) + Millionsend::Webhooks.update("w1", { inbox_ids: nil }) + expect(WebMock).to(have_requested(:patch, "https://api.test/webhooks/w1").with { |req| req.body == '{"inbox_ids":null}' }) + end + end end