millionsend 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +249 -15
- data/lib/millionsend/api_keys.rb +3 -1
- data/lib/millionsend/batch.rb +3 -1
- data/lib/millionsend/broadcasts.rb +2 -1
- data/lib/millionsend/contacts.rb +13 -6
- data/lib/millionsend/domains.rb +11 -3
- data/lib/millionsend/emails.rb +6 -0
- data/lib/millionsend/error.rb +23 -5
- data/lib/millionsend/inboxes.rb +294 -0
- data/lib/millionsend/receiving.rb +92 -0
- data/lib/millionsend/request.rb +25 -5
- data/lib/millionsend/util.rb +7 -0
- data/lib/millionsend/version.rb +1 -1
- data/lib/millionsend/webhooks.rb +4 -2
- data/lib/millionsend.rb +3 -1
- metadata +20 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa0ca4ce8b25420cf85d8856412e9357b5034ceebc74a54f44e78a97125e2322
|
|
4
|
+
data.tar.gz: f06f818f69f4cf130bda91fc0be22bc2aaba87350dc2ce551932a066e9c7e58f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: "
|
|
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
|
|
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.
|
|
@@ -121,7 +291,9 @@ contact = Millionsend::Contacts.create(
|
|
|
121
291
|
)
|
|
122
292
|
Millionsend::Contacts.get("ada@acme.dev") # by id or email; also resend-ruby's get(id: ...) / get(email: ...)
|
|
123
293
|
Millionsend::Contacts.update(id: contact[:id], unsubscribed: true, first_name: nil) # nil clears
|
|
124
|
-
Millionsend::Contacts.remove("ada@acme.dev")
|
|
294
|
+
Millionsend::Contacts.remove("ada@acme.dev") # the contact's emails stay in the send log
|
|
295
|
+
Millionsend::Contacts.remove("ada@acme.dev", erase: true) # ?erase=true — also scrubs the address from email
|
|
296
|
+
# history, event payloads and API logs (GDPR/LGPD)
|
|
125
297
|
Millionsend::Contacts.list(limit: 50)
|
|
126
298
|
# Bulk read (MillionSend extension): carry the property map and the topic subscriptions on every
|
|
127
299
|
# item, so an audience reads in one request per 100 contacts instead of one per contact
|
|
@@ -159,7 +331,8 @@ found[:data] # [{ object: "contact", id:, email:, first_name:, last_name:, cr
|
|
|
159
331
|
found[:missing] # [{ index:, email: }] / [{ index:, id: }] — request entries that matched nobody
|
|
160
332
|
|
|
161
333
|
# Bulk delete (MillionSend extension) — up to 1000 per call, exactly one of ids: / emails:
|
|
162
|
-
Millionsend::Contacts::Batch.remove(emails: ["a@acme.dev", "b@acme.dev"]) # or ids: [...]
|
|
334
|
+
Millionsend::Contacts::Batch.remove(emails: ["a@acme.dev", "b@acme.dev"]) # or ids: [...]; emails stay in the log
|
|
335
|
+
Millionsend::Contacts::Batch.remove(ids: [contact[:id]], erase: true) # also scrubs each address, as Contacts.remove
|
|
163
336
|
# => { data: [{ object: "contact", contact: "<uuid>", deleted: true }, ...] } — only the rows actually deleted
|
|
164
337
|
```
|
|
165
338
|
|
|
@@ -243,9 +416,22 @@ Millionsend::Domains.get(domain[:id])
|
|
|
243
416
|
Millionsend::Domains.list
|
|
244
417
|
Millionsend::Domains.verify(domain[:id]) # re-check DNS now
|
|
245
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)
|
|
246
420
|
Millionsend::Domains.remove(domain[:id])
|
|
247
421
|
```
|
|
248
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
|
+
|
|
249
435
|
### Webhooks
|
|
250
436
|
|
|
251
437
|
```ruby
|
|
@@ -258,6 +444,7 @@ hook[:signing_secret]
|
|
|
258
444
|
Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret and previous_secret_expires_at
|
|
259
445
|
Millionsend::Webhooks.list
|
|
260
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
|
|
261
448
|
Millionsend::Webhooks.remove(hook[:id])
|
|
262
449
|
|
|
263
450
|
# Rotate the signing secret (MillionSend extension) — POST /webhooks/:id/rotate
|
|
@@ -275,13 +462,22 @@ Events: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounce
|
|
|
275
462
|
`email.complained`, `email.opened`, `email.clicked`, `contact.created`, `contact.updated`,
|
|
276
463
|
`contact.deleted`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`,
|
|
277
464
|
`contact.topic_opt_out`, `suppression.added`, `suppression.removed`, `deliverability.warning`,
|
|
278
|
-
`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`.
|
|
279
473
|
|
|
280
474
|
### API keys
|
|
281
475
|
|
|
282
476
|
```ruby
|
|
283
|
-
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
|
|
284
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
|
|
285
481
|
Millionsend::ApiKeys.list
|
|
286
482
|
Millionsend::ApiKeys.remove(key[:id])
|
|
287
483
|
```
|
|
@@ -360,15 +556,52 @@ end
|
|
|
360
556
|
```
|
|
361
557
|
|
|
362
558
|
Subclasses: `ValidationError`, `AllRecipientsSuppressedError`, `NotFoundError`,
|
|
363
|
-
`MissingApiKeyError`, `InvalidApiKeyError`, `RestrictedApiKeyError`, `
|
|
364
|
-
`BroadcastsPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`,
|
|
365
|
-
`InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InternalServerError`,
|
|
366
|
-
|
|
367
|
-
|
|
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.
|
|
368
568
|
|
|
369
569
|
`Emails.send` and `Batch.send` raise `AllRecipientsSuppressedError` (`all_recipients_suppressed`,
|
|
370
570
|
422) when every `to` recipient is on the suppression list or opted out of the send's `topic_id`.
|
|
371
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
|
+
|
|
372
605
|
## Migrating from Resend
|
|
373
606
|
|
|
374
607
|
```diff
|
|
@@ -398,10 +631,11 @@ works as in resend-ruby. Notes:
|
|
|
398
631
|
part of this SDK. `Millionsend::Segments` is the dynamic-filter feature (`/segments`), not
|
|
399
632
|
Resend's audiences alias.
|
|
400
633
|
- Not in the API (yet), so not here: broadcast recipients/clicked links, email sharing and
|
|
401
|
-
metrics, contact imports,
|
|
634
|
+
metrics, contact imports, automations, logs, OAuth grants, webhook event replay.
|
|
402
635
|
- MillionSend extensions with no Resend counterpart: `Segments`, `Contacts::Batch`,
|
|
403
636
|
`Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`,
|
|
404
|
-
`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`.
|
|
405
639
|
|
|
406
640
|
## License
|
|
407
641
|
|
data/lib/millionsend/api_keys.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/millionsend/batch.rb
CHANGED
|
@@ -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
|
data/lib/millionsend/contacts.rb
CHANGED
|
@@ -20,13 +20,17 @@ 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, _| [
|
|
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
|
|
|
27
|
-
# DELETE a contact by id or email.
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# DELETE a contact by id or email. The contact's emails stay in the send
|
|
28
|
+
# log; erase: true also scrubs the address from email history, event
|
|
29
|
+
# payloads and API logs (a GDPR/LGPD erasure). The flag rides flat or in
|
|
30
|
+
# resend-ruby's addressing hash: remove(email: "a@x.dev", erase: true).
|
|
31
|
+
def remove(id_or_email, options = {})
|
|
32
|
+
erase = options.key?(:erase) ? options[:erase] : (id_or_email[:erase] if id_or_email.is_a?(Hash))
|
|
33
|
+
Millionsend::Request.new(method: :delete, path: member_path(id_or_email), query: { erase: erase }).perform
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
# GET /contacts — accepts limit:/after:/before: and include:
|
|
@@ -99,7 +103,8 @@ module Millionsend
|
|
|
99
103
|
# "skip", "upsert") for emails that already belong to a contact,
|
|
100
104
|
# batch_validation ("strict" default, "permissive" writes the valid
|
|
101
105
|
# subset and lists failures under errors[]), idempotency_key. Returns
|
|
102
|
-
# { data: [{ index:, id:, status: }], counts: {...}, errors: [
|
|
106
|
+
# { data: [{ index:, id:, status: }], counts: {...}, errors: [{ index:,
|
|
107
|
+
# statusCode:, name:, message: }] }.
|
|
103
108
|
def create(list, options = {})
|
|
104
109
|
options = options[:options] if options.is_a?(Hash) && options.key?(:options)
|
|
105
110
|
Millionsend::Request.new(
|
|
@@ -121,7 +126,9 @@ module Millionsend
|
|
|
121
126
|
end
|
|
122
127
|
|
|
123
128
|
# POST /contacts/batch/remove — { ids: [...] } or { emails: [...] }
|
|
124
|
-
# (exactly one, up to 1000)
|
|
129
|
+
# (exactly one, up to 1000), plus erase: true to also scrub each
|
|
130
|
+
# address from email history, event payloads and API logs, as
|
|
131
|
+
# Contacts.remove does. Returns { data: [{ object:, contact:,
|
|
125
132
|
# deleted: true }] } listing only the rows actually deleted; unknown
|
|
126
133
|
# ids or addresses are skipped.
|
|
127
134
|
def remove(params)
|
data/lib/millionsend/domains.rb
CHANGED
|
@@ -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
|
data/lib/millionsend/emails.rb
CHANGED
|
@@ -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)
|
data/lib/millionsend/error.rb
CHANGED
|
@@ -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
|
data/lib/millionsend/request.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/millionsend/util.rb
CHANGED
|
@@ -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)
|
data/lib/millionsend/version.rb
CHANGED
data/lib/millionsend/webhooks.rb
CHANGED
|
@@ -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: "
|
|
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.
|
|
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-
|
|
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
|
|
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
|