millionsend 0.4.0 → 0.6.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 +45 -16
- data/lib/millionsend/contacts.rb +27 -1
- data/lib/millionsend/error.rb +2 -0
- data/lib/millionsend/version.rb +1 -1
- data/lib/millionsend/webhooks.rb +10 -1
- data/lib/millionsend.rb +3 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d6e46f436a0805ef763edac14bbd254e6f17503c598a8374b6abe9f45efcc76
|
|
4
|
+
data.tar.gz: 04c8ab67229c03c8769c7f9ef872b0e11b98d8aa8919a21c0d4c6ce6cc0582f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea4ff1283155e92f65aea2088a6ae4abdfd5032b3c93c243eb838a30ab141070c328bc7d2a278e0399894ec1e18300abef83a727ace9ed48a4b7e3271941e284
|
|
7
|
+
data.tar.gz: e845a9d24731c584b931a0de44fd7f0adee3760bcaa8d45d55412e301c6e9ad545cc1d62e294938e8adbdba07ebc204d156a96584d2d5b16fc721826853137dc
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@ self-hostable, Resend-compatible email API on AWS SES.
|
|
|
5
5
|
|
|
6
6
|
The API is wire-compatible with Resend, and this gem deliberately mirrors the shape of
|
|
7
7
|
[`resend`](https://github.com/resend/resend-ruby), so migrating is mostly a find-and-replace:
|
|
8
|
-
swap the constant, set `base_url`
|
|
8
|
+
swap the constant (and, on a self-hosted instance, set `base_url`).
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -26,8 +26,8 @@ Requires Ruby 3.0+. Only the standard library is used at runtime (`net/http`, `j
|
|
|
26
26
|
```ruby
|
|
27
27
|
require "millionsend"
|
|
28
28
|
|
|
29
|
-
Millionsend.api_key
|
|
30
|
-
Millionsend.base_url = "https://mail.acme.dev"
|
|
29
|
+
Millionsend.api_key = "ms_123"
|
|
30
|
+
# Millionsend.base_url = "https://mail.acme.dev" # self-hosted only; defaults to MillionSend Cloud
|
|
31
31
|
|
|
32
32
|
email = Millionsend::Emails.send(
|
|
33
33
|
from: "Acme <onboarding@acme.dev>",
|
|
@@ -47,12 +47,12 @@ on any non-2xx response (see [Error handling](#error-handling)).
|
|
|
47
47
|
```ruby
|
|
48
48
|
Millionsend.api_key = "ms_123" # falls back to ENV["MILLIONSEND_API_KEY"]
|
|
49
49
|
Millionsend.base_url = "https://mail.acme.dev" # falls back to ENV["MILLIONSEND_BASE_URL"],
|
|
50
|
-
# then
|
|
50
|
+
# then https://api.millionsend.com (Cloud)
|
|
51
51
|
Millionsend.allow_insecure_http = false # accept a non-loopback http:// base_url
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
MillionSend
|
|
55
|
-
|
|
54
|
+
MillionSend Cloud works with just the API key; a self-hosted instance sets `base_url` to
|
|
55
|
+
its origin. An explicitly assigned value always wins over the environment.
|
|
56
56
|
Plain `http://` is only accepted for loopback hosts (`localhost`, `127.0.0.1`, `::1`); any
|
|
57
57
|
other `http://` URL raises `Millionsend::ApplicationError` on the first call, since the API
|
|
58
58
|
key is sent as a bearer header. Set `allow_insecure_http = true` to talk to a non-TLS
|
|
@@ -127,6 +127,13 @@ Millionsend::Contacts.list(limit: 50)
|
|
|
127
127
|
# Topic subscriptions (granular unsubscribe) — PATCH /contacts/:id/topics
|
|
128
128
|
Millionsend::Contacts::Topics.update(email: "ada@acme.dev", topics: [{ id: topic_id, subscription: "opt_out" }]) # resend-ruby shape
|
|
129
129
|
Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }]) # positional
|
|
130
|
+
Millionsend::Contacts::Topics.list(email: "ada@acme.dev") # GET /contacts/:id/topics — every topic with its effective
|
|
131
|
+
# subscription (explicit: false when it is the topic's default)
|
|
132
|
+
# and its visibility ("public" | "private")
|
|
133
|
+
|
|
134
|
+
# Preference-center link (MillionSend extension) — POST /contacts/:id/preferences-link
|
|
135
|
+
link = Millionsend::Contacts.preferences_link("ada@acme.dev") # by id or email; also preferences_link(id: ...) / (email: ...)
|
|
136
|
+
link[:url] # the contact's hosted preference page; no expiry, so show it only to that contact
|
|
130
137
|
|
|
131
138
|
# Segment membership — POST / DELETE /contacts/:id/segments/:segment_id
|
|
132
139
|
Millionsend::Contacts::Segments.add("ada@acme.dev", segment_id)
|
|
@@ -141,6 +148,10 @@ result = Millionsend::Contacts::Batch.create(
|
|
|
141
148
|
result[:data] # [{ index:, id:, status: "created" | "updated" | "skipped" }]
|
|
142
149
|
result[:counts] # { created:, updated:, skipped:, failed: }
|
|
143
150
|
result[:errors] # permissive mode only: [{ index:, message: }]
|
|
151
|
+
|
|
152
|
+
# Bulk delete (MillionSend extension) — up to 1000 per call, exactly one of ids: / emails:
|
|
153
|
+
Millionsend::Contacts::Batch.remove(emails: ["a@acme.dev", "b@acme.dev"]) # or ids: [...]
|
|
154
|
+
# => { data: [{ object: "contact", contact: "<uuid>", deleted: true }, ...] } — only the rows actually deleted
|
|
144
155
|
```
|
|
145
156
|
|
|
146
157
|
Contacts are addressable by id or email; when an `update` hash carries both, the email wins.
|
|
@@ -235,15 +246,27 @@ hook = Millionsend::Webhooks.create(
|
|
|
235
246
|
signing_secret: "whsec_..." # optional: reuse an existing secret so the receiver keeps verifying
|
|
236
247
|
)
|
|
237
248
|
hook[:signing_secret]
|
|
238
|
-
Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret
|
|
249
|
+
Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret and previous_secret_expires_at
|
|
239
250
|
Millionsend::Webhooks.list
|
|
240
251
|
Millionsend::Webhooks.update(hook[:id], events: ["email.opened"], status: "disabled")
|
|
241
252
|
Millionsend::Webhooks.remove(hook[:id])
|
|
253
|
+
|
|
254
|
+
# Rotate the signing secret (MillionSend extension) — POST /webhooks/:id/rotate
|
|
255
|
+
rotated = Millionsend::Webhooks.rotate(hook[:id]) # mints a new secret, 24h overlap
|
|
256
|
+
rotated = Millionsend::Webhooks.rotate(hook[:id], signing_secret: "whsec_...", overlap_hours: 0) # bring your own, no overlap
|
|
257
|
+
rotated[:signing_secret] # the secret now signing deliveries
|
|
258
|
+
rotated[:previous_secret_expires_at] # ISO time until which the old secret also signs, or nil
|
|
242
259
|
```
|
|
243
260
|
|
|
261
|
+
During the overlap window (`overlap_hours`, 0–72, default 24) every delivery carries both
|
|
262
|
+
signatures, so a receiver holding either verifies; `Webhooks.get` reports the window's end
|
|
263
|
+
as `previous_secret_expires_at` (`nil` when none is open).
|
|
264
|
+
|
|
244
265
|
Events: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`,
|
|
245
|
-
`email.complained`, `email.opened`, `email.clicked`, `
|
|
246
|
-
`
|
|
266
|
+
`email.complained`, `email.opened`, `email.clicked`, `contact.created`, `contact.updated`,
|
|
267
|
+
`contact.deleted`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`,
|
|
268
|
+
`contact.topic_opt_out`, `suppression.added`, `suppression.removed`, `deliverability.warning`,
|
|
269
|
+
`deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`.
|
|
247
270
|
|
|
248
271
|
### API keys
|
|
249
272
|
|
|
@@ -326,12 +349,16 @@ rescue Millionsend::Error => e
|
|
|
326
349
|
end
|
|
327
350
|
```
|
|
328
351
|
|
|
329
|
-
Subclasses: `ValidationError`, `
|
|
330
|
-
`
|
|
331
|
-
`
|
|
332
|
-
`
|
|
352
|
+
Subclasses: `ValidationError`, `AllRecipientsSuppressedError`, `NotFoundError`,
|
|
353
|
+
`MissingApiKeyError`, `InvalidApiKeyError`, `RestrictedApiKeyError`, `SendingPausedError`,
|
|
354
|
+
`BroadcastsPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`,
|
|
355
|
+
`InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InternalServerError`, and
|
|
356
|
+
`ApplicationError` (the fallback for unknown names). Client-side and
|
|
333
357
|
transport failures that never reached the API raise `ApplicationError` with `#status_code == nil`.
|
|
334
358
|
|
|
359
|
+
`Emails.send` and `Batch.send` raise `AllRecipientsSuppressedError` (`all_recipients_suppressed`,
|
|
360
|
+
422) when every `to` recipient is on the suppression list or opted out of the send's `topic_id`.
|
|
361
|
+
|
|
335
362
|
## Migrating from Resend
|
|
336
363
|
|
|
337
364
|
```diff
|
|
@@ -340,7 +367,7 @@ transport failures that never reached the API raise `ApplicationError` with `#st
|
|
|
340
367
|
- Resend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
|
|
341
368
|
+ require "millionsend"
|
|
342
369
|
+ Millionsend.api_key = "ms_123"
|
|
343
|
-
+ Millionsend.base_url = "https://mail.acme.dev"
|
|
370
|
+
+ Millionsend.base_url = "https://mail.acme.dev" # self-hosted only
|
|
344
371
|
+ Millionsend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
|
|
345
372
|
```
|
|
346
373
|
|
|
@@ -354,6 +381,7 @@ works as in resend-ruby. Notes:
|
|
|
354
381
|
accepts both.
|
|
355
382
|
- `Contacts` member methods and `Contacts::Segments` / `Contacts::Topics` accept resend-ruby's
|
|
356
383
|
addressing hashes (`id:` / `email:` / `contact_id:`, `segment_id:`) as well as bare values.
|
|
384
|
+
`Contacts::Topics.list` is unpaginated, so resend-ruby's `limit:`/`after:`/`before:` are not sent.
|
|
357
385
|
`Suppressions::Batch` is nested as in resend-ruby.
|
|
358
386
|
- **No audiences** — contacts are team-global, so there is no `Audiences` resource and no
|
|
359
387
|
`audience_id` params. The API's `/audiences/*` routes are a compatibility shim and are not
|
|
@@ -361,8 +389,9 @@ works as in resend-ruby. Notes:
|
|
|
361
389
|
Resend's audiences alias.
|
|
362
390
|
- Not in the API (yet), so not here: broadcast recipients/clicked links, email sharing and
|
|
363
391
|
metrics, contact imports, receiving, automations, logs, OAuth grants, webhook event replay.
|
|
364
|
-
- MillionSend extensions with no Resend counterpart: `Segments`, `Contacts::Batch`,
|
|
365
|
-
`
|
|
392
|
+
- MillionSend extensions with no Resend counterpart: `Segments`, `Contacts::Batch`,
|
|
393
|
+
`Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`,
|
|
394
|
+
`Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`.
|
|
366
395
|
|
|
367
396
|
## License
|
|
368
397
|
|
data/lib/millionsend/contacts.rb
CHANGED
|
@@ -42,6 +42,14 @@ module Millionsend
|
|
|
42
42
|
Millionsend::Request.new(method: :patch, path: "#{member_path(id_or_email)}/topics", body: topics).perform
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# POST /contacts/:id_or_email/preferences-link — the contact's hosted
|
|
46
|
+
# preference page, { object: "preferences_link", contact:, url: }. The
|
|
47
|
+
# url is a contact-scoped capability with no expiry: hand it only to that
|
|
48
|
+
# contact. 422 when the instance cannot build hosted links.
|
|
49
|
+
def preferences_link(id_or_email)
|
|
50
|
+
Millionsend::Request.new(method: :post, path: "#{member_path(id_or_email)}/preferences-link").perform
|
|
51
|
+
end
|
|
52
|
+
|
|
45
53
|
# Every member method also accepts resend-ruby's addressing hash
|
|
46
54
|
# ({ id: } / { email: } / { contact_id: }) in place of the bare value.
|
|
47
55
|
def member_path(id_or_email)
|
|
@@ -53,6 +61,15 @@ module Millionsend
|
|
|
53
61
|
# Topic subscriptions of one contact, in resend-ruby's nested shape.
|
|
54
62
|
module Topics
|
|
55
63
|
class << self
|
|
64
|
+
# GET /contacts/:id_or_email/topics — { id: | email: } or a bare id/email.
|
|
65
|
+
# Every topic comes back with the contact's effective subscription
|
|
66
|
+
# (explicit: false means it is the topic's default, not a stored
|
|
67
|
+
# choice) and its visibility ("public" | "private"). Unpaginated, like
|
|
68
|
+
# Topics.list.
|
|
69
|
+
def list(params)
|
|
70
|
+
Millionsend::Request.new(method: :get, path: "#{Millionsend::Contacts.member_path(params)}/topics").perform
|
|
71
|
+
end
|
|
72
|
+
|
|
56
73
|
# PATCH /contacts/:id_or_email/topics — { id: | email:, topics: [{ id:, subscription: }] }.
|
|
57
74
|
def update(params)
|
|
58
75
|
Millionsend::Contacts.topics_update(params, params[:topics])
|
|
@@ -60,7 +77,8 @@ module Millionsend
|
|
|
60
77
|
end
|
|
61
78
|
end
|
|
62
79
|
|
|
63
|
-
# Bulk contact creation —
|
|
80
|
+
# Bulk contact creation and deletion — MillionSend extensions (Resend
|
|
81
|
+
# imports via CSV and deletes one at a time).
|
|
64
82
|
module Batch
|
|
65
83
|
class << self
|
|
66
84
|
# POST /contacts/batch with a bare array of up to 1000 create payloads.
|
|
@@ -77,6 +95,14 @@ module Millionsend
|
|
|
77
95
|
**Millionsend::Util.request_options(options)
|
|
78
96
|
).perform
|
|
79
97
|
end
|
|
98
|
+
|
|
99
|
+
# POST /contacts/batch/remove — { ids: [...] } or { emails: [...] }
|
|
100
|
+
# (exactly one, up to 1000). Returns { data: [{ object:, contact:,
|
|
101
|
+
# deleted: true }] } listing only the rows actually deleted; unknown
|
|
102
|
+
# ids or addresses are skipped.
|
|
103
|
+
def remove(params)
|
|
104
|
+
Millionsend::Request.new(method: :post, path: "/contacts/batch/remove", body: params).perform
|
|
105
|
+
end
|
|
80
106
|
end
|
|
81
107
|
end
|
|
82
108
|
|
data/lib/millionsend/error.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Millionsend
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
class ValidationError < Error; end
|
|
34
|
+
class AllRecipientsSuppressedError < Error; end
|
|
34
35
|
class NotFoundError < Error; end
|
|
35
36
|
class MissingApiKeyError < Error; end
|
|
36
37
|
class InvalidApiKeyError < Error; end
|
|
@@ -46,6 +47,7 @@ module Millionsend
|
|
|
46
47
|
|
|
47
48
|
ERROR_TYPES = {
|
|
48
49
|
"validation_error" => ValidationError,
|
|
50
|
+
"all_recipients_suppressed" => AllRecipientsSuppressedError,
|
|
49
51
|
"not_found" => NotFoundError,
|
|
50
52
|
"missing_api_key" => MissingApiKeyError,
|
|
51
53
|
"invalid_api_key" => InvalidApiKeyError,
|
data/lib/millionsend/version.rb
CHANGED
data/lib/millionsend/webhooks.rb
CHANGED
|
@@ -10,7 +10,8 @@ module Millionsend
|
|
|
10
10
|
Millionsend::Request.new(method: :post, path: "/webhooks", body: params).perform
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
# GET /webhooks/:id — includes signing_secret
|
|
13
|
+
# GET /webhooks/:id — includes signing_secret and
|
|
14
|
+
# previous_secret_expires_at (nil unless a rotation's overlap window is open).
|
|
14
15
|
def get(id)
|
|
15
16
|
Millionsend::Request.new(method: :get, path: member_path(id)).perform
|
|
16
17
|
end
|
|
@@ -30,6 +31,14 @@ module Millionsend
|
|
|
30
31
|
Millionsend::Request.new(method: :delete, path: member_path(id)).perform
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
# POST /webhooks/:id/rotate — signing_secret (optional whsec_ value to
|
|
35
|
+
# bring your own; omitted mints one) and overlap_hours (0..72, default
|
|
36
|
+
# 24) during which deliveries carry both signatures. Returns { object:,
|
|
37
|
+
# id:, signing_secret:, previous_secret_expires_at: }.
|
|
38
|
+
def rotate(id, params = {})
|
|
39
|
+
Millionsend::Request.new(method: :post, path: "#{member_path(id)}/rotate", body: params).perform
|
|
40
|
+
end
|
|
41
|
+
|
|
33
42
|
private
|
|
34
43
|
|
|
35
44
|
def member_path(id)
|
data/lib/millionsend.rb
CHANGED
|
@@ -28,13 +28,13 @@ require "millionsend/deliverability"
|
|
|
28
28
|
# subject: "Hi", html: "<strong>it works</strong>")
|
|
29
29
|
#
|
|
30
30
|
# api_key falls back to the MILLIONSEND_API_KEY env var; base_url to
|
|
31
|
-
# MILLIONSEND_BASE_URL and then
|
|
32
|
-
#
|
|
31
|
+
# MILLIONSEND_BASE_URL and then MillionSend Cloud, so a self-hosted instance
|
|
32
|
+
# sets its own origin. Plain http is only accepted for
|
|
33
33
|
# loopback hosts unless allow_insecure_http is set, since the API key travels
|
|
34
34
|
# as a bearer header. Every call returns a symbol-keyed Hash on success and
|
|
35
35
|
# raises a Millionsend::Error on any non-2xx response.
|
|
36
36
|
module Millionsend
|
|
37
|
-
DEFAULT_BASE_URL = "
|
|
37
|
+
DEFAULT_BASE_URL = "https://api.millionsend.com"
|
|
38
38
|
USER_AGENT = "millionsend-ruby/#{VERSION}"
|
|
39
39
|
|
|
40
40
|
class << self
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: millionsend
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MillionSend
|
|
@@ -55,7 +55,7 @@ dependencies:
|
|
|
55
55
|
description: 'Ruby client for the MillionSend HTTP API: emails, batch, contacts, contact
|
|
56
56
|
properties, topics, broadcasts, segments, suppressions, domains, webhooks, API keys,
|
|
57
57
|
templates and usage. Wire-compatible with Resend and mirror-shaped after resend-ruby,
|
|
58
|
-
so migrating is mostly an import swap
|
|
58
|
+
so migrating is mostly an import swap.'
|
|
59
59
|
email:
|
|
60
60
|
executables: []
|
|
61
61
|
extensions: []
|