millionsend 0.5.0 → 0.7.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 +37 -5
- data/lib/millionsend/contacts.rb +52 -10
- data/lib/millionsend/segments.rb +4 -3
- data/lib/millionsend/util.rb +7 -0
- data/lib/millionsend/version.rb +1 -1
- data/lib/millionsend/webhooks.rb +10 -1
- 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: b7af1c0b498dd1ff772b222aac67dce73ea3c1f4c71a7d2a9d4d3e427ef8a141
|
|
4
|
+
data.tar.gz: 8f019e8f091187618102b125d58bfe0233be20f838cb61059ef5e6ed979d1e09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b382ae969cd92102bb62a9108d64f7bf39b159c98c660ad03b2726a58573e81dde023ee474c2194e744d5c34775e8a1fb667bf9e64c52058850ddc4c9b19d9e6
|
|
7
|
+
data.tar.gz: 1666f792e81b7eaab5cc93fb314e11a80e90e92dec25a6a0c12ef0c090ddf4d03a48f982d7059f952e254af7878dfc62c6680e28d7a47a199e6989b5fbe9daf3
|
data/README.md
CHANGED
|
@@ -123,12 +123,20 @@ Millionsend::Contacts.get("ada@acme.dev") # by id or email; also resend-ruby's g
|
|
|
123
123
|
Millionsend::Contacts.update(id: contact[:id], unsubscribed: true, first_name: nil) # nil clears
|
|
124
124
|
Millionsend::Contacts.remove("ada@acme.dev")
|
|
125
125
|
Millionsend::Contacts.list(limit: 50)
|
|
126
|
+
# Bulk read (MillionSend extension): carry the property map and the topic subscriptions on every
|
|
127
|
+
# item, so an audience reads in one request per 100 contacts instead of one per contact
|
|
128
|
+
Millionsend::Contacts.list(limit: 100, include: ["properties", "topics"]) # ?include=properties,topics
|
|
126
129
|
|
|
127
130
|
# Topic subscriptions (granular unsubscribe) — PATCH /contacts/:id/topics
|
|
128
131
|
Millionsend::Contacts::Topics.update(email: "ada@acme.dev", topics: [{ id: topic_id, subscription: "opt_out" }]) # resend-ruby shape
|
|
129
132
|
Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }]) # positional
|
|
130
133
|
Millionsend::Contacts::Topics.list(email: "ada@acme.dev") # GET /contacts/:id/topics — every topic with its effective
|
|
131
|
-
# subscription
|
|
134
|
+
# subscription (explicit: false when it is the topic's default)
|
|
135
|
+
# and its visibility ("public" | "private")
|
|
136
|
+
|
|
137
|
+
# Preference-center link (MillionSend extension) — POST /contacts/:id/preferences-link
|
|
138
|
+
link = Millionsend::Contacts.preferences_link("ada@acme.dev") # by id or email; also preferences_link(id: ...) / (email: ...)
|
|
139
|
+
link[:url] # the contact's hosted preference page; no expiry, so show it only to that contact
|
|
132
140
|
|
|
133
141
|
# Segment membership — POST / DELETE /contacts/:id/segments/:segment_id
|
|
134
142
|
Millionsend::Contacts::Segments.add("ada@acme.dev", segment_id)
|
|
@@ -143,6 +151,16 @@ result = Millionsend::Contacts::Batch.create(
|
|
|
143
151
|
result[:data] # [{ index:, id:, status: "created" | "updated" | "skipped" }]
|
|
144
152
|
result[:counts] # { created:, updated:, skipped:, failed: }
|
|
145
153
|
result[:errors] # permissive mode only: [{ index:, message: }]
|
|
154
|
+
|
|
155
|
+
# Bulk lookup (MillionSend extension) — up to 1000 contacts by id or email in one request, in
|
|
156
|
+
# request order; unknown entries are listed, not errors — one request against the rate limit
|
|
157
|
+
found = Millionsend::Contacts::Batch.get([contact[:id], "b@acme.dev", { email: "c@acme.dev" }], include: ["topics"])
|
|
158
|
+
found[:data] # [{ object: "contact", id:, email:, first_name:, last_name:, created_at:, unsubscribed:, topics: }]
|
|
159
|
+
found[:missing] # [{ index:, email: }] / [{ index:, id: }] — request entries that matched nobody
|
|
160
|
+
|
|
161
|
+
# 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: [...]
|
|
163
|
+
# => { data: [{ object: "contact", contact: "<uuid>", deleted: true }, ...] } — only the rows actually deleted
|
|
146
164
|
```
|
|
147
165
|
|
|
148
166
|
Contacts are addressable by id or email; when an `update` hash carries both, the email wins.
|
|
@@ -237,14 +255,26 @@ hook = Millionsend::Webhooks.create(
|
|
|
237
255
|
signing_secret: "whsec_..." # optional: reuse an existing secret so the receiver keeps verifying
|
|
238
256
|
)
|
|
239
257
|
hook[:signing_secret]
|
|
240
|
-
Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret
|
|
258
|
+
Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret and previous_secret_expires_at
|
|
241
259
|
Millionsend::Webhooks.list
|
|
242
260
|
Millionsend::Webhooks.update(hook[:id], events: ["email.opened"], status: "disabled")
|
|
243
261
|
Millionsend::Webhooks.remove(hook[:id])
|
|
262
|
+
|
|
263
|
+
# Rotate the signing secret (MillionSend extension) — POST /webhooks/:id/rotate
|
|
264
|
+
rotated = Millionsend::Webhooks.rotate(hook[:id]) # mints a new secret, 24h overlap
|
|
265
|
+
rotated = Millionsend::Webhooks.rotate(hook[:id], signing_secret: "whsec_...", overlap_hours: 0) # bring your own, no overlap
|
|
266
|
+
rotated[:signing_secret] # the secret now signing deliveries
|
|
267
|
+
rotated[:previous_secret_expires_at] # ISO time until which the old secret also signs, or nil
|
|
244
268
|
```
|
|
245
269
|
|
|
270
|
+
During the overlap window (`overlap_hours`, 0–72, default 24) every delivery carries both
|
|
271
|
+
signatures, so a receiver holding either verifies; `Webhooks.get` reports the window's end
|
|
272
|
+
as `previous_secret_expires_at` (`nil` when none is open).
|
|
273
|
+
|
|
246
274
|
Events: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`,
|
|
247
|
-
`email.complained`, `email.opened`, `email.clicked`, `
|
|
275
|
+
`email.complained`, `email.opened`, `email.clicked`, `contact.created`, `contact.updated`,
|
|
276
|
+
`contact.deleted`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`,
|
|
277
|
+
`contact.topic_opt_out`, `suppression.added`, `suppression.removed`, `deliverability.warning`,
|
|
248
278
|
`deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`.
|
|
249
279
|
|
|
250
280
|
### API keys
|
|
@@ -286,6 +316,7 @@ segment = Millionsend::Segments.create(
|
|
|
286
316
|
Millionsend::Segments.get(segment[:id]) # includes a live contact_count
|
|
287
317
|
Millionsend::Segments.list
|
|
288
318
|
Millionsend::Segments.contacts(segment[:id], limit: 50) # the contacts currently matching
|
|
319
|
+
Millionsend::Segments.contacts(segment[:id], include: ["properties", "topics"]) # with each contact's extras, as Contacts.list
|
|
289
320
|
Millionsend::Segments.update(segment[:id], name: "Pro tier")
|
|
290
321
|
Millionsend::Segments.remove(segment[:id])
|
|
291
322
|
```
|
|
@@ -368,8 +399,9 @@ works as in resend-ruby. Notes:
|
|
|
368
399
|
Resend's audiences alias.
|
|
369
400
|
- Not in the API (yet), so not here: broadcast recipients/clicked links, email sharing and
|
|
370
401
|
metrics, contact imports, receiving, automations, logs, OAuth grants, webhook event replay.
|
|
371
|
-
- MillionSend extensions with no Resend counterpart: `Segments`, `Contacts::Batch`,
|
|
372
|
-
`
|
|
402
|
+
- MillionSend extensions with no Resend counterpart: `Segments`, `Contacts::Batch`,
|
|
403
|
+
`Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`,
|
|
404
|
+
`Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`.
|
|
373
405
|
|
|
374
406
|
## License
|
|
375
407
|
|
data/lib/millionsend/contacts.rb
CHANGED
|
@@ -29,11 +29,12 @@ module Millionsend
|
|
|
29
29
|
Millionsend::Request.new(method: :delete, path: member_path(id_or_email)).perform
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# GET /contacts — accepts limit:/after:/before
|
|
32
|
+
# GET /contacts — accepts limit:/after:/before: and include:
|
|
33
|
+
# (["properties", "topics"]) to carry the property map and the topic
|
|
34
|
+
# subscriptions on every item, as Contacts.get and Topics.list return them.
|
|
33
35
|
def list(options = {})
|
|
34
|
-
Millionsend::
|
|
35
|
-
|
|
36
|
-
).perform
|
|
36
|
+
query = Millionsend::Util.list_query(options).merge(include: Millionsend::Util.include_query(options[:include]))
|
|
37
|
+
Millionsend::Request.new(method: :get, path: "/contacts", query: query).perform
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# PATCH /contacts/:id_or_email/topics with a bare array of
|
|
@@ -42,11 +43,31 @@ module Millionsend
|
|
|
42
43
|
Millionsend::Request.new(method: :patch, path: "#{member_path(id_or_email)}/topics", body: topics).perform
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
# POST /contacts/:id_or_email/preferences-link — the contact's hosted
|
|
47
|
+
# preference page, { object: "preferences_link", contact:, url: }. The
|
|
48
|
+
# url is a contact-scoped capability with no expiry: hand it only to that
|
|
49
|
+
# contact. 422 when the instance cannot build hosted links.
|
|
50
|
+
def preferences_link(id_or_email)
|
|
51
|
+
Millionsend::Request.new(method: :post, path: "#{member_path(id_or_email)}/preferences-link").perform
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
# Every member method also accepts resend-ruby's addressing hash
|
|
46
55
|
# ({ id: } / { email: } / { contact_id: }) in place of the bare value.
|
|
47
56
|
def member_path(id_or_email)
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
"/contacts/#{Millionsend::Util.encode(unwrap(id_or_email))}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One /contacts/batch/get entry. The wire wants { id: } and { email: }
|
|
61
|
+
# told apart, and only an email carries "@".
|
|
62
|
+
def address(id_or_email)
|
|
63
|
+
value = unwrap(id_or_email)
|
|
64
|
+
value.to_s.include?("@") ? { email: value } : { id: value }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def unwrap(id_or_email)
|
|
70
|
+
id_or_email.is_a?(Hash) ? id_or_email[:email] || id_or_email[:id] || id_or_email[:contact_id] : id_or_email
|
|
50
71
|
end
|
|
51
72
|
end
|
|
52
73
|
|
|
@@ -54,9 +75,10 @@ module Millionsend
|
|
|
54
75
|
module Topics
|
|
55
76
|
class << self
|
|
56
77
|
# GET /contacts/:id_or_email/topics — { id: | email: } or a bare id/email.
|
|
57
|
-
# Every topic comes back with the contact's effective subscription
|
|
58
|
-
# explicit: false means it is the topic's default, not a stored
|
|
59
|
-
# Unpaginated, like
|
|
78
|
+
# Every topic comes back with the contact's effective subscription
|
|
79
|
+
# (explicit: false means it is the topic's default, not a stored
|
|
80
|
+
# choice) and its visibility ("public" | "private"). Unpaginated, like
|
|
81
|
+
# Topics.list.
|
|
60
82
|
def list(params)
|
|
61
83
|
Millionsend::Request.new(method: :get, path: "#{Millionsend::Contacts.member_path(params)}/topics").perform
|
|
62
84
|
end
|
|
@@ -68,7 +90,8 @@ module Millionsend
|
|
|
68
90
|
end
|
|
69
91
|
end
|
|
70
92
|
|
|
71
|
-
# Bulk contact creation —
|
|
93
|
+
# Bulk contact creation and deletion — MillionSend extensions (Resend
|
|
94
|
+
# imports via CSV and deletes one at a time).
|
|
72
95
|
module Batch
|
|
73
96
|
class << self
|
|
74
97
|
# POST /contacts/batch with a bare array of up to 1000 create payloads.
|
|
@@ -85,6 +108,25 @@ module Millionsend
|
|
|
85
108
|
**Millionsend::Util.request_options(options)
|
|
86
109
|
).perform
|
|
87
110
|
end
|
|
111
|
+
|
|
112
|
+
# POST /contacts/batch/get — up to 1000 contacts by id or email (bare
|
|
113
|
+
# values or addressing hashes, like Contacts.get) in one request, one
|
|
114
|
+
# call against the rate limit. Returns { data: [...] } in request order
|
|
115
|
+
# plus missing: [{ index:, id: | email: }] for the entries that matched
|
|
116
|
+
# nobody — those never fail the call. include: ["properties", "topics"]
|
|
117
|
+
# attaches the same extras as Contacts.list.
|
|
118
|
+
def get(addresses, options = {})
|
|
119
|
+
body = { contacts: addresses.map { |a| Millionsend::Contacts.address(a) }, include: options[:include] }
|
|
120
|
+
Millionsend::Request.new(method: :post, path: "/contacts/batch/get", body: body.compact).perform
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# POST /contacts/batch/remove — { ids: [...] } or { emails: [...] }
|
|
124
|
+
# (exactly one, up to 1000). Returns { data: [{ object:, contact:,
|
|
125
|
+
# deleted: true }] } listing only the rows actually deleted; unknown
|
|
126
|
+
# ids or addresses are skipped.
|
|
127
|
+
def remove(params)
|
|
128
|
+
Millionsend::Request.new(method: :post, path: "/contacts/batch/remove", body: params).perform
|
|
129
|
+
end
|
|
88
130
|
end
|
|
89
131
|
end
|
|
90
132
|
|
data/lib/millionsend/segments.rb
CHANGED
|
@@ -23,11 +23,12 @@ module Millionsend
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# GET /segments/:id/contacts — the contacts currently matching, paginated
|
|
26
|
-
# with limit:/after:/before
|
|
26
|
+
# with limit:/after:/before:; include: (["properties", "topics"]) as on
|
|
27
|
+
# Contacts.list.
|
|
27
28
|
def contacts(id, options = {})
|
|
29
|
+
query = Millionsend::Util.list_query(options).merge(include: Millionsend::Util.include_query(options[:include]))
|
|
28
30
|
Millionsend::Request.new(
|
|
29
|
-
method: :get, path: "/segments/#{Millionsend::Util.encode(id)}/contacts",
|
|
30
|
-
query: Millionsend::Util.list_query(options)
|
|
31
|
+
method: :get, path: "/segments/#{Millionsend::Util.encode(id)}/contacts", query: query
|
|
31
32
|
).perform
|
|
32
33
|
end
|
|
33
34
|
|
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
|
+
# ?include= for the contact lists: the names comma-joined, or nil (dropped
|
|
37
|
+
# from the query) when none were given.
|
|
38
|
+
def include_query(include)
|
|
39
|
+
names = Array(include)
|
|
40
|
+
names.empty? ? nil : names.join(",")
|
|
41
|
+
end
|
|
42
|
+
|
|
36
43
|
# Per-request options for the Request constructor. Both call shapes land in
|
|
37
44
|
# the same trailing positional hash: the original `idempotency_key: "k"` and
|
|
38
45
|
# resend-ruby's keyword form `options: { idempotency_key: "k" }`, so the
|
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)
|
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.7.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-
|
|
11
|
+
date: 2026-09-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|