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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f44b4a952ce5037c565e1ba8071a29ddf7d2b7d875fd99cad7719e91155d3054
4
- data.tar.gz: a6b58d948cd026fe50ff598cc46129d6e9c164de71453dcdd77855237c0376fd
3
+ metadata.gz: 0d6e46f436a0805ef763edac14bbd254e6f17503c598a8374b6abe9f45efcc76
4
+ data.tar.gz: 04c8ab67229c03c8769c7f9ef872b0e11b98d8aa8919a21c0d4c6ce6cc0582f9
5
5
  SHA512:
6
- metadata.gz: 4c16c5f8ee728136b872cc3f6a83d78052c62649c7b69497973d72845fb318669073078eb54406863fb0d83cd5336868896e098da794677b50667eefb4f0ec7f
7
- data.tar.gz: d492c8d93ddcfff8e5f887f50aa7200fa0e30856effd041530a19a62cd2c2ad07ed47520c9f5fb57a32e4078103d6edad6619cb45cbba888cb94e0f01ff0faf8
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` to your instance.
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 = "ms_123"
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 http://localhost:3001
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 is self-hosted, so there is no cloud default **set `base_url` to your
55
- deployment in production.** An explicitly assigned value always wins over the environment.
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`, `deliverability.warning`,
246
- `deliverability.paused`, `quota.warning`, `quota.reached`.
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`, `NotFoundError`, `MissingApiKeyError`, `InvalidApiKeyError`,
330
- `RestrictedApiKeyError`, `SendingPausedError`, `BroadcastsPausedError`, `RateLimitExceededError`,
331
- `DailyQuotaExceededError`, `InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`,
332
- `InternalServerError`, and `ApplicationError` (the fallback for unknown names). Client-side and
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`, `Usage`,
365
- `Deliverability`, `Emails.get_insights`, `Suppressions` `origin: "unsubscribe"`.
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
 
@@ -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 — a MillionSend extension (Resend imports via CSV).
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
 
@@ -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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.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)
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 http://localhost:3001 (MillionSend is
32
- # self-hosted, so there is no cloud default). Plain http is only accepted for
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 = "http://localhost:3001"
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.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 plus a base_url.'
58
+ so migrating is mostly an import swap.'
59
59
  email:
60
60
  executables: []
61
61
  extensions: []