millionsend 0.3.0 → 0.4.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: 3a05f5a8a6f73d20210b65a538c84b985206374628dd5298793c01d37bd9138f
4
- data.tar.gz: 7fe8ff20393c98a38072d70617e0df869aefebdb14a577db320f8514d17eca05
3
+ metadata.gz: f44b4a952ce5037c565e1ba8071a29ddf7d2b7d875fd99cad7719e91155d3054
4
+ data.tar.gz: a6b58d948cd026fe50ff598cc46129d6e9c164de71453dcdd77855237c0376fd
5
5
  SHA512:
6
- metadata.gz: ec4a59041ec45a8c620b9fa2ff618971273b0dc6ba43ba75e758cec70653978e9a428249acd52c060b79ab83cdc70b5f119b019b2407b2ce802f2407444cbee9
7
- data.tar.gz: 2fc47253ed0511c373c8273f84457ad90f812db1d4d6d9df8c4d58019780e34e484681239679033f9b9d0ca435cebac24c19f23f5438c26cd92c2d388bf6be85
6
+ metadata.gz: 4c16c5f8ee728136b872cc3f6a83d78052c62649c7b69497973d72845fb318669073078eb54406863fb0d83cd5336868896e098da794677b50667eefb4f0ec7f
7
+ data.tar.gz: d492c8d93ddcfff8e5f887f50aa7200fa0e30856effd041530a19a62cd2c2ad07ed47520c9f5fb57a32e4078103d6edad6619cb45cbba888cb94e0f01ff0faf8
data/README.md CHANGED
@@ -57,51 +57,115 @@ Plain `http://` is only accepted for loopback hosts (`localhost`, `127.0.0.1`, `
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
59
59
  instance elsewhere (e.g. inside a private network).
60
- Params are symbol-keyed hashes and map straight to the wire (Ruby's snake_case is already
61
- the wire's snake_case: `reply_to`, `scheduled_at`, `segment_id`).
60
+
61
+ Params are symbol-keyed hashes and go on the wire **exactly as given** — nothing is
62
+ filtered or renamed (Ruby's snake_case is already the wire's snake_case: `reply_to`,
63
+ `scheduled_at`, `segment_id`). A `nil` value is sent as JSON `null`, which is how the API
64
+ clears a nullable field (`topic_id`, `first_name`, a template `alias`, …); omit the key to
65
+ leave it unchanged.
66
+
67
+ ## Request options
68
+
69
+ `Emails.send`, `Batch.send` and `Contacts::Batch.create` take a trailing options hash, in
70
+ either of two shapes:
71
+
72
+ ```ruby
73
+ Millionsend::Emails.send(payload, idempotency_key: "order-42") # flat
74
+ Millionsend::Emails.send(payload, options: { idempotency_key: "order-42" }) # resend-ruby keyword shape
75
+ ```
76
+
77
+ | option | header / query | where |
78
+ | ------------------ | --------------------- | --------------------------------------- |
79
+ | `idempotency_key` | `Idempotency-Key` | any POST |
80
+ | `batch_validation` | `x-batch-validation` | `Batch.send`, `Contacts::Batch.create` |
81
+ | `on_conflict` | `?on_conflict=` | `Contacts::Batch.create` |
82
+
83
+ `batch_validation` is `"strict"` by default (one invalid item rejects the whole batch) or
84
+ `"permissive"` (the valid subset is processed and the failures come back under `errors:`
85
+ as `[{ index:, message: }]`).
62
86
 
63
87
  ## Resources
64
88
 
65
89
  ### Emails
66
90
 
67
91
  ```ruby
68
- Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST /emails
69
- Millionsend::Emails.get(id) # GET /emails/:id (includes score: 0-10 or nil)
70
- Millionsend::Emails.get_insights(id) # GET /emails/:id/insights (404 until computed)
71
- Millionsend::Emails.cancel(id) # POST /emails/:id/cancel (scheduled only)
72
-
73
- Millionsend::Batch.send([payload_a, payload_b], idempotency_key: "run-7") # up to 100
92
+ Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST /emails
93
+ Millionsend::Emails.get(id) # GET /emails/:id (includes score: 0-10 or nil)
94
+ Millionsend::Emails.list(limit: 20, after: id) # GET /emails
95
+ Millionsend::Emails.update(id, scheduled_at: "in 2 hours") # PATCH /emails/:id (scheduled only)
96
+ Millionsend::Emails.cancel(id) # POST /emails/:id/cancel (scheduled only)
97
+ Millionsend::Emails.remove(id) # DELETE /emails/:id
98
+ Millionsend::Emails.get_insights(id) # GET /emails/:id/insights (404 until computed)
99
+
100
+ Millionsend::Batch.send([payload_a, payload_b], idempotency_key: "run-7", batch_validation: "permissive") # up to 100
74
101
  ```
75
102
 
76
- `to`, `cc`, `bcc` and `reply_to` accept either a string or an array. `Emails.create` is
77
- an alias of `Emails.send` (as is `Batch.create`), mirroring Resend.
103
+ The send payload accepts `from`, `to`, `subject`, `html`, `text`, `cc`, `bcc`, `reply_to`,
104
+ `scheduled_at`, `tags: [{ name:, value: }]`, `topic_id`, `headers: { "X-..." => "..." }`,
105
+ `attachments: [{ filename:, content: <base64>, content_type:, content_id:, path: }]` and
106
+ `template` (passed through; the server currently answers 422 for it). `to`, `cc`, `bcc` and
107
+ `reply_to` accept either a string or an array. `Emails.create` is an alias of `Emails.send`
108
+ (as is `Batch.create`), mirroring Resend. `Emails.update` also takes resend-ruby's single
109
+ hash: `Emails.update(email_id: id, scheduled_at: ...)`.
78
110
 
79
111
  ### Contacts
80
112
 
81
113
  Contacts are team-global — one list per team, no audiences.
82
114
 
83
115
  ```ruby
84
- contact = Millionsend::Contacts.create(email: "ada@acme.dev", first_name: "Ada",
85
- properties: { plan: "pro" })
86
- Millionsend::Contacts.get("ada@acme.dev") # by id or email
116
+ contact = Millionsend::Contacts.create(
117
+ email: "ada@acme.dev", first_name: "Ada", last_name: "Lovelace", unsubscribed: false,
118
+ properties: { plan: "pro", seats: 3 },
119
+ segments: [{ id: segment_id }],
120
+ topics: [{ id: topic_id, subscription: "opt_in" }]
121
+ )
122
+ Millionsend::Contacts.get("ada@acme.dev") # by id or email; also resend-ruby's get(id: ...) / get(email: ...)
87
123
  Millionsend::Contacts.update(id: contact[:id], unsubscribed: true, first_name: nil) # nil clears
88
124
  Millionsend::Contacts.remove("ada@acme.dev")
89
125
  Millionsend::Contacts.list(limit: 50)
90
126
 
91
- # Topic subscriptions (granular unsubscribe) — mirrors resend's contacts.topics.update
92
- Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }])
127
+ # Topic subscriptions (granular unsubscribe) — PATCH /contacts/:id/topics
128
+ Millionsend::Contacts::Topics.update(email: "ada@acme.dev", topics: [{ id: topic_id, subscription: "opt_out" }]) # resend-ruby shape
129
+ Millionsend::Contacts.topics_update("ada@acme.dev", [{ id: topic_id, subscription: "opt_out" }]) # positional
130
+
131
+ # Segment membership — POST / DELETE /contacts/:id/segments/:segment_id
132
+ Millionsend::Contacts::Segments.add("ada@acme.dev", segment_id)
133
+ Millionsend::Contacts::Segments.remove(contact_id: contact[:id], segment_id: segment_id) # resend-ruby shape
134
+
135
+ # Bulk create (MillionSend extension) — up to 1000 per call
136
+ result = Millionsend::Contacts::Batch.create(
137
+ [{ email: "a@acme.dev" }, { email: "b@acme.dev", first_name: "B" }],
138
+ on_conflict: "upsert", # "error" (default) | "skip" | "upsert"
139
+ batch_validation: "permissive" # "strict" (default) | "permissive"
140
+ )
141
+ result[:data] # [{ index:, id:, status: "created" | "updated" | "skipped" }]
142
+ result[:counts] # { created:, updated:, skipped:, failed: }
143
+ result[:errors] # permissive mode only: [{ index:, message: }]
93
144
  ```
94
145
 
95
146
  Contacts are addressable by id or email; when an `update` hash carries both, the email wins.
96
147
  Emails are unique per team (case-insensitive) — a duplicate `create` raises
97
148
  `Millionsend::ValidationError`.
98
149
 
150
+ ### Contact properties
151
+
152
+ Typed custom fields for contacts. `key` and `type` are fixed at creation.
153
+
154
+ ```ruby
155
+ prop = Millionsend::ContactProperties.create(key: "plan", type: "string", fallback_value: "free")
156
+ Millionsend::ContactProperties.get(prop[:id])
157
+ Millionsend::ContactProperties.list(limit: 50)
158
+ Millionsend::ContactProperties.update(prop[:id], fallback_value: nil) # nil clears
159
+ Millionsend::ContactProperties.remove(prop[:id])
160
+ ```
161
+
99
162
  ### Topics
100
163
 
101
164
  ```ruby
