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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9260a596d597a0aad944da04656fb5c99606374b48b9e235f3e883917ef2bc6
4
- data.tar.gz: 677e347b5604bd68edf198df96802d652470d3e4aef679460fdb24bce3563993
3
+ metadata.gz: b7af1c0b498dd1ff772b222aac67dce73ea3c1f4c71a7d2a9d4d3e427ef8a141
4
+ data.tar.gz: 8f019e8f091187618102b125d58bfe0233be20f838cb61059ef5e6ed979d1e09
5
5
  SHA512:
6
- metadata.gz: fb4c0031b1c2b22911ebba5b0f5d45c05b613196b2e665c11212d5146c5e7102c4cdbba343245acf814bffc6e8394d603c2c124d325a25c4c70fc1a49ac21789
7
- data.tar.gz: bae43393a0387f459f882638a9eb6a7961cdc6d9b94b602d914cd199c071ce7e403706610e89e1426273e347cb1a3d6393518cf7d9f91d567352692c8765f2fe
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; explicit: false when it is the topic's default
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`, `deliverability.warning`,
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`, `Usage`,
372
- `Deliverability`, `Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`.
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
 
@@ -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::Request.new(
35
- method: :get, path: "/contacts", query: Millionsend::Util.list_query(options)
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
- id_or_email = id_or_email[:email] || id_or_email[:id] || id_or_email[:contact_id] if id_or_email.is_a?(Hash)
49
- "/contacts/#{Millionsend::Util.encode(id_or_email)}"
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 choice.
59
- # Unpaginated, like Topics.list.
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 — a MillionSend extension (Resend imports via CSV).
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
 
@@ -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
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -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.5.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-04 00:00:00.000000000 Z
11
+ date: 2026-09-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake