millionsend 0.2.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: e15bda7d83e77437273c93cad192bc1da266e48ffb560b29037c898e26cc825d
4
- data.tar.gz: be0dbc70f2210ff128541664405e74278f03825a2ea3e5eaa7ef887ce8c73a3b
3
+ metadata.gz: f44b4a952ce5037c565e1ba8071a29ddf7d2b7d875fd99cad7719e91155d3054
4
+ data.tar.gz: a6b58d948cd026fe50ff598cc46129d6e9c164de71453dcdd77855237c0376fd
5
5
  SHA512:
6
- metadata.gz: 2a851400dfdff77ecd956e54df92b6ce0bf8b201c471b5354734e43a27dae34d66dff76602d61ac347758da0ee027f3cd9d331653752097dd13d637f10a38a17
7
- data.tar.gz: aded65d56245645bef51bf1aa9954caf9f212c7c73e88946d3e6bc39ec59849ef3c05fd522b5dc259548c2408b01c834b684cd3609874ff23df4c960837d7e6c
6
+ metadata.gz: 4c16c5f8ee728136b872cc3f6a83d78052c62649c7b69497973d72845fb318669073078eb54406863fb0d83cd5336868896e098da794677b50667eefb4f0ec7f
7
+ data.tar.gz: d492c8d93ddcfff8e5f887f50aa7200fa0e30856effd041530a19a62cd2c2ad07ed47520c9f5fb57a32e4078103d6edad6619cb45cbba888cb94e0f01ff0faf8
data/README.md CHANGED
@@ -48,54 +48,124 @@ on any non-2xx response (see [Error handling](#error-handling)).
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
50
  # then http://localhost:3001
51
+ Millionsend.allow_insecure_http = false # accept a non-loopback http:// base_url
51
52
  ```
52
53
 
53
54
  MillionSend is self-hosted, so there is no cloud default — **set `base_url` to your
54
55
  deployment in production.** An explicitly assigned value always wins over the environment.
55
- Params are symbol-keyed hashes and map straight to the wire (Ruby's snake_case is already
56
- the wire's snake_case: `reply_to`, `scheduled_at`, `segment_id`).
56
+ Plain `http://` is only accepted for loopback hosts (`localhost`, `127.0.0.1`, `::1`); any
57
+ other `http://` URL raises `Millionsend::ApplicationError` on the first call, since the API
58
+ key is sent as a bearer header. Set `allow_insecure_http = true` to talk to a non-TLS
59
+ instance elsewhere (e.g. inside a private network).
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: }]`).
57
86
 
58
87
  ## Resources
59
88
 
60
89
  ### Emails
61
90
 
62
91
  ```ruby
63
- Millionsend::Emails.send(payload, idempotency_key: "order-42") # POST /emails
64
- Millionsend::Emails.get(id) # GET /emails/:id
65
- Millionsend::Emails.cancel(id) # POST /emails/:id/cancel (scheduled only)
66
-
67
- 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
68
101
  ```
69
102
 
70
- `to`, `cc`, `bcc` and `reply_to` accept either a string or an array. `Emails.create` is
71
- 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: ...)`.
72
110
 
73
111
  ### Contacts
74
112
 
75
113
  Contacts are team-global — one list per team, no audiences.
76
114
 
77
115
  ```ruby
78
- contact = Millionsend::Contacts.create(email: "ada@acme.dev", first_name: "Ada",
79
- properties: { plan: "pro" })
80
- 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: ...)
81
123
  Millionsend::Contacts.update(id: contact[:id], unsubscribed: true, first_name: nil) # nil clears
82
124
  Millionsend::Contacts.remove("ada@acme.dev")
83
125
  Millionsend::Contacts.list(limit: 50)
84
126
 
85
- # Topic subscriptions (granular unsubscribe) — mirrors resend's contacts.topics.update
86
- 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: }]
87
144
  ```
88
145
 
89
146
  Contacts are addressable by id or email; when an `update` hash carries both, the email wins.
90
147
  Emails are unique per team (case-insensitive) — a duplicate `create` raises
91
148
  `Millionsend::ValidationError`.
92
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
+
93
162
  ### Topics
94
163
 
95
164
  ```ruby
96
- Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in")
165
+ Millionsend::Topics.create(name: "Product updates", default_subscription: "opt_in", visibility: "public")
97
166
  Millionsend::Topics.get(id)
98
167
  Millionsend::Topics.list # bare { data: [...] } — topics are unpaginated
168
+ Millionsend::Topics.update(id, name: "Product news")
99
169
  Millionsend::Topics.remove(id)
100
170
  ```
101
171
 
@@ -103,19 +173,104 @@ Millionsend::Topics.remove(id)
103
173
 
104
174
  ```ruby
105
175
  broadcast = Millionsend::Broadcasts.create(
176
+ name: "Launch", # internal name shown in the dashboard
106
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
107
179
  from: "Acme <news@acme.dev>",
108
180
  subject: "Launch",
109
- 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)
110
187
  )
111
188
  Millionsend::Broadcasts.list
112
189
  Millionsend::Broadcasts.get(broadcast[:id])
113
- Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀") # draft only
190
+ Millionsend::Broadcasts.update(broadcast[:id], subject: "Launch 🚀", topic_id: nil) # draft only; nil clears
114
191
  Millionsend::Broadcasts.send(broadcast[:id], scheduled_at: "2026-09-01T09:00:00Z") # omit to send now
115
192
  Millionsend::Broadcasts.cancel(broadcast[:id]) # scheduled only
116
193
  Millionsend::Broadcasts.remove(broadcast[:id]) # draft only
117
194
  ```
118
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
+
119
274
  ### Segments (MillionSend extension)
120
275
 
121
276
  Dynamic segments are a saved filter over the team's contacts — a MillionSend superset with
@@ -128,10 +283,33 @@ segment = Millionsend::Segments.create(
128
283
  )
129
284
  Millionsend::Segments.get(segment[:id]) # includes a live contact_count
130
285
  Millionsend::Segments.list
286
+ Millionsend::Segments.contacts(segment[:id], limit: 50) # the contacts currently matching
131
287
  Millionsend::Segments.update(segment[:id], name: "Pro tier")
132
288
  Millionsend::Segments.remove(segment[:id])
133
289
  ```
134
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
+
300
+ ### Deliverability (MillionSend extension)
301
+
302
+ Per-email best-practice insights and the account-level deliverability score.
303
+
304
+ ```ruby
305
+ insights = Millionsend::Emails.get_insights(email[:id]) # score, band, checks: [{id:, severity:, status:, penalty:, detail:}]
306
+ account = Millionsend::Deliverability.get # GET /deliverability — trailing-30-day account score
307
+ puts account[:score] # 0-10 (one decimal) or nil when there is not enough data
308
+ ```
309
+
310
+ Check ids and the band/severity/status values are an open set that grows across score
311
+ versions — treat them as strings, not a closed enum.
312
+
135
313
  ## Error handling
136
314
 
137
315
  No `{ data, error }` tuple — a non-2xx response raises. The base class is `Millionsend::Error`,
@@ -148,9 +326,11 @@ rescue Millionsend::Error => e
148
326
  end
149
327
  ```
150
328
 
151
- Subclasses: `ValidationError`, `NotFoundError`, `RestrictedApiKeyError`, `SendingPausedError`,
152
- `InvalidIdempotentRequestError`, and `ApplicationError` (the fallback). Client-side and
153
- 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`.
154
334
 
155
335
  ## Migrating from Resend
156
336
 
@@ -164,15 +344,25 @@ transport failures that never reached the API raise with `#status_code == nil`.
164
344
  + Millionsend::Emails.send(from: "...", to: "...", subject: "Hi", html: "<p>hi</p>")
165
345
  ```
166
346
 
167
- Method names, nesting and payloads match. Notes:
168
-
169
- - **Domains and API keys** are managed in the MillionSend dashboard, not via the API, so there
170
- are no `Domains` / `ApiKeys` resources here.
171
- - Resend's `Contacts.topics.update` becomes `Millionsend::Contacts.topics_update` (Ruby has no
172
- 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.
173
358
  - **No audiences** — contacts are team-global, so there is no `Audiences` resource and no
174
- `audience_id` params. `Millionsend::Segments` is the dynamic-filter feature (`/segments`),
175
- 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"`.
176
366
 
177
367
  ## License
178
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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Millionsend
4
+ # Account-level deliverability score over the trailing window
5
+ # (scores are 0-10, one decimal; null means not enough data).
6
+ module Deliverability
7
+ class << self
8
+ # GET /deliverability
9
+ def get
10
+ Millionsend::Request.new(method: :get, path: "/deliverability").perform
11
+ end
12
+ end
13
+ end
14
+ 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,28 +1,67 @@
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
41
+ end
42
+
43
+ # GET /emails/:id/insights — the pre-send best-practice report computed
44
+ # when the email was sent. 404 when the email is unknown or has no
45
+ # insights yet. Check ids and band/severity/status values are an open
46
+ # set that grows across score versions; they arrive as plain strings.
47
+ def get_insights(id)
48
+ Millionsend::Request.new(method: :get, path: "#{member_path(id)}/insights").perform
21
49
  end
