millionsend 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e916d31911bcbb75dc0c6aae5f671a2034a6aff4eac3f0b9be4e9eaa58b5c747
4
- data.tar.gz: ba3d5063d02bd243660dfe04dee0780a84b2bad05cd11cd2c2690f3ffcfeca2a
3
+ metadata.gz: fa0ca4ce8b25420cf85d8856412e9357b5034ceebc74a54f44e78a97125e2322
4
+ data.tar.gz: f06f818f69f4cf130bda91fc0be22bc2aaba87350dc2ce551932a066e9c7e58f
5
5
  SHA512:
6
- metadata.gz: 5c5d558bf5d08c705e8ae11ba6a6ed5e7cf6220a9629eb5a83bcc5570a713e669b5a7718b6d0f10b8bef00c5bf730d1e89599f6072948d76931cffe675b1d46c
7
- data.tar.gz: 65edabd5b84e8f224045a7ebaab207f962221a75aab27ca32330435bee190a15834c45a06c7fcaeeeda1a77c46c305be4a314304d256968657d4c1886ad12ef3
6
+ metadata.gz: a6f1c6a821b2ae1762923e517a0992800862d963f7314b49a0f5997084bd99524ddbb07d8c8c3d0b0d0032c2abd03136cb804c9c167093e09df04e859aa8c5da
7
+ data.tar.gz: 277d2022d625d3a2f253db7a357cf7d3c455a88bd7b60d8ced220a56e34cd0721d3825a19d751b67fde679fb49ad60c351a7709021197a568974f048dd99fef8
data/README.md CHANGED
@@ -31,7 +31,7 @@ Millionsend.api_key = "ms_123"
31
31
 
32
32
  email = Millionsend::Emails.send(
33
33
  from: "Acme <onboarding@acme.dev>",
34
- to: "delivered@resend.dev",
34
+ to: "success@simulator.amazonses.com",
35
35
  subject: "Hello from MillionSend",
36
36
  html: "<strong>It works!</strong>"
37
37
  )
@@ -66,8 +66,8 @@ leave it unchanged.
66
66
 
67
67
  ## Request options
68
68
 
69
- `Emails.send`, `Batch.send` and `Contacts::Batch.create` take a trailing options hash, in
70
- either of two shapes:
69
+ `Emails.send`, `Batch.send`, `Contacts::Batch.create` and the receiving and inbox replies,
70
+ forwards and `Drafts.send` take a trailing options hash, in either of two shapes:
71
71
 
72
72
  ```ruby
73
73
  Millionsend::Emails.send(payload, idempotency_key: "order-42") # flat
@@ -82,7 +82,33 @@ Millionsend::Emails.send(payload, options: { idempotency_key: "order-42" }) # r
82
82
 
83
83
  `batch_validation` is `"strict"` by default (one invalid item rejects the whole batch) or
84
84
  `"permissive"` (the valid subset is processed and the failures come back under `errors:`
85
- as `[{ index:, message: }]`).
85
+ as `[{ index:, message: }]`; `Contacts::Batch.create` adds each failure's `statusCode:` and
86
+ `name:`).
87
+
88
+ ## Pagination
89
+
90
+ Lists answer `{ object: "list", data: [...], has_more: }`, and the SDK never pages for you.
91
+ Most lists are newest first: `after:` takes the last id of a page and pages toward older
92
+ items, `before:` takes the first id and pages toward newer ones (a `before:` page is still
93
+ newest first). Threads come most recently active first, a thread's emails oldest first
94
+ (`after:` pages toward newer), drafts most recently updated first; labels and topics are
95
+ unpaginated.
96
+
97
+ ```ruby
98
+ # Incremental sync: only what is newer than the newest id already stored
99
+ page = Millionsend::Emails.list(limit: 100, before: newest_id)
100
+ until page[:data].empty?
101
+ store(page[:data])
102
+ newest_id = page[:data].first[:id]
103
+ break unless page[:has_more]
104
+
105
+ page = Millionsend::Emails.list(limit: 100, before: newest_id)
106
+ end
107
+ ```
108
+
109
+ A cursor whose item was deleted raises `ValidationError` (`NotFoundError` on the `Inboxes`
110
+ lists): start again without a cursor and stop at the ids you already have. Servers before
111
+ v0.13.0 listed oldest first; walking every page with `after:` returns every item on both.
86
112
 
87
113
  ## Resources
88
114
 
@@ -108,6 +134,150 @@ The send payload accepts `from`, `to`, `subject`, `html`, `text`, `cc`, `bcc`, `
108
134
  (as is `Batch.create`), mirroring Resend. `Emails.update` also takes resend-ruby's single
109
135
  hash: `Emails.update(email_id: id, scheduled_at: ...)`.
110
136
 