102
- Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in")
165
+ Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in", visibility: "public")
103
166
  Millionsend::Topics.get(id)
104
167
  Millionsend::Topics.list # bare { data: [...] } — topics are unpaginated
168
+ Millionsend::Topics.update(id, name: "Product news")
105
169
  Millionsend::Topics.remove(id)
106
170
  ```
107
171
 
@@ -109,19 +173,104 @@ Millionsend::Topics.remove(id)
109
173
 
110
174
  ```ruby
111
175
  broadcast = Millionsend::Broadcasts.create(
176
+ name: "Launch", # internal name shown in the dashboard
112
177
  segment_id: segment[:id], # optional; omit segment_id and topic_id to send to all contacts
178
+ topic_id: topic[:id], # optional; only contacts subscribed to it receive it
113
179
  from: "Acme <news@acme.dev>",
114
180
  subject: "Launch",
115
- html: "<p>Hi {{{FIRST_NAME|there}}}</p>"
181
+ html: "<p>Hi {{{FIRST_NAME|there}}}</p>",
182
+ text: "Hi",
183
+ reply_to: "hello@acme.dev",
184
+ preview_text: "It's here",
185
+ send: false, # true sends (or, with scheduled_at, schedules) instead of saving a draft
186
+ scheduled_at: nil # "2026-09-01T09:00:00Z" or "in 1 hour" (requires send: true)
116
187
  )
117
188
  Millionsend::Broadcasts.list
118
189
  Millionsend::Broadcasts.get(broadcast[:id])
119
- Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀") # draft only
190
+ Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀", topic_id: nil) # draft only; nil clears
120
191
  Millionsend::Broadcasts.send(broadcast[:id], scheduled_at: "2026-09-01T09:00:00Z") # omit to send now
121
192
  Millionsend::Broadcasts.cancel(broadcast[:id]) # scheduled only
122
193
  Millionsend::Broadcasts.remove(broadcast[:id]) # draft only