22
50
 
23
51
  # POST /emails/:id/cancel — scheduled, unsent emails only.
24
52
  def cancel(id)
25
- 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)}"
26
65
  end
27
66
  end
28
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
@@ -65,6 +66,13 @@ module Millionsend
65
66
  def build_uri
66
67
  base = Millionsend.base_url.to_s.sub(%r{/+\z}, "")
67
68
  uri = URI.parse("#{base}#{@path}")
69
+ if !Millionsend.allow_insecure_http && Millionsend::Util.insecure_http?(uri)
70
+ raise Millionsend::ApplicationError.new(
71
+ "Refusing to send the API key over plain http to #{base}. " \
72
+ "Use https, or set Millionsend.allow_insecure_http = true.",
73
+ nil, "application_error"
74
+ )
75
+ end
68
76
  query = (@query || {}).reject { |_, v| v.nil? }
69
77
  uri.query = URI.encode_www_form(query) unless query.empty?
70
78
  uri
@@ -81,6 +89,7 @@ module Millionsend
81
89
  end
82
90
  # Idempotency is POST-only on the wire; ignored on other verbs.
83
91
  request["Idempotency-Key"] = @idempotency_key if @idempotency_key && @method == :post
92
+ request["x-batch-validation"] = @batch_validation.to_s if @batch_validation
84
93
  request
85
94
  end
86
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
@@ -16,11 +16,31 @@ module Millionsend
16
16
  URI.encode_www_form_component(value.to_s).gsub("+", "%20")
17
17
  end
18
18
 
19
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
20
+
21
+ # True for an http:// URI whose host is not loopback.
22
+ def insecure_http?(uri)
23
+ return false unless uri.scheme == "http"
24
+
25
+ host = uri.host.to_s.downcase
26
+ !LOOPBACK_HOSTS.include?(host) && !host.start_with?("127.")
27
+ end
28
+
19
29
  # The keyset pagination params every list endpoint accepts. nil values are
20
30
  # dropped when the query string is built.
21
31
  def list_query(options)
22
32
  options ||= {}
23
33
  { limit: options[:limit], after: options[:after], before: options[:before] }
24
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
25
45
  end
26
46
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Millionsend
4
- VERSION = "0.2.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,17 @@ 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"
20
+ require "millionsend/deliverability"
13
21
 
14
22
  # Ruby client for the MillionSend HTTP API. Configure once, then call the
15
23
  # resource modules:
@@ -21,14 +29,17 @@ require "millionsend/segments"
21
29
  #
22
30
  # api_key falls back to the MILLIONSEND_API_KEY env var; base_url to
23
31
  # MILLIONSEND_BASE_URL and then http://localhost:3001 (MillionSend is
24
- # self-hosted, so there is no cloud default). Every call returns a symbol-keyed
25
- # Hash on success and raises a Millionsend::Error on any non-2xx response.
32
+ # self-hosted, so there is no cloud default). Plain http is only accepted for
33
+ # loopback hosts unless allow_insecure_http is set, since the API key travels
34
+ # as a bearer header. Every call returns a symbol-keyed Hash on success and
35
+ # raises a Millionsend::Error on any non-2xx response.
26
36
  module Millionsend
27
37
  DEFAULT_BASE_URL = "http://localhost:3001"
28
38
  USER_AGENT = "millionsend-ruby/#{VERSION}"
29
39
 
30
40
  class << self
31
41
  attr_writer :api_key, :base_url
42
+ attr_accessor :allow_insecure_http
32
43
 
33
44
  def api_key
34
45
  @api_key || ENV["MILLIONSEND_API_KEY"]
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.2.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-17 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,16 +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
72
+ - lib/millionsend/deliverability.rb
73
+ - lib/millionsend/domains.rb
69
74
  - lib/millionsend/emails.rb
70
75
  - lib/millionsend/error.rb
71
76
  - lib/millionsend/request.rb
72
77
  - lib/millionsend/segments.rb
78
+ - lib/millionsend/suppressions.rb
79
+ - lib/millionsend/templates.rb
73
80
  - lib/millionsend/topics.rb
81
+ - lib/millionsend/usage.rb
74
82
  - lib/millionsend/util.rb
75
83
  - lib/millionsend/version.rb
84
+ - lib/millionsend/webhooks.rb
76
85
  homepage: https://github.com/MillionSend/millionsend-ruby
77
86
  licenses:
78
87
  - MIT