137
+ `subject` takes up to 998 characters here, in batches and in broadcasts (inbox replies,
138
+ forwards and drafts take 2,000). A `to`, `cc` or `bcc` address whose domain can never receive
139
+ mail (`example.com`, `.test`, `.invalid`, a TLD that doesn't exist, …) raises `ValidationError`,
140
+ on replies, forwards and drafts too, and so does such an address on a new contact. To test
141
+ sending without emailing anyone, use `success@simulator.amazonses.com`.
142
+
143
+ `from` may be an inbox's address on the [shared domain](#the-shared-domain) while the shared row
144
+ in `Domains.list` has `sending_mode: "open"`: the inbox's exact `email_address` (no `+tag`),
145
+ no display name or its `from_name`, no `reply_to`, `attachments`, `headers` or `scheduled_at`,
146
+ and at most 10 distinct recipients across `to`, `cc` and `bcc`. `Batch.send` and broadcasts
147
+ never send from the shared domain: `InboxSendPolicyError`, or that item's entry in a
148
+ permissive batch's `errors:`.
149
+
150
+ ### Receiving
151
+
152
+ Mail your domains receive: Resend's `Emails::Receiving` plus MillionSend's long-poll, server-side
153
+ reply and forward, and erase. Inbox-scoped keys never see mail held for screening or in quarantine.
154
+
155
+ ```ruby
156
+ page = Millionsend::Emails::Receiving.list(limit: 20) # GET /emails/receiving
157
+ email = Millionsend::Emails::Receiving.get(id, html_format: "cid") # GET /emails/receiving/:id
158
+ email[:extracted_text] # the visible text: hidden text and invisible characters removed
159
+ email[:screening] # { verdict:, sender_trust:, quarantine_reason:, injection_score:, ... }
160
+ email[:extracted] # { kinds:, code:, link:, ... } on verification mail
161
+ Millionsend::Emails::Receiving::Attachments.list(id) # each with a signed download_url (1 hour)
162
+ Millionsend::Emails::Receiving::Attachments.get(id, attachment_id) # also get(email_id:, id:) as in resend-ruby
163
+ Millionsend::Emails::Receiving.remove(id) # erase now (full-access keys)
164
+
165
+ # Long-poll: wait up to 50 s for the next match
166
+ page = Millionsend::Emails::Receiving.list(inbox_id: inbox[:id], from: "github.com", wait: 30)
167
+ page[:data] # what arrived
168
+ page[:held] # [{ id:, inbox_id:, reason:, quarantine_reason:, created_at: }] — matches held for screening or quarantine
169
+
170
+ # Reply and forward from the server, threaded
171
+ res = Millionsend::Emails::Receiving.reply(id, { text: "Thanks!" }, idempotency_key: "reply-#{id}")
172
+ res = Millionsend::Emails::Receiving.forward(id, { to: "ops@acme.dev", mode: "attachment" }) # the original as an .eml
173
+ ```
174
+
175
+ List options go on the query as given: `limit`, `after`, `before`, `wait` (0–50), `since`,
176
+ `inbox_id` (an unknown one raises `NotFoundError` at once, even with `wait`), `to`, `from` (an
177
+ address, or a domain covering its subdomains), `subject_contains`, `unread` (with `inbox_id`,
178
+ or alone on an inbox-scoped key), and `include_quarantined` or `quarantined` (held mail too, or
179
+ only; full-access keys). `reply` also takes `subject`, `cc`, `bcc`, `reply_all`,
180
+ `requires_approval`, `from` and `inbox_id`.
181
+
182
+ `reply` and `forward` return `{ kind: "sent", data: { object: "email", id:, thread_id:, inbox_id: } }`,
183
+ or `{ kind: "draft", data: <draft> }` when an inbox's rules hold the message for a person to
184
+ approve (HTTP 202); the draft's `approval_reasons` say why.
185
+
186
+ ### Inboxes (beta)
187
+
188
+ Addresses whose mail lands in threads, for an agent (`kind: "agent"`, the default: screening, code
189
+ extraction and send rules on) or for people (`kind: "human"`). Mirrors Resend's Inboxes beta;
190
+ `Safety`, `Codes` and `Quarantine` are MillionSend extensions.
191
+
192
+ ```ruby
193
+ inbox = Millionsend::Inboxes.create(name: "Signup bot", username: "signup-bot") # signup-bot@<team>.<shared domain>
194
+ inbox = Millionsend::Inboxes.create(email_address: "support@acme.dev", kind: "human")
195
+ inbox = Millionsend::Inboxes.create(domain_id: domain[:id], username: "billing") # billing@<that domain>
196
+ Millionsend::Inboxes.list(kind: "agent")
197
+ Millionsend::Inboxes.get("support@acme.dev") # by id or address
198
+ Millionsend::Inboxes.update(inbox[:id], name: "Support", from_name: "Acme Support", expires_in: nil) # nil: never expires
199
+ Millionsend::Inboxes.remove(inbox[:id]) # also revokes its inbox-scoped keys
200
+
201
+ # Safety rules: who may write in and what an agent may send
202
+ Millionsend::Inboxes::Safety.get(inbox[:id])
203
+ Millionsend::Inboxes::Safety.update(inbox[:id], accept_from: "allowlist", allowlist: ["*@stripe.com"], send_cap: nil)
204
+
205
+ # Verification codes and sign-in links (links are never fetched); waits up to 50 s
206
+ code = Millionsend::Inboxes::Codes.latest(inbox[:id], from: "github.com", wait: 30, max_age: 600)
207
+ code[:status] # "found" | "none" | "ambiguous" | "timeout"
208
+ code[:code] # "482913", or code[:url] for a sign-in link
209
+ # A second sign-in waits for its own code: only mail received after the one already used counts
210
+ Millionsend::Inboxes::Codes.latest(inbox[:id], from: "github.com", wait: 30, since: code[:received_at])
211
+
212
+ # Quarantine: held mail is kept and listed; agents see it only once a person releases it
213
+ Millionsend::Inboxes::Quarantine.list(inbox[:id])
214
+ Millionsend::Inboxes::Quarantine.release(inbox[:id], email_id, trust_sender: true) # also allowlists the sender
215
+
216
+ # Threads and their messages
217
+ Millionsend::Inboxes::Threads.list(inbox[:id], folders: ["inbox", "archive"], read: false)
218
+ Millionsend::Inboxes::Threads.search(inbox[:id], query: "invoice", from: ["stripe.com"])
219
+ Millionsend::Inboxes::Threads.get(inbox[:id], thread_id)
220
+ Millionsend::Inboxes::Threads.update(inbox[:id], thread_id, read: true, folder: "archive", label_id: label_id)
221
+ Millionsend::Inboxes::Threads.remove(inbox[:id], thread_id) # to trash, purged after 30 days
222
+ Millionsend::Inboxes::Threads::Emails.list(inbox[:id], thread_id) # oldest first; include_quarantined: true for held mail too
223
+ Millionsend::Inboxes::Threads::Emails.get(inbox[:id], thread_id, email_id)
224
+ res = Millionsend::Inboxes::Threads::Emails.reply(inbox[:id], thread_id, email_id, { text: "On it." }, idempotency_key: "k1")
225
+ Millionsend::Inboxes::Threads::Emails.forward(inbox[:id], thread_id, email_id, { to: "ops@acme.dev" })
226
+
227
+ # Labels
228
+ label = Millionsend::Inboxes::Labels.create(inbox[:id], name: "Billing", color: "#FFAA00")
229
+ Millionsend::Inboxes::Labels.list(inbox[:id])
230
+ Millionsend::Inboxes::Labels.update(inbox[:id], label[:id], name: "Invoices")
231
+ Millionsend::Inboxes::Labels.remove(inbox[:id], label[:id])
232
+
233
+ # Drafts
234
+ draft = Millionsend::Inboxes::Drafts.create(inbox[:id], to: ["ada@x.dev"], subject: "Hello", text: "Hi Ada")
235
+ Millionsend::Inboxes::Drafts.list(inbox[:id])
236
+ Millionsend::Inboxes::Drafts.get(inbox[:id], draft[:id])
237
+ Millionsend::Inboxes::Drafts.update(inbox[:id], draft[:id], subject: nil) # nil clears
238
+ Millionsend::Inboxes::Drafts.send(inbox[:id], draft[:id], idempotency_key: "draft-1")
239
+ Millionsend::Inboxes::Drafts.reject(inbox[:id], draft[:id]) # approvers only
240
+ Millionsend::Inboxes::Drafts.remove(inbox[:id], draft[:id])
241
+ ```
242
+
243
+ Thread replies, forwards and `Drafts.send` return `{ kind: "sent", data: ... }`, or
244
+ `{ kind: "draft", data: <draft> }` when the inbox's rules hold the message for approval; an
245
+ agent's reply to a Reply-To at another organization is held with `approval_reasons` including
246
+ `"reply_to_redirect"`. An inbox-scoped key or an MCP token cannot send a draft that waits for
247
+ approval (`ApprovalRequiredError`); a person in the dashboard or a full-access team key can.
248
+ Arrays in list and search options (`folders`, `labels`, `from`, `to`, `cc`, `bcc`) go out as one
249
+ comma-separated value. With `include_quarantined: true` (full-access team keys), `Threads.list`
250
+ and `search` items carry `quarantined:`, the count of held messages. Inbox names take 1–320
251
+ characters. Resend's hosted agent settings (`/inboxes/:id/agent`) are not exposed: MillionSend
252
+ runs no agent.
253
+
254
+ #### The shared domain
255
+
256
+ Without `email_address`, an inbox gets an address on the team's subdomain of the instance's
257
+ shared domain, which needs no DNS. `username` picks the part before the @ (1–64 of
258
+ `a-z 0-9 . _ -`, starting and ending with a letter or digit), and it is random when omitted.
259
+ `domain_id` takes the id of a `Domains.list` row in place of `email_address`: the shared row
260
+ works like leaving it out, and one of your own domains needs `username`. Reserved names
261
+ (`postmaster`, `admin`, `support`, …), a `fwd-` prefix and brand look-alikes raise
262
+ `ValidationError`, and so does an address already in use, with status 409.
263
+
264
+ A chosen `username` or a shared inbox's `from_name` is reviewed first. While that review is
265
+ down, `create` and `update` raise `NameReviewUnavailableError` (503, `retry_after` 60), and so
266
+ does every shared or forwarding `create` while a new team's subdomain still waits for its own
267
+ review (`retry_after` 300); nothing was created, so retry once `e.retry_after` seconds have
268
+ passed. Too many names reviewed or refused in a day raise `RateLimitExceededError` until the next
269
+ UTC midnight. Inboxes never expire unless `expires_in` says so (`"1h"` to `"365d"`), but a
270
+ shared-domain inbox left unused for 90 days is deleted.
271
+
272
+ What a shared address may send follows the shared row's `sending_mode`: `"open"` starts
273
+ conversations (`Emails.send` too), `"reply_only"` replies inside its threads, and
274
+ `"receive_only"` sends nothing; an inbox whose name looks like a brand only replies, even under
275
+ `"open"`. Refusals raise `InboxSendPolicyError` (403), `InboxSendCapReachedError` (429, the
276
+ team's daily cap, with `retry_after` running to the next UTC midnight) or `ValidationError`
277
+ (more than 10 recipients). On a shared inbox, `Safety.update` accepts `send_policy: "open"` only
278
+ while the mode is `"open"` and refuses `"allowlist"`; `owner_addresses` don't make senders
279
+ trusted there, and `forward_to` copies go to members' sign-in addresses only.
280
+
111
281
  ### Contacts
112
282
 
113
283
  Contacts are team-global — one list per team, no audiences.
@@ -246,9 +416,22 @@ Millionsend::Domains.get(domain[:id])
246
416
  Millionsend::Domains.list
247
417
  Millionsend::Domains.verify(domain[:id]) # re-check DNS now
248
418
  Millionsend::Domains.update(domain[:id], open_tracking: false, click_tracking: true, tracking_subdomain: nil) # nil clears
419
+ Millionsend::Domains.update(domain[:id], receiving_catch_all: true) # keep mail to addresses without an inbox (paid plans on Cloud)
249
420
  Millionsend::Domains.remove(domain[:id])
250
421
  ```
251
422
 
423
+ `Domains.list` ends with the team's subdomain of the [shared domain](#the-shared-domain), after
424
+ all of your own domains; there is none when the instance has no shared domain, or while a new
425
+ team's subdomain waits for its name review. Every row carries `shared:` (absent before server
426
+ v0.14.0, so `nil` reads as false); the shared row adds `sending_mode` (`"open"`, `"reply_only"`
427
+ or `"receive_only"`) and `sending_mode_reason` (`"plan"`, `"instance"`, or `nil` when open). It
428
+ has no DNS records (`records: []`), and `update`, `verify` and `remove` raise `ForbiddenError` on
429
+ it. It only sends from an inbox's own address, so skip it when picking a sender domain:
430
+
431
+ ```ruby
432
+ sender = Millionsend::Domains.list[:data].find { |d| d[:status] == "verified" && !d[:shared] }
433
+ ```
434
+
252
435
  ### Webhooks
253
436
 
254
437
  ```ruby
@@ -261,6 +444,7 @@ hook[:signing_secret]
261
444
  Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret and previous_secret_expires_at
262
445
  Millionsend::Webhooks.list
263
446
  Millionsend::Webhooks.update(hook[:id], events: ["email.opened"], status: "disabled")
447
+ Millionsend::Webhooks.update(hook[:id], inbox_ids: [inbox[:id]]) # inbox events for these inboxes only; nil: every inbox
264
448
  Millionsend::Webhooks.remove(hook[:id])
265
449
 
266
450
  # Rotate the signing secret (MillionSend extension) — POST /webhooks/:id/rotate
@@ -278,13 +462,22 @@ Events: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounce
278
462
  `email.complained`, `email.opened`, `email.clicked`, `contact.created`, `contact.updated`,
279
463
  `contact.deleted`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`,
280
464
  `contact.topic_opt_out`, `suppression.added`, `suppression.removed`, `deliverability.warning`,
281
- `deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`.
465
+ `deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`, `email.received`, and
466
+ the inbox events `inbox.created`, `inbox.updated`, `inbox.deleted`, `inbox.expired`,
467
+ `inbox.thread.created`, `inbox.thread.folder.updated`, `inbox.thread.labels.updated`,
468
+ `inbox.email.received`, `inbox.email.sent`, `inbox.email.quarantined`, `inbox.draft.created`,
469
+ `inbox.draft.updated`, `inbox.draft.sent`, `inbox.draft.deleted`. Inbox events are opt-in: an
470
+ endpoint gets them only when its `events` list names them. A shared-domain inbox deleted after
471
+ 90 unused days sends `inbox.expired`, then `inbox.deleted`, and inbox events from a send in the
472
+ dashboard carry `source: "dashboard"` in their `data`.
282
473
 
283
474
  ### API keys
284
475
 
285
476
  ```ruby
286
- key = Millionsend::ApiKeys.create(name: "ci", permission: "sending_access", domain_id: domain[:id])
477
+ key = Millionsend::ApiKeys.create(name: "ci", permission: "sending_access", domain_id: domain[:id]) # a shared: false row
287
478
  key[:token] # only returned here
479
+ agent_key = Millionsend::ApiKeys.create(name: "signup-bot", permission: "full_access", inbox_id: inbox[:id])
480
+ # reads and sends for that one inbox only; list rows carry inbox_id
288
481
  Millionsend::ApiKeys.list
289
482
  Millionsend::ApiKeys.remove(key[:id])
290
483
  ```
@@ -363,15 +556,52 @@ end
363
556
  ```
364
557
 
365
558
  Subclasses: `ValidationError`, `AllRecipientsSuppressedError`, `NotFoundError`,
366
- `MissingApiKeyError`, `InvalidApiKeyError`, `RestrictedApiKeyError`, `SendingPausedError`,
367
- `BroadcastsPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`,
368
- `InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InternalServerError`, and
369
- `ApplicationError` (the fallback for unknown names). Client-side and
370
- transport failures that never reached the API raise `ApplicationError` with `#status_code == nil`.
559
+ `MissingApiKeyError`, `InvalidApiKeyError`, `RestrictedApiKeyError`, `ForbiddenError`,
560
+ `SendingPausedError`, `BroadcastsPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`,
561
+ `InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InternalServerError`, the
562
+ receiving and inbox errors `InboxSendPolicyError`, `InboxSendCapReachedError`,
563
+ `ApprovalRequiredError`, `EmailQuarantinedError`, `EmailBlockedError`, `TooManyWaitsError` and
564
+ `NameReviewUnavailableError`, and `ApplicationError` (the fallback for other names, such as
565
+ `plan_limit_reached`). Client-side and transport failures that never reached the API raise
566
+ `ApplicationError` with `#status_code == nil`. An unknown route raises `NotFoundError`; servers
567
+ before v0.13.0 answered it with text, which raises `ApplicationError` with status 404.
371
568
 
372
569
  `Emails.send` and `Batch.send` raise `AllRecipientsSuppressedError` (`all_recipients_suppressed`,
373
570
  422) when every `to` recipient is on the suppression list or opted out of the send's `topic_id`.
374
571
 
572
+ ### Retries
573
+
574
+ The SDK never retries. `e.retry_after` is the response's `retry-after` header in seconds (`nil`
575
+ without one). Worth retrying once it has passed: `NameReviewUnavailableError` (60 or 300 s),
576
+ `TooManyWaitsError`, and `RateLimitExceededError` when `retry_after` is at most 60 (the
577
+ per-minute limits). Not worth retrying in the same process: `InboxSendCapReachedError`, the
578
+ daily name-review budget (a `RateLimitExceededError` whose `retry_after` runs to the next UTC
579
+ midnight), and any 403, 404, 409 or 422.
580
+
581
+ Retry a send with the same `idempotency_key` (`Emails.send`, `Batch.send`, replies, forwards and
582
+ `Drafts.send`): a retried batch gets its stored response even if a recipient was refused or
583
+ suppressed since. A held draft (`kind: "draft"`) is not a failure, so never resend it.
584
+ `Inboxes.create` takes no idempotency key: after a 503 or 429 nothing was created, but after a
585
+ timeout a resend can create a second inbox. With a chosen `username`, a 409 on the resend
586
+ usually means the first attempt worked, and `Inboxes.get(address)` finds it.
587
+
588
+ ## Server versions
589
+
590
+ On a self-hosted instance, each feature needs at least this MillionSend version:
591
+
592
+ | Server | Features |
593
+ | ------- | -------- |
594
+ | v0.8.0 | `Emails::Receiving` |
595
+ | v0.10.0 | `Inboxes` |
596
+ | v0.11.0 | `ValidationError` for recipients that can never receive mail; a retried batch replays its stored response |
597
+ | 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 |
598
+ | 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 |
599
+ | 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 |
600
+
601
+ An older server ignores options it doesn't know: before v0.14.0 `domain_id` (check the created
602
+ inbox's `email_address`), before v0.13.0 `since` (a code already used can come back), and before
603
+ v0.12.0 `username` (the address is random).
604
+
375
605
  ## Migrating from Resend
376
606
 
377
607
  ```diff
@@ -401,10 +631,11 @@ works as in resend-ruby. Notes:
401
631
  part of this SDK. `Millionsend::Segments` is the dynamic-filter feature (`/segments`), not
402
632
  Resend's audiences alias.
403
633
  - Not in the API (yet), so not here: broadcast recipients/clicked links, email sharing and
404
- metrics, contact imports, receiving, automations, logs, OAuth grants, webhook event replay.
634
+ metrics, contact imports, automations, logs, OAuth grants, webhook event replay.
405
635
  - MillionSend extensions with no Resend counterpart: `Segments`, `Contacts::Batch`,
406
636
  `Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`,
407
- `Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`.
637
+ `Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`, the receiving long-poll,
638
+ `Emails::Receiving.reply`/`forward`/`remove`, and `Inboxes::Safety`, `Codes` and `Quarantine`.
408
639
 
409
640
  ## License
410
641
 
@@ -5,7 +5,9 @@ module Millionsend
5
5
  module ApiKeys
6
6
  class << self
7
7
  # POST /api-keys — name, permission ("full_access" default or
8
- # "sending_access"), domain_id. Returns { id:, token: }.
8
+ # "sending_access"), domain_id (a Domains.list row with shared: false),
9
+ # inbox_id (a full_access key that reads and sends for that one inbox).
10
+ # Returns { id:, token: }.
9
11
  def create(params)
10
12
  Millionsend::Request.new(method: :post, path: "/api-keys", body: params).perform
11
13
  end
@@ -8,7 +8,9 @@ module Millionsend
8
8
  # idempotency_key and batch_validation ("strict", the default, rejects the
9
9
  # whole batch on one invalid item; "permissive" sends the valid subset and
10
10
  # lists the rest under errors[]) — either flat or under options:, like
11
- # Emails.send.
11
+ # Emails.send. An item from a shared-domain address is refused
12
+ # (inbox_send_policy): the whole batch in strict mode, that item in
13
+ # permissive mode.
12
14
  def send(list, options = {})
13
15
  Millionsend::Request.new(
14
16
  method: :post, path: "/emails/batch", body: list, **Millionsend::Util.request_options(options)
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Millionsend
4
4
  # Broadcasts — one email sent to a segment, a topic's subscribers, or every
5
- # contact (pass neither segment_id nor topic_id).
5
+ # contact (pass neither segment_id nor topic_id). Never from the shared
6
+ # domain: create(send: true) and send answer 403 inbox_send_policy.
6
7
  module Broadcasts
7
8
  class << self
8
9
  # POST /broadcasts
@@ -20,7 +20,7 @@ module Millionsend
20
20
  # everything else is the body. A nil value clears a field; omit a key to
21
21
  # leave it unchanged.
22
22
  def update(params)
23
- body = params.reject { |k, _| [:id, :email].include?(k) }
23
+ body = params.reject { |k, _| %i[id email].include?(k) }
24
24
  Millionsend::Request.new(method: :patch, path: member_path(params), body: body).perform
25
25
  end
26
26
 
@@ -103,7 +103,8 @@ module Millionsend
103
103
  # "skip", "upsert") for emails that already belong to a contact,
104
104
  # batch_validation ("strict" default, "permissive" writes the valid
105
105
  # subset and lists failures under errors[]), idempotency_key. Returns
106
- # { data: [{ index:, id:, status: }], counts: {...}, errors: [...] }.
106
+ # { data: [{ index:, id:, status: }], counts: {...}, errors: [{ index:,
107
+ # statusCode:, name:, message: }] }.
107
108
  def create(list, options = {})
108
109
  options = options[:options] if options.is_a?(Hash) && options.key?(:options)
109
110
  Millionsend::Request.new(
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- # Sending domains and their DNS records.
4
+ # Sending domains and their DNS records. Rows carry shared: (absent before
5
+ # server v0.14.0, so nil reads as false). The team's subdomain of the shared
6
+ # inbox domain is a read-only row with shared: true, sending_mode: ("open",
7
+ # "reply_only" or "receive_only"), sending_mode_reason: ("plan", "instance"
8
+ # or nil when open) and records: []; update, verify and remove raise
9
+ # ForbiddenError on it. It only sends from an inbox's own address, so skip
10
+ # it when picking a sender domain.
5
11
  module Domains
6
12
  class << self
7
13
  # POST /domains — name, region, custom_return_path, open_tracking,
@@ -15,13 +21,15 @@ module Millionsend
15
21
  Millionsend::Request.new(method: :get, path: member_path(id)).perform
16
22
  end
17
23
 
18
- # GET /domains — accepts limit:/after:/before:.
24
+ # GET /domains — accepts limit:/after:/before:. The shared row comes
25
+ # last, after all of the team's own domains.
19
26
  def list(options = {})
20
27
  Millionsend::Request.new(method: :get, path: "/domains", query: Millionsend::Util.list_query(options)).perform
21
28
  end
22
29
 
23
30
  # PATCH /domains/:id — open_tracking, click_tracking, tracking_subdomain
24
- # (nil or "" clears it).
31
+ # (nil or "" clears it), receiving_catch_all (mail to addresses without
32
+ # an inbox is kept rather than refused; paid plans on Cloud).
25
33
  def update(id, params)
26
34
  Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
27
35
  end
@@ -13,6 +13,12 @@ module Millionsend
13
13
  # `options: { idempotency_key: "k" }`. No keyword parameters are declared
14
14
  # on purpose — Ruby 3 keyword separation would otherwise reject the
15
15
  # bare-keyword call shape.
16
+ #
17
+ # from may be an inbox's address on the shared domain while that domain's
18
+ # Domains.list row has sending_mode "open": the inbox's exact
19
+ # email_address (no +tag), no display name or its from_name, no
20
+ # reply_to, attachments, headers or scheduled_at, and at most 10 distinct
21
+ # recipients across to, cc and bcc.
16
22
  def send(params = {}, options = {})
17
23
  Millionsend::Request.new(
18
24
  method: :post, path: "/emails", body: params, **Millionsend::Util.request_options(options)
@@ -3,20 +3,22 @@
3
3
  module Millionsend
4
4
  # Base class for everything this SDK raises. Mirrors the API's wire error body
5
5
  # { statusCode, name, message }; #status_code is nil for client-side and
6
- # transport failures that never reached the API.
6
+ # transport failures that never reached the API. #retry_after is the
7
+ # response's retry-after header in seconds, nil when it sent none.
7
8
  class Error < StandardError
8
- attr_reader :status_code, :name
9
+ attr_reader :status_code, :name, :retry_after
9
10
 
10
- def initialize(message, status_code = nil, name = nil)
11
+ def initialize(message, status_code = nil, name = nil, retry_after = nil)
11
12
  super(message)
12
13
  @status_code = status_code
13
14
  @name = name
15
+ @retry_after = retry_after
14
16
  end
15
17
 
16
18
  # Build the right subclass from a non-2xx response, keyed on the stable
17
19
  # `name` discriminant. Falls back to ApplicationError for unknown names or a
18
20
  # body that is not the canonical error shape.
19
- def self.from_response(status, body)
21
+ def self.from_response(status, body, retry_after = nil)
20
22
  if body.is_a?(Hash)
21
23
  name = body[:name].is_a?(String) ? body[:name] : "application_error"
22
24
  message = body[:message].is_a?(String) ? body[:message] : "Request failed with status #{status}"
@@ -26,7 +28,7 @@ module Millionsend
26
28
  message = "Request failed with status #{status}"
27
29
  code = status
28
30
  end
29
- (ERROR_TYPES[name] || ApplicationError).new(message, code, name)
31
+ (ERROR_TYPES[name] || ApplicationError).new(message, code, name, retry_after)
30
32
  end
31
33
  end
32
34
 
@@ -36,12 +38,20 @@ module Millionsend
36
38
  class MissingApiKeyError < Error; end
37
39
  class InvalidApiKeyError < Error; end
38
40
  class RestrictedApiKeyError < Error; end
41
+ class ForbiddenError < Error; end
39
42
  class SendingPausedError < Error; end
40
43
  class BroadcastsPausedError < Error; end
41
44
  class RateLimitExceededError < Error; end
42
45
  class DailyQuotaExceededError < Error; end
43
46
  class InvalidIdempotentRequestError < Error; end
44
47
  class ConcurrentIdempotentRequestsError < Error; end
48
+ class InboxSendPolicyError < Error; end
49
+ class InboxSendCapReachedError < Error; end
50
+ class ApprovalRequiredError < Error; end
51
+ class EmailQuarantinedError < Error; end
52
+ class EmailBlockedError < Error; end
53
+ class TooManyWaitsError < Error; end
54
+ class NameReviewUnavailableError < Error; end
45
55
  class InternalServerError < Error; end
46
56
  class ApplicationError < Error; end
47
57
 
@@ -52,12 +62,20 @@ module Millionsend
52
62
  "missing_api_key" => MissingApiKeyError,
53
63
  "invalid_api_key" => InvalidApiKeyError,
54
64
  "restricted_api_key" => RestrictedApiKeyError,
65
+ "forbidden" => ForbiddenError,
55
66
  "sending_paused" => SendingPausedError,
56
67
  "broadcasts_paused" => BroadcastsPausedError,
57
68
  "rate_limit_exceeded" => RateLimitExceededError,
58
69
  "daily_quota_exceeded" => DailyQuotaExceededError,
59
70
  "invalid_idempotent_request" => InvalidIdempotentRequestError,
60
71
  "concurrent_idempotent_requests" => ConcurrentIdempotentRequestsError,
72
+ "inbox_send_policy" => InboxSendPolicyError,
73
+ "inbox_send_cap_reached" => InboxSendCapReachedError,
74
+ "approval_required" => ApprovalRequiredError,
75
+ "email_quarantined" => EmailQuarantinedError,
76
+ "email_blocked" => EmailBlockedError,
77
+ "too_many_waits" => TooManyWaitsError,
78
+ "name_review_unavailable" => NameReviewUnavailableError,
61
79
  "internal_server_error" => InternalServerError,
62
80
  "application_error" => ApplicationError,
63
81
  }.freeze
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Agent and human inboxes (Resend's Inboxes beta plus MillionSend's
5
+ # extensions): addresses whose mail lands in threads, with labels, drafts,
6
+ # safety rules, quarantine and code extraction. Lists that take arrays
7
+ # (folders:, labels:, from:...) send them comma-separated.
8
+ module Inboxes
9
+ class << self
10
+ # POST /inboxes — every field optional. The address is email_address on
11
+ # one of your domains, or else one on the shared domain (no DNS) named
12
+ # by username (random when omitted). domain_id, the id of a Domains.list
13
+ # row, stands in for email_address: the shared row works like leaving it
14
+ # out, and one of your own domains needs username, the part before the
15
+ # @. Also name (1-320 characters), from_name, forwarding, kind ("agent",
16
+ # the default, or "human"), expires_in ("1h" to "365d"; omitted or nil
17
+ # never expires) and safety (any subset of the rules). Returns the inbox.
18
+ def create(params = {})
19
+ Millionsend::Request.new(method: :post, path: "/inboxes", body: params).perform
20
+ end
21
+
22
+ # GET /inboxes — limit:/after:/before: and kind:. Newest first.
23
+ def list(options = {})
24
+ Millionsend::Request.new(method: :get, path: "/inboxes", query: Millionsend::Util.csv_query(options)).perform
25
+ end
26
+
27
+ # GET /inboxes/:id_or_email
28
+ def get(id_or_email)
29
+ Millionsend::Request.new(method: :get, path: member_path(id_or_email)).perform
30
+ end
31
+
32
+ # PATCH /inboxes/:id — name (1-320 characters), from_name ("" removes
33
+ # it), expires_in (nil never expires). Returns the inbox.
34
+ def update(id, params)
35
+ Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
36
+ end
37
+
38
+ # DELETE /inboxes/:id — its threads, drafts and labels go with it, and
39
+ # its inbox-scoped keys are revoked.
40
+ def remove(id)
41
+ Millionsend::Request.new(method: :delete, path: member_path(id)).perform
42
+ end
43
+
44
+ def member_path(id)
45
+ "/inboxes/#{Millionsend::Util.encode(id)}"
46
+ end
47
+ end
48
+
49
+ # Who may write in and what an agent may send (MillionSend extension).
50
+ module Safety
51
+ class << self
52
+ # GET /inboxes/:id/safety
53
+ def get(inbox_id)
54
+ Millionsend::Request.new(method: :get, path: "#{Inboxes.member_path(inbox_id)}/safety").perform
55
+ end
56
+
57
+ # PATCH /inboxes/:id/safety — any subset of accept_from, allowlist,
58
+ # owner_addresses, screening, extract_codes, send_policy,
59
+ # require_approval, private_codes, send_cap (nil: no cap) and
60
+ # forward_to. Returns every rule.
61
+ def update(inbox_id, params)
62
+ Millionsend::Request.new(method: :patch, path: "#{Inboxes.member_path(inbox_id)}/safety", body: params).perform
63
+ end
64
+ end
65
+ end
66
+
67
+ # Verification codes and sign-in links found in received mail
68
+ # (MillionSend extension). Links are never fetched.
69
+ module Codes
70
+ class << self
71
+ # GET /inboxes/:id/codes/latest — from: (an address, or a domain
72
+ # covering its subdomains), wait: (0-50 s), max_age: (60-86400 s since
73
+ # delivery, default 600), since: (an ISO 8601 time with an offset or
74
+ # Z; only mail received after it counts, so passing the previous
75
+ # code's received_at makes a second sign-in wait for its own code).
76
+ # Always 200; status is "found", "none", "ambiguous" or "timeout".
77
+ def latest(inbox_id, options = {})
78
+ Millionsend::Request.new(
79
+ method: :get, path: "#{Inboxes.member_path(inbox_id)}/codes/latest",
80
+ query: Millionsend::Util.csv_query(options)
81
+ ).perform
82
+ end
83
+ end
84
+ end
85
+
86
+ # Mail the inbox's rules held back (MillionSend extension): kept and
87
+ # listed, but never shown to agents until a person releases it.
88
+ module Quarantine
89
+ class << self
90
+ # GET /inboxes/:id/quarantine — limit:/after:/before:. Newest first.
91
+ def list(inbox_id, options = {})
92
+ Millionsend::Request.new(
93
+ method: :get, path: "#{Inboxes.member_path(inbox_id)}/quarantine",
94
+ query: Millionsend::Util.list_query(options)
95
+ ).perform
96
+ end
97
+
98
+ # POST /inboxes/:id/quarantine/:email_id/release — trust_sender: true
99
+ # also adds the sender to the allowlist.
100
+ def release(inbox_id, email_id, params = {})
101
+ path = "#{Inboxes.member_path(inbox_id)}/quarantine/#{Millionsend::Util.encode(email_id)}/release"
102
+ Millionsend::Request.new(method: :post, path: path, body: params).perform
103
+ end
104
+ end
105
+ end
106
+
107
+ # Labels of one inbox, applied to threads one at a time.
108
+ module Labels
109
+ class << self
110
+ # GET /inboxes/:id/labels — every label, oldest first (unpaginated).
111
+ def list(inbox_id)
112
+ Millionsend::Request.new(method: :get, path: "#{Inboxes.member_path(inbox_id)}/labels").perform
113
+ end
114
+
115
+ # POST /inboxes/:id/labels — name, color ("#RRGGBB"; random when omitted).
116
+ def create(inbox_id, params)
117
+ Millionsend::Request.new(method: :post, path: "#{Inboxes.member_path(inbox_id)}/labels", body: params).perform
118
+ end
119
+
120
+ # PATCH /inboxes/:id/labels/:label_id — name and/or color.
121
+ def update(inbox_id, label_id, params)
122
+ Millionsend::Request.new(method: :patch, path: member_path(inbox_id, label_id), body: params).perform
123
+ end
124
+
125
+ # DELETE /inboxes/:id/labels/:label_id — also takes it off every thread.
126
+ def remove(inbox_id, label_id)
127
+ Millionsend::Request.new(method: :delete, path: member_path(inbox_id, label_id)).perform
128
+ end
129
+
130
+ private
131
+
132
+ def member_path(inbox_id, label_id)
133
+ "#{Inboxes.member_path(inbox_id)}/labels/#{Millionsend::Util.encode(label_id)}"
134
+ end
135
+ end
136
+ end
137
+
138
+ # Conversations of one inbox, in both directions.
139
+ module Threads
140
+ class << self
141
+ # GET /inboxes/:id/threads — folders: (["inbox"] by default), labels:,
142
+ # read:, include_quarantined: (items then carry quarantined:, the
143
+ # count of held messages; full-access team keys),
144
+ # limit:/after:/before:. Most recently active first.
145
+ def list(inbox_id, options = {})
146
+ Millionsend::Request.new(
147
+ method: :get, path: "#{Inboxes.member_path(inbox_id)}/threads", query: Millionsend::Util.csv_query(options)
148
+ ).perform
149
+ end
150
+
151
+ # GET /inboxes/:id/threads/search — list's options plus query:, from:,
152
+ # to:, cc:, bcc:, has_attachment:, start_date:, end_date:. Searches
153
+ # subjects, senders and recipients.
154
+ def search(inbox_id, options = {})
155
+ Millionsend::Request.new(
156
+ method: :get, path: "#{Inboxes.member_path(inbox_id)}/threads/search",
157
+ query: Millionsend::Util.csv_query(options)
158
+ ).perform
159
+ end
160
+
161
+ # GET /inboxes/:id/threads/:thread_id
162
+ def get(inbox_id, thread_id)
163
+ Millionsend::Request.new(method: :get, path: member_path(inbox_id, thread_id)).perform
164
+ end
165
+
166
+ # PATCH /inboxes/:id/threads/:thread_id — read, folder ("inbox",
167
+ # "archive", "spam" or "trash"), label_id.
168
+ def update(inbox_id, thread_id, params)
169
+ Millionsend::Request.new(method: :patch, path: member_path(inbox_id, thread_id), body: params).perform
170
+ end
171
+
172
+ # DELETE /inboxes/:id/threads/:thread_id — moves it to trash, purged
173
+ # after 30 days; deleted is false when it was there already.
174
+ def remove(inbox_id, thread_id)
175
+ Millionsend::Request.new(method: :delete, path: member_path(inbox_id, thread_id)).perform
176
+ end
177
+
178
+ def member_path(inbox_id, thread_id)
179
+ "#{Inboxes.member_path(inbox_id)}/threads/#{Millionsend::Util.encode(thread_id)}"
180
+ end
181
+ end
182
+
183
+ # The messages of one thread.
184
+ module Emails
185
+ class << self
186
+ # GET .../threads/:thread_id/emails — limit:/after:/before: and
187
+ # include_quarantined: (full-access team keys). Oldest first; after:
188
+ # pages toward newer.
189
+ def list(inbox_id, thread_id, options = {})
190
+ Millionsend::Request.new(
191
+ method: :get, path: "#{Threads.member_path(inbox_id, thread_id)}/emails",
192
+ query: Millionsend::Util.csv_query(options)
193
+ ).perform
194
+ end
195
+
196
+ # GET .../threads/:thread_id/emails/:email_id — include_quarantined:
197
+ # (full-access team keys) reads a held message.
198
+ def get(inbox_id, thread_id, email_id, options = {})
199
+ Millionsend::Request.new(
200
+ method: :get, path: member_path(inbox_id, thread_id, email_id),
201
+ query: { include_quarantined: options[:include_quarantined] }
202
+ ).perform
203
+ end
204
+
205
+ # POST .../emails/:email_id/reply — html/text, cc, bcc, subject,
206
+ # reply_all, requires_approval. Returns { kind: "sent", data:
207
+ # <sent email> }, or { kind: "draft", data: <draft> } when the
208
+ # inbox's rules held it for approval (202). The trailing hash takes
209
+ # idempotency_key, flat or under options:.
210
+ def reply(inbox_id, thread_id, email_id, params, options = {})
211
+ Millionsend::Request.new(
212
+ method: :post, path: "#{member_path(inbox_id, thread_id, email_id)}/reply", body: params,
213
+ **Millionsend::Util.request_options(options)
214
+ ).perform_outcome
215
+ end
216
+
217
+ # POST .../emails/:email_id/forward — to (required), cc, bcc, a
218
+ # html/text note, subject, requires_approval. Same result and options
219
+ # as reply.
220
+ def forward(inbox_id, thread_id, email_id, params, options = {})
221
+ Millionsend::Request.new(
222
+ method: :post, path: "#{member_path(inbox_id, thread_id, email_id)}/forward", body: params,
223
+ **Millionsend::Util.request_options(options)
224
+ ).perform_outcome
225
+ end
226
+
227
+ private
228
+
229
+ def member_path(inbox_id, thread_id, email_id)
230
+ "#{Threads.member_path(inbox_id, thread_id)}/emails/#{Millionsend::Util.encode(email_id)}"
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ # Drafts, standalone or replies: sent on demand or held for approval.
237
+ module Drafts
238
+ class << self
239
+ # GET /inboxes/:id/drafts — limit:/after:/before:. Unsent drafts, most
240
+ # recently updated first.
241
+ def list(inbox_id, options = {})
242
+ Millionsend::Request.new(
243
+ method: :get, path: "#{Inboxes.member_path(inbox_id)}/drafts", query: Millionsend::Util.list_query(options)
244
+ ).perform
245
+ end
246
+
247
+ # POST /inboxes/:id/drafts — to, cc, bcc, subject, html, text, plus
248
+ # thread_id with reply_to_email_id for a reply draft. An unsent reply
249
+ # draft to the same email comes back instead of a new one.
250
+ def create(inbox_id, params)
251
+ Millionsend::Request.new(method: :post, path: "#{Inboxes.member_path(inbox_id)}/drafts", body: params).perform
252
+ end
253
+
254
+ # GET /inboxes/:id/drafts/:draft_id — also after it was sent.
255
+ def get(inbox_id, draft_id)
256
+ Millionsend::Request.new(method: :get, path: member_path(inbox_id, draft_id)).perform
257
+ end
258
+
259
+ # PATCH /inboxes/:id/drafts/:draft_id — nil clears to, cc, bcc,
260
+ # subject, html or text; setting html or text replaces both.
261
+ def update(inbox_id, draft_id, params)
262
+ Millionsend::Request.new(method: :patch, path: member_path(inbox_id, draft_id), body: params).perform
263
+ end
264
+
265
+ # DELETE /inboxes/:id/drafts/:draft_id
266
+ def remove(inbox_id, draft_id)
267
+ Millionsend::Request.new(method: :delete, path: member_path(inbox_id, draft_id)).perform
268
+ end
269
+
270
+ # POST /inboxes/:id/drafts/:draft_id/send — no body. Returns { kind:
271
+ # "sent", data: { object:, id:, thread_id:, email_id: } }, or { kind:
272
+ # "draft", data: <draft> } when the rules hold it for approval (202).
273
+ # The trailing hash takes idempotency_key, flat or under options:.
274
+ def send(inbox_id, draft_id, options = {})
275
+ Millionsend::Request.new(
276
+ method: :post, path: "#{member_path(inbox_id, draft_id)}/send", **Millionsend::Util.request_options(options)
277
+ ).perform_outcome
278
+ end
279
+
280
+ # POST /inboxes/:id/drafts/:draft_id/reject — approvers only; returns
281
+ # the draft with status "rejected".
282
+ def reject(inbox_id, draft_id)
283
+ Millionsend::Request.new(method: :post, path: "#{member_path(inbox_id, draft_id)}/reject").perform
284
+ end
285
+
286
+ private
287
+
288
+ def member_path(inbox_id, draft_id)
289
+ "#{Inboxes.member_path(inbox_id)}/drafts/#{Millionsend::Util.encode(draft_id)}"
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ module Emails
5
+ # Received mail: Resend's emails.receiving surface plus MillionSend's
6
+ # long-poll, server-side reply and forward, and erase. Inbox-scoped keys
7
+ # never see mail held for screening or in quarantine.
8
+ module Receiving
9
+ class << self
10
+ # GET /emails/receiving. Options go on the query as given: limit:,
11
+ # after:, before:, plus wait: (long-poll up to 50 s; the answer then
12
+ # also lists held: [{ id:, inbox_id:, reason:, quarantine_reason:,
13
+ # created_at: }] for matches held since the cursor), since:,
14
+ # inbox_id: (an unknown one is not_found at once, even with wait:),
15
+ # to:, from: (an address, or a domain covering its subdomains),
16
+ # subject_contains:, unread: (with inbox_id:, or alone on an
17
+ # inbox-scoped key), include_quarantined: and quarantined: (held mail
18
+ # too, or only; full-access keys).
19
+ def list(options = {})
20
+ Millionsend::Request.new(
21
+ method: :get, path: "/emails/receiving", query: Millionsend::Util.csv_query(options)
22
+ ).perform
23
+ end
24
+
25
+ # GET /emails/receiving/:id — html_format: "data_uri" (default) or
26
+ # "cid". Adds screening, the spam and virus verdicts, extracted_text
27
+ # and, for verification mail, extracted: { code:, link: }.
28
+ def get(id, options = {})
29
+ Millionsend::Request.new(method: :get, path: member_path(id), query: { html_format: options[:html_format] }).perform
30
+ end
31
+
32
+ # POST /emails/receiving/:id/reply — text/html, subject, cc, bcc,
33
+ # reply_all, requires_approval, from, inbox_id. Returns { kind: "sent",
34
+ # data: { object:, id:, thread_id:, inbox_id: } }, or { kind: "draft",
35
+ # data: <draft> } when the inbox's rules held it for approval (202).
36
+ # The trailing hash takes idempotency_key, flat or under options:.
37
+ def reply(id, params, options = {})
38
+ Millionsend::Request.new(
39
+ method: :post, path: "#{member_path(id)}/reply", body: params, **Millionsend::Util.request_options(options)
40
+ ).perform_outcome
41
+ end
42
+
43
+ # POST /emails/receiving/:id/forward — to (required), cc, bcc, a
44
+ # text/html note, subject, requires_approval, from, inbox_id and mode
45
+ # ("inline", the default, or "attachment" for the original as an .eml).
46
+ # Same result and options as reply.
47
+ def forward(id, params, options = {})
48
+ Millionsend::Request.new(
49
+ method: :post, path: "#{member_path(id)}/forward", body: params, **Millionsend::Util.request_options(options)
50
+ ).perform_outcome
51
+ end
52
+
53
+ # DELETE /emails/receiving/:id — erases the message now (full-access
54
+ # keys that are not scoped to an inbox).
55
+ def remove(id)
56
+ Millionsend::Request.new(method: :delete, path: member_path(id)).perform
57
+ end
58
+
59
+ def member_path(id)
60
+ "/emails/receiving/#{Millionsend::Util.encode(id)}"
61
+ end
62
+ end
63
+
64
+ # Attachments of a received email, each with a signed download_url valid
65
+ # for one hour.
66
+ module Attachments
67
+ class << self
68
+ # GET /emails/receiving/:email_id/attachments — limit:/after:/before:.
69
+ # Also takes resend-ruby's single hash: list(email_id:, limit:).
70
+ def list(email_id, options = {})
71
+ if email_id.is_a?(Hash)
72
+ options = email_id.reject { |k, _| k == :email_id }
73
+ email_id = email_id[:email_id]
74
+ end
75
+ Millionsend::Request.new(
76
+ method: :get, path: "#{Receiving.member_path(email_id)}/attachments",
77
+ query: Millionsend::Util.list_query(options)
78
+ ).perform
79
+ end
80
+
81
+ # GET /emails/receiving/:email_id/attachments/:id. Also takes
82
+ # resend-ruby's get(email_id:, id:).
83
+ def get(email_id, id = nil)
84
+ email_id, id = email_id.values_at(:email_id, :id) if email_id.is_a?(Hash)
85
+ path = "#{Receiving.member_path(email_id)}/attachments/#{Millionsend::Util.encode(id)}"
86
+ Millionsend::Request.new(method: :get, path: path).perform
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -23,7 +23,7 @@ module Millionsend
23
23
  # (WebMock::NetConnectNotAllowedError) is not swallowed as a transport error.
24
24
  TRANSPORT_ERRORS = [
25
25
  Timeout::Error, SocketError, SystemCallError, IOError,
26
- Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
26
+ Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError,
27
27
  ].freeze
28
28
 
29
29
  def initialize(method:, path:, body: nil, query: nil, idempotency_key: nil, batch_validation: nil)
@@ -36,6 +36,19 @@ module Millionsend
36
36
  end
37
37
 
38
38
  def perform
39
+ perform_with_status.last
40
+ end
41
+
42
+ # Reply, forward and draft send answer 200 once the email went out and 202
43
+ # when the inbox's rules held it as a draft for a person to approve.
44
+ def perform_outcome
45
+ status, body = perform_with_status
46
+ { kind: status == 202 ? "draft" : "sent", data: body }
47
+ end
48
+
49
+ private
50
+
51
+ def perform_with_status
39
52
  api_key = Millionsend.api_key
40
53
  if api_key.nil? || api_key.to_s.empty?
41
54
  # Client-side failure: same class and stable name as a transport error,
@@ -61,9 +74,13 @@ module Millionsend
61
74
  handle(response)
62
75
  end
63
76
 
64
- private
65
-
66
77
  def build_uri
78
+ # URL normalization resolves an empty, "." or ".." segment away, so an id
79
+ # such as ".." would send the request to another resource
80
+ # (DELETE /inboxes/{id}/labels/.. deletes the inbox).
81
+ dot = @path.split("/", -1).drop(1).any? { |segment| ["", ".", ".."].include?(segment) }
82
+ raise ArgumentError, 'an id in the request path is empty, "." or ".."' if dot
83
+
67
84
  base = Millionsend.base_url.to_s.sub(%r{/+\z}, "")
68
85
  uri = URI.parse("#{base}#{@path}")
69
86
  if !Millionsend.allow_insecure_http && Millionsend::Util.insecure_http?(uri)
@@ -96,9 +113,12 @@ module Millionsend
96
113
  def handle(response)
97
114
  status = response.code.to_i
98
115
  body = parse(response.body)
99
- raise Millionsend::Error.from_response(status, body) unless (200..299).cover?(status)
116
+ unless (200..299).cover?(status)
117
+ retry_after = Integer(response["retry-after"], 10, exception: false)
118
+ raise Millionsend::Error.from_response(status, body, retry_after)
119
+ end
100
120
 
101
- body
121
+ [status, body]
102
122
  end
103
123
 
104
124
  def parse(text)
@@ -33,6 +33,13 @@ module Millionsend
33
33
  { limit: options[:limit], after: options[:after], before: options[:before] }
34
34
  end
35
35
 
36
+ # A list or search query as given (Ruby's snake_case is the wire's), with
37
+ # arrays comma-joined: the inbox endpoints read CSV and answer 422 to a
38
+ # repeated param. nil values are dropped when the query string is built.
39
+ def csv_query(options)
40
+ (options || {}).transform_values { |v| v.is_a?(Array) ? v.join(",") : v }
41
+ end
42
+
36
43
  # ?include= for the contact lists: the names comma-joined, or nil (dropped
37
44
  # from the query) when none were given.
38
45
  def include_query(include)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -5,7 +5,8 @@ module Millionsend
5
5
  module Webhooks
6
6
  class << self
7
7
  # POST /webhooks — endpoint, events[], signing_secret (optional; pass an
8
- # existing whsec_ value to keep a receiver verifying unchanged).
8
+ # existing whsec_ value to keep a receiver verifying unchanged),
9
+ # inbox_ids (inbox events and email.received for these inboxes only).
9
10
  def create(params)
10
11
  Millionsend::Request.new(method: :post, path: "/webhooks", body: params).perform
11
12
  end
@@ -21,7 +22,8 @@ module Millionsend
21
22
  Millionsend::Request.new(method: :get, path: "/webhooks", query: Millionsend::Util.list_query(options)).perform
22
23
  end
23
24
 
24
- # PATCH /webhooks/:id — endpoint, events, status ("enabled"/"disabled").
25
+ # PATCH /webhooks/:id — endpoint, events, status ("enabled"/"disabled"),
26
+ # inbox_ids (nil: every inbox).
25
27
  def update(id, params)
26
28
  Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
27
29
  end
data/lib/millionsend.rb CHANGED
@@ -5,6 +5,7 @@ require "millionsend/util"
5
5
  require "millionsend/error"
6
6
  require "millionsend/request"
7
7
  require "millionsend/emails"
8
+ require "millionsend/receiving"
8
9
  require "millionsend/batch"
9
10
  require "millionsend/contacts"
10
11
  require "millionsend/contact_properties"
@@ -18,13 +19,14 @@ require "millionsend/api_keys"
18
19
  require "millionsend/templates"
19
20
  require "millionsend/usage"
20
21
  require "millionsend/deliverability"
22
+ require "millionsend/inboxes"
21
23
 
22
24
  # Ruby client for the MillionSend HTTP API. Configure once, then call the
23
25
  # resource modules:
24
26
  #
25
27
  # Millionsend.api_key = "ms_..."
26
28
  # Millionsend.base_url = "https://mail.acme.dev"
27
- # Millionsend::Emails.send(from: "onboarding@acme.dev", to: "you@example.com",
29
+ # Millionsend::Emails.send(from: "onboarding@acme.dev", to: "success@simulator.amazonses.com",
28
30
  # subject: "Hi", html: "<strong>it works</strong>")
29
31
  #
30
32
  # api_key falls back to the MILLIONSEND_API_KEY env var; base_url to
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: millionsend
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - MillionSend
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-08 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '3.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '='
46
+ - !ruby/object:Gem::Version
47
+ version: 1.91.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '='
53
+ - !ruby/object:Gem::Version
54
+ version: 1.91.0
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: webmock
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -54,8 +68,8 @@ dependencies:
54
68
  version: '3.19'
55
69
  description: 'Ruby client for the MillionSend HTTP API: emails, batch, contacts, contact
56
70
  properties, topics, broadcasts, segments, suppressions, domains, webhooks, API keys,
57
- templates and usage. Wire-compatible with Resend and mirror-shaped after resend-ruby,
58
- so migrating is mostly an import swap.'
71
+ templates, usage, received mail and inboxes. Wire-compatible with Resend and mirror-shaped
72
+ after resend-ruby, so migrating is mostly an import swap.'
59
73
  email:
60
74
  executables: []
61
75
  extensions: []
@@ -73,6 +87,8 @@ files:
73
87
  - lib/millionsend/domains.rb
74
88
  - lib/millionsend/emails.rb
75
89
  - lib/millionsend/error.rb
90
+ - lib/millionsend/inboxes.rb
91
+ - lib/millionsend/receiving.rb
76
92
  - lib/millionsend/request.rb
77
93
  - lib/millionsend/segments.rb
78
94
  - lib/millionsend/suppressions.rb