123
194
  ```
124
195
 
196
+ ### Suppressions
197
+
198
+ Addresses the API refuses to send to. Entries are addressable by id or by email.
199
+
200
+ ```ruby
201
+ Millionsend::Suppressions.add(email: "bounced@example.com", origin: "manual") # origin: bounce | complaint | manual | unsubscribe
202
+ Millionsend::Suppressions.get("bounced@example.com")
203
+ Millionsend::Suppressions.list(limit: 50, origin: "bounce")
204
+ Millionsend::Suppressions.remove("bounced@example.com")
205
+
206
+ Millionsend::Suppressions::Batch.add(emails: ["a@example.com", "b@example.com"], origin: "unsubscribe") # up to 1000
207
+ Millionsend::Suppressions::Batch.remove(emails: ["a@example.com"]) # or ids: [...]
208
+ ```
209
+
210
+ `Suppressions.create` is an alias of `Suppressions.add`.
211
+
212
+ ### Domains
213
+
214
+ ```ruby
215
+ domain = Millionsend::Domains.create(
216
+ name: "acme.dev",
217
+ region: "us-east-1", # optional; a deployment serves one region
218
+ custom_return_path: "send", # optional
219
+ open_tracking: true, click_tracking: true, tracking_subdomain: "links"
220
+ )
221
+ domain[:records] # DNS records to publish, each with its own status
222
+ Millionsend::Domains.get(domain[:id])
223
+ Millionsend::Domains.list
224
+ Millionsend::Domains.verify(domain[:id]) # re-check DNS now
225
+ Millionsend::Domains.update(domain[:id], open_tracking: false, click_tracking: true, tracking_subdomain: nil) # nil clears
226
+ Millionsend::Domains.remove(domain[:id])
227
+ ```
228
+
229
+ ### Webhooks
230
+
231
+ ```ruby
232
+ hook = Millionsend::Webhooks.create(
233
+ endpoint: "https://acme.dev/hooks/millionsend",
234
+ events: ["email.delivered", "email.bounced", "email.complained"],
235
+ signing_secret: "whsec_..." # optional: reuse an existing secret so the receiver keeps verifying
236
+ )
237
+ hook[:signing_secret]
238
+ Millionsend::Webhooks.get(hook[:id]) # also returns signing_secret
239
+ Millionsend::Webhooks.list
240
+ Millionsend::Webhooks.update(hook[:id], events: ["email.opened"], status: "disabled")
241
+ Millionsend::Webhooks.remove(hook[:id])
242
+ ```
243
+
244
+ 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`.
247
+
248
+ ### API keys
249
+
250
+ ```ruby
251
+ key = Millionsend::ApiKeys.create(name: "ci", permission: "sending_access", domain_id: domain[:id])
252
+ key[:token] # only returned here
253
+ Millionsend::ApiKeys.list
254
+ Millionsend::ApiKeys.remove(key[:id])
255
+ ```
256
+
257
+ ### Templates
258
+
259
+ Addressable by id or alias.
260
+
261
+ ```ruby
262
+ tpl = Millionsend::Templates.create(name: "Welcome", html: "<p>Hi</p>", subject: "Welcome", text: "Hi", alias: "welcome")
263
+ Millionsend::Templates.get("welcome")
264
+ Millionsend::Templates.list
265
+ Millionsend::Templates.update("welcome", subject: nil, alias: nil) # nil clears subject/text/alias
266
+ Millionsend::Templates.publish(tpl[:id]) # templates are always published; kept as a no-op for compatibility
267
+ Millionsend::Templates.duplicate(tpl[:id]) # returns the copy's id
268
+ Millionsend::Templates.remove(tpl[:id])
269
+ ```
270
+
271
+ Resend's `from`, `reply_to` and `variables` template fields are passed through untouched; the
272
+ server currently answers 422 for them.
273
+
125
274
  ### Segments (MillionSend extension)
126
275
 
127
276
  Dynamic segments are a saved filter over the team's contacts — a MillionSend superset with
@@ -134,10 +283,20 @@ segment = Millionsend::Segments.create(
134
283
  )
135
284
  Millionsend::Segments.get(segment[:id]) # includes a live contact_count
136
285
  Millionsend::Segments.list
286
+ Millionsend::Segments.contacts(segment[:id], limit: 50) # the contacts currently matching
137
287
  Millionsend::Segments.update(segment[:id], name: "Pro tier")
138
288
  Millionsend::Segments.remove(segment[:id])
139
289
  ```
140
290
 
291
+ ### Usage (MillionSend extension)
292
+
293
+ ```ruby
294
+ usage = Millionsend::Usage.get # GET /usage
295
+ usage[:plan] # "free" | "pro" | "scale" | nil (self-hosted)
296
+ usage[:limits] # { emails_per_day:, domains: } — nil means unlimited
297
+ usage[:today] # { emails_sent:, resets_at: }
298
+ ```
299
+
141
300
  ### Deliverability (MillionSend extension)
142
301
 
143
302
  Per-email best-practice insights and the account-level deliverability score.
@@ -167,9 +326,11 @@ rescue Millionsend::Error => e
167
326
  end
168
327
  ```
169
328
 
170
- Subclasses: `ValidationError`, `NotFoundError`, `RestrictedApiKeyError`, `SendingPausedError`,
171
- `InvalidIdempotentRequestError`, and `ApplicationError` (the fallback). Client-side and
172
- transport failures that never reached the API raise with `#status_code == nil`.
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
333
+ transport failures that never reached the API raise `ApplicationError` with `#status_code == nil`.
173
334
 
174
335
  ## Migrating from Resend
175
336
 
@@ -183,15 +344,25 @@ transport failures that never reached the API raise with `#status_code == nil`.
183
344
  + Millionsend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
184
345
  ```
185
346
 
186
- Method names, nesting and payloads match. Notes:
187
-
188
- - **Domains and API keys** are managed in the MillionSend dashboard, not via the API, so there
189
- are no `Domains` / `ApiKeys` resources here.
190
- - Resend's `Contacts.topics.update` becomes `Millionsend::Contacts.topics_update` (Ruby has no
191
- nested-module method on a module function).
347
+ Method names, nesting and payloads match; `options: { idempotency_key:, batch_validation: }`
348
+ works as in resend-ruby. Notes:
349
+
350
+ - **`update` takes the id first.** On domains, webhooks, contact properties, topics, broadcasts
351
+ and segments it is `update(id, params)` where resend-ruby takes one hash carrying the id
352
+ (`:id`, `:topic_id`, `:broadcast_id`, `:segment_id`); templates already match resend-ruby's
353
+ `update(id, params)`, `Contacts.update` keeps resend's single-hash shape, and `Emails.update`
354
+ accepts both.
355
+ - `Contacts` member methods and `Contacts::Segments` / `Contacts::Topics` accept resend-ruby's
356
+ addressing hashes (`id:` / `email:` / `contact_id:`, `segment_id:`) as well as bare values.
357
+ `Suppressions::Batch` is nested as in resend-ruby.
192
358
  - **No audiences** — contacts are team-global, so there is no `Audiences` resource and no
193
- `audience_id` params. `Millionsend::Segments` is the dynamic-filter feature (`/segments`),
194
- not Resend's audiences alias.
359
+ `audience_id` params. The API's `/audiences/*` routes are a compatibility shim and are not
360
+ part of this SDK. `Millionsend::Segments` is the dynamic-filter feature (`/segments`), not
361
+ Resend's audiences alias.
362
+ - Not in the API (yet), so not here: broadcast recipients/clicked links, email sharing and
363
+ 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"`.
195
366
 
196
367
  ## License
197
368
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # API keys. The token is only ever returned by create.
5
+ module ApiKeys
6
+ class << self
7
+ # POST /api-keys — name, permission ("full_access" default or
8
+ # "sending_access"), domain_id. Returns { id:, token: }.
9
+ def create(params)
10
+ Millionsend::Request.new(method: :post, path: "/api-keys", body: params).perform
11
+ end
12
+
13
+ # GET /api-keys — accepts limit:/after:/before:.
14
+ def list(options = {})
15
+ Millionsend::Request.new(method: :get, path: "/api-keys", query: Millionsend::Util.list_query(options)).perform
16
+ end
17
+
18
+ # DELETE /api-keys/:id
19
+ def remove(id)
20
+ Millionsend::Request.new(method: :delete, path: "/api-keys/#{Millionsend::Util.encode(id)}").perform
21
+ end
22
+ end
23
+ end
24
+ end
@@ -4,11 +4,14 @@ module Millionsend
4
4
  # Send up to 100 emails in a single call.
5
5
  module Batch
6
6
  class << self
7
- # POST /emails/batch with a bare array body. A trailing options hash
8
- # carries idempotency_key (no keyword params, matching Emails.send).
7
+ # POST /emails/batch with a bare array body. The trailing hash carries
8
+ # idempotency_key and batch_validation ("strict", the default, rejects the
9
+ # whole batch on one invalid item; "permissive" sends the valid subset and
10
+ # lists the rest under errors[]) — either flat or under options:, like
11
+ # Emails.send.
9
12
  def send(list, options = {})
10
13
  Millionsend::Request.new(
11
- method: :post, path: "/emails/batch", body: list, idempotency_key: options[:idempotency_key]
14
+ method: :post, path: "/emails/batch", body: list, **Millionsend::Util.request_options(options)
12
15
  ).perform
13
16
  end
14
17
  alias_method :create, :send
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Typed custom properties for contacts. key and type are fixed at creation;
5
+ # only fallback_value can change.
6
+ module ContactProperties
7
+ class << self
8
+ # POST /contact-properties — key, type ("string"/"number"), fallback_value.
9
+ def create(params)
10
+ Millionsend::Request.new(method: :post, path: "/contact-properties", body: params).perform
11
+ end
12
+
13
+ # GET /contact-properties/:id
14
+ def get(id)
15
+ Millionsend::Request.new(method: :get, path: member_path(id)).perform
16
+ end
17
+
18
+ # GET /contact-properties — accepts limit:/after:/before:.
19
+ def list(options = {})
20
+ Millionsend::Request.new(
21
+ method: :get, path: "/contact-properties", query: Millionsend::Util.list_query(options)
22
+ ).perform
23
+ end
24
+
25
+ # PATCH /contact-properties/:id — fallback_value (nil clears it).
26
+ def update(id, params)
27
+ Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
28
+ end
29
+
30
+ # DELETE /contact-properties/:id
31
+ def remove(id)
32
+ Millionsend::Request.new(method: :delete, path: member_path(id)).perform
33
+ end
34
+
35
+ private
36
+
37
+ def member_path(id)
38
+ "/contact-properties/#{Millionsend::Util.encode(id)}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -5,7 +5,8 @@ module Millionsend
5
5
  # an update hash carries both).
6
6
  module Contacts
7
7
  class << self
8
- # POST /contacts
8
+ # POST /contacts — email, first_name, last_name, unsubscribed, properties,
9
+ # segments: [{ id: }], topics: [{ id:, subscription: }].
9
10
  def create(params)
10
11
  Millionsend::Request.new(method: :post, path: "/contacts", body: params).perform
11
12
  end
@@ -19,9 +20,8 @@ module Millionsend
19
20
  # everything else is the body. A nil value clears a field; omit a key to
20
21
  # leave it unchanged.
21
22
  def update(params)
22
- key = params[:email] || params[:id]
23
23
  body = params.reject { |k, _| [:id, :email].include?(k) }
24
- Millionsend::Request.new(method: :patch, path: member_path(key), body: body).perform
24
+ Millionsend::Request.new(method: :patch, path: member_path(params), body: body).perform
25
25
  end
26
26
 
27
27
  # DELETE a contact by id or email.
@@ -37,16 +37,71 @@ module Millionsend
37
37
  end
38
38
 
39
39
  # PATCH /contacts/:id_or_email/topics with a bare array of
40
- # { id:, subscription: }. Mirrors resend-ruby's contacts.topics.update.
40
+ # { id:, subscription: }. Contacts::Topics.update is the resend-ruby shape.
41
41
  def topics_update(id_or_email, topics)
42
42
  Millionsend::Request.new(method: :patch, path: "#{member_path(id_or_email)}/topics", body: topics).perform
43
43
  end
44
44
 
45
- private
46
-
45
+ # Every member method also accepts resend-ruby's addressing hash
46
+ # ({ id: } / { email: } / { contact_id: }) in place of the bare value.
47
47
  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)
48
49
  "/contacts/#{Millionsend::Util.encode(id_or_email)}"
49
50
  end
50
51
  end
52
+
53
+ # Topic subscriptions of one contact, in resend-ruby's nested shape.
54
+ module Topics
55
+ class << self
56
+ # PATCH /contacts/:id_or_email/topics — { id: | email:, topics: [{ id:, subscription: }] }.
57
+ def update(params)
58
+ Millionsend::Contacts.topics_update(params, params[:topics])
59
+ end
60
+ end
61
+ end
62
+
63
+ # Bulk contact creation — a MillionSend extension (Resend imports via CSV).
64
+ module Batch
65
+ class << self
66
+ # POST /contacts/batch with a bare array of up to 1000 create payloads.
67
+ # Options (flat or under options:): on_conflict ("error" default,
68
+ # "skip", "upsert") for emails that already belong to a contact,
69
+ # batch_validation ("strict" default, "permissive" writes the valid
70
+ # subset and lists failures under errors[]), idempotency_key. Returns
71
+ # { data: [{ index:, id:, status: }], counts: {...}, errors: [...] }.
72
+ def create(list, options = {})
73
+ options = options[:options] if options.is_a?(Hash) && options.key?(:options)
74
+ Millionsend::Request.new(
75
+ method: :post, path: "/contacts/batch", body: list,
76
+ query: { on_conflict: options[:on_conflict] },
77
+ **Millionsend::Util.request_options(options)
78
+ ).perform
79
+ end
80
+ end
81
+ end
82
+
83
+ # Segment membership of one contact. Segments are dynamic filters, so this
84
+ # only applies to segments that hold an explicit member list.
85
+ module Segments
86
+ class << self
87
+ # POST /contacts/:id_or_email/segments/:segment_id. Takes
88
+ # (id_or_email, segment_id) or resend-ruby's { contact_id: | email:, segment_id: }.
89
+ def add(id_or_email, segment_id = nil)
90
+ Millionsend::Request.new(method: :post, path: path(id_or_email, segment_id)).perform
91
+ end
92
+
93
+ # DELETE /contacts/:id_or_email/segments/:segment_id — same shapes as add.
94
+ def remove(id_or_email, segment_id = nil)
95
+ Millionsend::Request.new(method: :delete, path: path(id_or_email, segment_id)).perform
96
+ end
97
+
98
+ private
99
+
100
+ def path(id_or_email, segment_id)
101
+ segment_id ||= id_or_email[:segment_id] if id_or_email.is_a?(Hash)
102
+ "#{Millionsend::Contacts.member_path(id_or_email)}/segments/#{Millionsend::Util.encode(segment_id)}"
103
+ end
104
+ end
105
+ end
51
106
  end
52
107
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Sending domains and their DNS records.
5
+ module Domains
6
+ class << self
7
+ # POST /domains — name, region, custom_return_path, open_tracking,
8
+ # click_tracking, tracking_subdomain. Returns the records to publish.
9
+ def create(params)
10
+ Millionsend::Request.new(method: :post, path: "/domains", body: params).perform
11
+ end
12
+
13
+ # GET /domains/:id — includes records[] with per-record status.
14
+ def get(id)
15
+ Millionsend::Request.new(method: :get, path: member_path(id)).perform
16
+ end
17
+
18
+ # GET /domains — accepts limit:/after:/before:.
19
+ def list(options = {})
20
+ Millionsend::Request.new(method: :get, path: "/domains", query: Millionsend::Util.list_query(options)).perform
21
+ end
22
+
23
+ # PATCH /domains/:id — open_tracking, click_tracking, tracking_subdomain
24
+ # (nil or "" clears it).
25
+ def update(id, params)
26
+ Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
27
+ end
28
+
29
+ # POST /domains/:id/verify — re-check DNS now.
30
+ def verify(id)
31
+ Millionsend::Request.new(method: :post, path: "#{member_path(id)}/verify").perform
32
+ end
33
+
34
+ # DELETE /domains/:id
35
+ def remove(id)
36
+ Millionsend::Request.new(method: :delete, path: member_path(id)).perform
37
+ end
38
+
39
+ private
40
+
41
+ def member_path(id)
42
+ "/domains/#{Millionsend::Util.encode(id)}"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,23 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- # Transactional email: send one, look it up, cancel a scheduled one.
4
+ # Transactional email: send one, look it up, reschedule or cancel a scheduled
5
+ # one, list what was sent. Params hashes go on the wire as-is (from, to, cc,
6
+ # bcc, reply_to, subject, html, text, headers, tags, attachments,
7
+ # scheduled_at, topic_id, template), so nothing a caller passes is dropped.
5
8
  module Emails
6
9
  class << self
7
10
  # POST /emails. Accepts a params hash or bare keywords
8
- # (Emails.send(from: ..., to: ...)); a trailing options hash carries
9
- # idempotency_key. No keyword parameters are declared on purpose — Ruby 3
10
- # keyword separation would otherwise reject the bare-keyword call shape.
11
+ # (Emails.send(from: ..., to: ...)); a trailing hash carries the request
12
+ # options as either `idempotency_key: "k"` or resend-ruby's
13
+ # `options: { idempotency_key: "k" }`. No keyword parameters are declared
14
+ # on purpose — Ruby 3 keyword separation would otherwise reject the
15
+ # bare-keyword call shape.
11
16
  def send(params = {}, options = {})
12
17
  Millionsend::Request.new(
13
- method: :post, path: "/emails", body: params, idempotency_key: options[:idempotency_key]
18
+ method: :post, path: "/emails", body: params, **Millionsend::Util.request_options(options)
14
19
  ).perform
15
20
  end
16
21
  alias_method :create, :send
17
22
 
18
23
  # GET /emails/:id
19
24
  def get(id)
20
- Millionsend::Request.new(method: :get, path: "/emails/#{Millionsend::Util.encode(id)}").perform
25
+ Millionsend::Request.new(method: :get, path: member_path(id)).perform
26
+ end
27
+
28
+ # GET /emails — accepts limit:/after:/before:.
29
+ def list(options = {})
30
+ Millionsend::Request.new(method: :get, path: "/emails", query: Millionsend::Util.list_query(options)).perform
31
+ end
32
+
33
+ # PATCH /emails/:id — reschedule a scheduled, unsent email. Takes
34
+ # (id, { scheduled_at: }) or resend-ruby's single hash with :email_id.
35
+ def update(id, params = nil)
36
+ if id.is_a?(Hash)
37
+ params = id.reject { |k, _| k == :email_id }
38
+ id = id[:email_id]
39
+ end
40
+ Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
21
41
  end
22
42
 
23
43
  # GET /emails/:id/insights — the pre-send best-practice report computed
@@ -25,12 +45,23 @@ module Millionsend
25
45
  # insights yet. Check ids and band/severity/status values are an open
26
46
  # set that grows across score versions; they arrive as plain strings.
27
47
  def get_insights(id)
28
- Millionsend::Request.new(method: :get, path: "/emails/#{Millionsend::Util.encode(id)}/insights").perform
48
+ Millionsend::Request.new(method: :get, path: "#{member_path(id)}/insights").perform
29
49
  end
30
50
 
31
51
  # POST /emails/:id/cancel — scheduled, unsent emails only.
32
52
  def cancel(id)
33
- Millionsend::Request.new(method: :post, path: "/emails/#{Millionsend::Util.encode(id)}/cancel").perform
53
+ Millionsend::Request.new(method: :post, path: "#{member_path(id)}/cancel").perform
54
+ end
55
+
56
+ # DELETE /emails/:id
57
+ def remove(id)
58
+ Millionsend::Request.new(method: :delete, path: member_path(id)).perform
59
+ end
60
+
61
+ private
62
+
63
+ def member_path(id)
64
+ "/emails/#{Millionsend::Util.encode(id)}"
34
65
  end
35
66
  end
36
67
  end
@@ -32,17 +32,31 @@ module Millionsend
32
32
 
33
33
  class ValidationError < Error; end
34
34
  class NotFoundError < Error; end
35
+ class MissingApiKeyError < Error; end
36
+ class InvalidApiKeyError < Error; end
35
37
  class RestrictedApiKeyError < Error; end
36
38
  class SendingPausedError < Error; end
39
+ class BroadcastsPausedError < Error; end
40
+ class RateLimitExceededError < Error; end
41
+ class DailyQuotaExceededError < Error; end
37
42
  class InvalidIdempotentRequestError < Error; end
43
+ class ConcurrentIdempotentRequestsError < Error; end
44
+ class InternalServerError < Error; end
38
45
  class ApplicationError < Error; end
39
46
 
40
47
  ERROR_TYPES = {
41
48
  "validation_error" => ValidationError,
42
49
  "not_found" => NotFoundError,
50
+ "missing_api_key" => MissingApiKeyError,
51
+ "invalid_api_key" => InvalidApiKeyError,
43
52
  "restricted_api_key" => RestrictedApiKeyError,
44
53
  "sending_paused" => SendingPausedError,
54
+ "broadcasts_paused" => BroadcastsPausedError,
55
+ "rate_limit_exceeded" => RateLimitExceededError,
56
+ "daily_quota_exceeded" => DailyQuotaExceededError,
45
57
  "invalid_idempotent_request" => InvalidIdempotentRequestError,
58
+ "concurrent_idempotent_requests" => ConcurrentIdempotentRequestsError,
59
+ "internal_server_error" => InternalServerError,
46
60
  "application_error" => ApplicationError,
47
61
  }.freeze
48
62
  end
@@ -26,12 +26,13 @@ module Millionsend
26
26
  Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
27
27
  ].freeze
28
28
 
29
- def initialize(method:, path:, body: nil, query: nil, idempotency_key: nil)
29
+ def initialize(method:, path:, body: nil, query: nil, idempotency_key: nil, batch_validation: nil)
30
30
  @method = method
31
31
  @path = path
32
32
  @body = body
33
33
  @query = query
34
34
  @idempotency_key = idempotency_key
35
+ @batch_validation = batch_validation
35
36
  end
36
37
 
37
38
  def perform
@@ -88,6 +89,7 @@ module Millionsend
88
89
  end
89
90
  # Idempotency is POST-only on the wire; ignored on other verbs.
90
91
  request["Idempotency-Key"] = @idempotency_key if @idempotency_key && @method == :post
92
+ request["x-batch-validation"] = @batch_validation.to_s if @batch_validation
91
93
  request
92
94
  end
93
95
 
@@ -22,6 +22,15 @@ module Millionsend
22
22
  ).perform
23
23
  end
24
24
 
25
+ # GET /segments/:id/contacts — the contacts currently matching, paginated
26
+ # with limit:/after:/before:.
27
+ def contacts(id, options = {})
28
+ Millionsend::Request.new(
29
+ method: :get, path: "/segments/#{Millionsend::Util.encode(id)}/contacts",
30
+ query: Millionsend::Util.list_query(options)
31
+ ).perform
32
+ end
33
+
25
34
  # PATCH /segments/:id
26
35
  def update(id, params)
27
36
  Millionsend::Request.new(method: :patch, path: "/segments/#{Millionsend::Util.encode(id)}", body: params).perform
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Suppression list — addresses the API refuses to send to. Entries are
5
+ # addressable by id or by email.
6
+ module Suppressions
7
+ class << self
8
+ # POST /suppressions — email, origin ("manual" default, "bounce",
9
+ # "complaint", "unsubscribe"). Adding an already suppressed address
10
+ # returns the existing entry.
11
+ def add(params)
12
+ Millionsend::Request.new(method: :post, path: "/suppressions", body: params).perform
13
+ end
14
+ alias_method :create, :add
15
+
16
+ # GET /suppressions — accepts limit:/after:/before: and origin:.
17
+ def list(options = {})
18
+ query = Millionsend::Util.list_query(options).merge(origin: options[:origin])
19
+ Millionsend::Request.new(method: :get, path: "/suppressions", query: query).perform
20
+ end
21
+
22
+ # GET /suppressions/:id_or_email
23
+ def get(id_or_email)
24
+ Millionsend::Request.new(method: :get, path: member_path(id_or_email)).perform
25
+ end
26
+
27
+ # DELETE /suppressions/:id_or_email — allows sending to that address again.
28
+ def remove(id_or_email)
29
+ Millionsend::Request.new(method: :delete, path: member_path(id_or_email)).perform
30
+ end
31
+
32
+ private
33
+
34
+ def member_path(id_or_email)
35
+ "/suppressions/#{Millionsend::Util.encode(id_or_email)}"
36
+ end
37
+ end
38
+
39
+ # Up to 1000 suppressions per call.
40
+ module Batch
41
+ class << self
42
+ # POST /suppressions/batch/add — { emails: [...], origin: }.
43
+ def add(params)
44
+ Millionsend::Request.new(method: :post, path: "/suppressions/batch/add", body: params).perform
45
+ end
46
+
47
+ # POST /suppressions/batch/remove — { emails: [...] } or { ids: [...] }.
48
+ def remove(params)
49
+ Millionsend::Request.new(method: :post, path: "/suppressions/batch/remove", body: params).perform
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Email templates, addressable by id or alias. Templates are always
5
+ # published: publish is kept as a no-op for resend-ruby compatibility.
6
+ module Templates
7
+ class << self
8
+ # POST /templates — name, html, subject, text, alias.
9
+ def create(params)
10
+ Millionsend::Request.new(method: :post, path: "/templates", body: params).perform
11
+ end
12
+
13
+ # GET /templates/:id_or_alias
14
+ def get(id_or_alias)
15
+ Millionsend::Request.new(method: :get, path: member_path(id_or_alias)).perform
16
+ end
17
+
18
+ # GET /templates — accepts limit:/after:/before:.
19
+ def list(options = {})
20
+ Millionsend::Request.new(method: :get, path: "/templates", query: Millionsend::Util.list_query(options)).perform
21
+ end
22
+
23
+ # PATCH /templates/:id_or_alias — name, html, subject, text, alias; nil
24
+ # clears subject/text/alias.
25
+ def update(id_or_alias, params)
26
+ Millionsend::Request.new(method: :patch, path: member_path(id_or_alias), body: params).perform
27
+ end
28
+
29
+ # POST /templates/:id_or_alias/publish
30
+ def publish(id_or_alias)
31
+ Millionsend::Request.new(method: :post, path: "#{member_path(id_or_alias)}/publish").perform
32
+ end
33
+
34
+ # POST /templates/:id_or_alias/duplicate — returns the copy's id.
35
+ def duplicate(id_or_alias)
36
+ Millionsend::Request.new(method: :post, path: "#{member_path(id_or_alias)}/duplicate").perform
37
+ end
38
+
39
+ # DELETE /templates/:id_or_alias
40
+ def remove(id_or_alias)
41
+ Millionsend::Request.new(method: :delete, path: member_path(id_or_alias)).perform
42
+ end
43
+
44
+ private
45
+
46
+ def member_path(id_or_alias)
47
+ "/templates/#{Millionsend::Util.encode(id_or_alias)}"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -4,7 +4,7 @@ module Millionsend
4
4
  # Subscription topics — granular unsubscribe categories.
5
5
  module Topics
6
6
  class << self
7
- # POST /topics
7
+ # POST /topics — name, default_subscription, description, visibility.
8
8
  def create(params)
9
9
  Millionsend::Request.new(method: :post, path: "/topics", body: params).perform
10
10
  end
@@ -19,6 +19,11 @@ module Millionsend
19
19
  Millionsend::Request.new(method: :get, path: "/topics").perform
20
20
  end
21
21
 
22
+ # PATCH /topics/:id — name, description, visibility.
23
+ def update(id, params)
24
+ Millionsend::Request.new(method: :patch, path: "/topics/#{Millionsend::Util.encode(id)}", body: params).perform
25
+ end
26
+
22
27
  # DELETE /topics/:id
23
28
  def remove(id)
24
29
  Millionsend::Request.new(method: :delete, path: "/topics/#{Millionsend::Util.encode(id)}").perform
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Plan limits and today's send count — a MillionSend extension.
5
+ module Usage
6
+ class << self
7
+ # GET /usage
8
+ def get
9
+ Millionsend::Request.new(method: :get, path: "/usage").perform
10
+ end
11
+ end
12
+ end
13
+ end
@@ -32,5 +32,15 @@ module Millionsend
32
32
  options ||= {}
33
33
  { limit: options[:limit], after: options[:after], before: options[:before] }
34
34
  end
35
+
36
+ # Per-request options for the Request constructor. Both call shapes land in
37
+ # the same trailing positional hash: the original `idempotency_key: "k"` and
38
+ # resend-ruby's keyword form `options: { idempotency_key: "k" }`, so the
39
+ # latter is unwrapped here.
40
+ def request_options(options)
41
+ options = options[:options] if options.is_a?(Hash) && options.key?(:options)
42
+ options ||= {}
43
+ { idempotency_key: options[:idempotency_key], batch_validation: options[:batch_validation] }
44
+ end
35
45
  end
36
46
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Webhook endpoints. The signing secret is returned on create and on get.
5
+ module Webhooks
6
+ class << self
7
+ # POST /webhooks — endpoint, events[], signing_secret (optional; pass an
8
+ # existing whsec_ value to keep a receiver verifying unchanged).
9
+ def create(params)
10
+ Millionsend::Request.new(method: :post, path: "/webhooks", body: params).perform
11
+ end
12
+
13
+ # GET /webhooks/:id — includes signing_secret.
14
+ def get(id)
15
+ Millionsend::Request.new(method: :get, path: member_path(id)).perform
16
+ end
17
+
18
+ # GET /webhooks — accepts limit:/after:/before:.
19
+ def list(options = {})
20
+ Millionsend::Request.new(method: :get, path: "/webhooks", query: Millionsend::Util.list_query(options)).perform
21
+ end
22
+
23
+ # PATCH /webhooks/:id — endpoint, events, status ("enabled"/"disabled").
24
+ def update(id, params)
25
+ Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
26
+ end
27
+
28
+ # DELETE /webhooks/:id
29
+ def remove(id)
30
+ Millionsend::Request.new(method: :delete, path: member_path(id)).perform
31
+ end
32
+
33
+ private
34
+
35
+ def member_path(id)
36
+ "/webhooks/#{Millionsend::Util.encode(id)}"
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/millionsend.rb CHANGED
@@ -7,9 +7,16 @@ require "millionsend/request"
7
7
  require "millionsend/emails"
8
8
  require "millionsend/batch"
9
9
  require "millionsend/contacts"
10
+ require "millionsend/contact_properties"
10
11
  require "millionsend/topics"
11
12
  require "millionsend/broadcasts"
12
13
  require "millionsend/segments"
14
+ require "millionsend/suppressions"
15
+ require "millionsend/domains"
16
+ require "millionsend/webhooks"
17
+ require "millionsend/api_keys"
18
+ require "millionsend/templates"
19
+ require "millionsend/usage"
13
20
  require "millionsend/deliverability"
14
21
 
15
22
  # Ruby client for the MillionSend HTTP API. Configure once, then call the
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.3.0
4
+ version: 0.4.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-08-31 00:00:00.000000000 Z
11
+ date: 2026-09-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -52,9 +52,10 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.19'
55
- description: 'Ruby client for the MillionSend HTTP API: emails, batch, contacts, topics,
56
- broadcasts, and dynamic segments. Wire-compatible with Resend and mirror-shaped
57
- after resend-ruby, so migrating is mostly an import swap plus a base_url.'
55
+ description: 'Ruby client for the MillionSend HTTP API: emails, batch, contacts, contact
56
+ properties, topics, broadcasts, segments, suppressions, domains, webhooks, API keys,
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
59
  email:
59
60
  executables: []
60
61
  extensions: []
@@ -63,17 +64,24 @@ files:
63
64
  - LICENSE
64
65
  - README.md
65
66
  - lib/millionsend.rb
67
+ - lib/millionsend/api_keys.rb
66
68
  - lib/millionsend/batch.rb
67
69
  - lib/millionsend/broadcasts.rb
70
+ - lib/millionsend/contact_properties.rb
68
71
  - lib/millionsend/contacts.rb
69
72
  - lib/millionsend/deliverability.rb
73
+ - lib/millionsend/domains.rb
70
74
  - lib/millionsend/emails.rb
71
75
  - lib/millionsend/error.rb
72
76
  - lib/millionsend/request.rb
73
77
  - lib/millionsend/segments.rb
78
+ - lib/millionsend/suppressions.rb
79
+ - lib/millionsend/templates.rb
74
80
  - lib/millionsend/topics.rb
81
+ - lib/millionsend/usage.rb
75
82
  - lib/millionsend/util.rb
76
83
  - lib/millionsend/version.rb
84
+ - lib/millionsend/webhooks.rb
77
85
  homepage: https://github.com/MillionSend/millionsend-ruby
78
86
  licenses:
79
87
  - MIT