mailblastr 1.2.0 → 2.0.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: b33c9cbe102716978dacde6713e4cc66e25164cb91b26a0272c1f44dbcd5fab9
4
- data.tar.gz: cb3b4fa5e7ba5bc85266c6cb0108e6a4a555daaf749963284d6045709ff05536
3
+ metadata.gz: da4eac6f929de305deb6c395655cb2658cc1ac3ee7410355846092ff6b61f66a
4
+ data.tar.gz: 05bfee5848400946fef957671b8bf891916bda6b7847988c531f2d5995ecb58a
5
5
  SHA512:
6
- metadata.gz: 480c2cff7e78425b91e649421aa0ca5f1e883188f29a4fe63b68618f68ed11cee752b7e251a30ed7f720377596af3f40cf68e1b5bf23042ed27999fc3a86e977
7
- data.tar.gz: 337481b2dbd5dd2b4b788706d302ae028aa823e992804bd7681c42a696c087b479c79370e14a803493e0564a121f68c87560a60ed70e7238124599b839778b45
6
+ metadata.gz: 1d3cd0f0741e9e55d2b6f24f34f33add0b192d305f5834fb4d481dbd70a0aacbdd8d05d516ba8797aa3201360d491a2a22bd6929a7ffdbd7b9ae57ae45ed4bbb
7
+ data.tar.gz: 907b8ec4abe3920ba6e94c04587cfcb72f4692420f461504f391dd6c44bc75cda2e562df73fe05911c1b40fa1e79ea8b581c948c0f64dc1f4fabd86bec489c8b
data/README.md CHANGED
@@ -54,6 +54,36 @@ rescue Mailblastr::Error => e
54
54
  end
55
55
  ```
56
56
 
57
+ Branch on `e.name`, never on `e.message` — messages are sanitized server-side and may change. The same `name` can arrive with different HTTP statuses depending on the endpoint, so read `e.status_code` rather than assuming one. Common names: `missing_api_key` (401), `restricted_api_key` (401, the key lacks the scope), `invalid_api_key` (403), `validation_error` (422), `not_found` (404), `plan_limit_reached` (402), `daily_quota_exceeded` / `monthly_quota_exceeded` / `rate_limit_exceeded` (429).
58
+
59
+ Some errors carry more than that envelope. The extras are readers on the error and are `nil` on an ordinary one:
60
+
61
+ ```ruby
62
+ rescue Mailblastr::Error => e
63
+ # WHICH quota ran out, and what would clear it.
64
+ if (cap = e.limit)
65
+ cap["kind"] # => "emails_daily"
66
+ cap["used"], cap["limit"] # => 100, 100
67
+ cap["period"] # => "24h"
68
+ cap.dig("next_plan", "name") # => "Pro"
69
+ end
70
+
71
+ # Reputation gates: whether waiting helps, and until when.
72
+ if (rep = e.reputation)
73
+ rep["retryable"], rep["scope"], rep["retry_at"]
74
+ end
75
+
76
+ # A batch that failed part way through — do NOT resend these.
77
+ if (sent = e.sent)
78
+ puts "#{e.sent_count} already went out: #{sent.map { |s| s['id'] }.join(', ')}"
79
+ end
80
+ end
81
+ ```
82
+
83
+ `e.body` is the whole parsed error body, so a field newer than this SDK version is still reachable.
84
+
85
+ Every request carries a `User-Agent` automatically — the API rejects requests without one with a 403 `validation_error`.
86
+
57
87
  ## Domain-first model
58
88
 
59
89
  MailBlastr is **domain-first**: each sending domain has its own pool of contacts. The same email address on two domains is two records with separate consent, so unsubscribes on one product never leak into another.
@@ -71,6 +101,8 @@ That means `domain` (the sending domain, e.g. `"yourdomain.com"` — one of your
71
101
  ```ruby
72
102
  Mailblastr::Emails.send({ from: from, to: to, subject: subject, html: html })
73
103
  Mailblastr::Emails.list({ limit: 20, after: cursor }) # cursor pagination
104
+ Mailblastr::Emails.list({ status: "bounced", search: "acme.com" }) # filters
105
+ Mailblastr::Emails.sources # per-campaign/automation send metrics
74
106
  Mailblastr::Emails.get(email_id)
75
107
  Mailblastr::Emails.list_attachments(email_id)
76
108
  Mailblastr::Emails.get_attachment(email_id, attachment_id)
@@ -97,6 +129,7 @@ Mailblastr::Emails.send({
97
129
 
98
130
  ```ruby
99
131
  Mailblastr::Emails::Receiving.list
132
+ Mailblastr::Emails::Receiving.addresses # per-address inbound stats
100
133
  Mailblastr::Emails::Receiving.get(id)
101
134
  Mailblastr::Emails::Receiving.list_attachments(id)
102
135
  Mailblastr::Emails::Receiving.get_attachment(id, attachment_id) # => raw bytes (String)
@@ -114,6 +147,8 @@ Mailblastr::Domains.get(id)
114
147
  Mailblastr::Domains.list
115
148
  Mailblastr::Domains.update(id, { click_tracking: true })
116
149
  Mailblastr::Domains.verify(id)
150
+ Mailblastr::Domains.mx_check("yourdomain.com") # inspect live MX before enabling receiving
151
+ Mailblastr::Domains.records_csv(id) # => CSV text (String)
117
152
  Mailblastr::Domains.delete(id)
118
153
 
119
154
  # Claim a domain verified in another account
@@ -146,6 +181,11 @@ Mailblastr::Contacts.list({ audience_id: aud_id, segment_id: seg_id })
146
181
  Mailblastr::Contacts.batch({ audience_id: aud_id, contacts: [{ email: "a@b.com" }], on_conflict: "skip" })
147
182
  Mailblastr::Contacts.import({ audience_id: aud_id, csv: "email,company\na@b.com,Acme" })
148
183
 
184
+ # CSV too big to inline (5 MB / 10,000 rows)? Upload it directly, then import by key.
185
+ slot = Mailblastr::Contacts.import_upload({ audience_id: aud_id, filename: "list.csv", size: bytes })
186
+ # PUT the file to slot["upload_url"], then:
187
+ Mailblastr::Contacts.import({ audience_id: aud_id, storage_key: slot["storage_key"] })
188
+
149
189
  # Segments & topics per contact
150
190
  Mailblastr::Contacts.add_to_segment(contact_id, segment_id)
151
191
  Mailblastr::Contacts.remove_from_segment(contact_id, segment_id)
@@ -201,6 +241,7 @@ Mailblastr::Campaigns.send(campaign["id"]) #
201
241
  Mailblastr::Campaigns.send(campaign["id"], { scheduled_at: "2026-08-01T09:00:00Z" }) # or schedule
202
242
  Mailblastr::Campaigns.cancel(campaign["id"])
203
243
  Mailblastr::Campaigns.stats(campaign["id"])
244
+ Mailblastr::Campaigns.engagement(campaign["id"]) # who opened / clicked / replied
204
245
  Mailblastr::Campaigns.ab(campaign["id"]) # A/B winner evaluation
205
246
  Mailblastr::Campaigns.get(campaign["id"])
206
247
  Mailblastr::Campaigns.list({ limit: 25 })
@@ -233,8 +274,12 @@ automation = Mailblastr::Automations.create({
233
274
  })
234
275
 
235
276
  Mailblastr::Automations.add_step(automation["id"], { type: "send_email", config: { template_id: tmpl_id } })
277
+ Mailblastr::Automations.update_step(automation["id"], step_id, { config: { subject: "New subject" } })
236
278
  Mailblastr::Automations.update(automation["id"], { status: "enabled" })
237
279
 
280
+ # Or describe the flow and let the server build the steps (automation must be stopped)
281
+ Mailblastr::Automations.create_with_ai(automation["id"], { prompt: "Wait 2 days, then send the onboarding email" })
282
+
238
283
  # Fire a custom event — only yourdomain.com's automations are triggered
239
284
  Mailblastr::Events.send({
240
285
  event: "signup.completed",
@@ -243,13 +288,15 @@ Mailblastr::Events.send({
243
288
  payload: { plan: "pro" }
244
289
  })
245
290
 
246
- # Event definitions
291
+ # Event definitions — schema types are "string", "number", "boolean" or "date".
292
+ # Event names cannot start with the reserved "mailblastr:" prefix.
247
293
  Mailblastr::Events.create({ name: "signup.completed", schema: { plan: "string" } })
248
294
  Mailblastr::Events.list
295
+ Mailblastr::Events.update(event_id, { schema: { plan: "string", seats: "number" } }) # name is immutable
249
296
  Mailblastr::Events.delete(event_id)
250
297
 
251
298
  # Inspect execution
252
- runs = Mailblastr::Automations.runs(automation["id"], { limit: 25 })
299
+ runs = Mailblastr::Automations.runs(automation["id"], { limit: 25, status: ["failed"] })
253
300
  Mailblastr::Automations.get_run(automation["id"], runs["data"].first["id"])
254
301
  Mailblastr::Automations.delete_step(automation["id"], step_id)
255
302
  Mailblastr::Automations.stop(automation["id"])
@@ -261,7 +308,7 @@ Mailblastr::Automations.delete(automation["id"])
261
308
  ```ruby
262
309
  hook = Mailblastr::Webhooks.create({
263
310
  endpoint: "https://yourapp.com/hooks/mailblastr",
264
- events: ["email.delivered", "email.bounced", "contact.unsubscribed"]
311
+ events: ["email.delivered", "email.bounced", "email.unsubscribed"]
265
312
  })
266
313
  hook["signing_secret"] # shown ONCE — store it
267
314
 
@@ -272,6 +319,15 @@ Mailblastr::Webhooks.test(hook["id"])
272
319
  Mailblastr::Webhooks.delete(hook["id"])
273
320
  ```
274
321
 
322
+ Endpoints must be `https://` and must not resolve to a private address. Valid event names are `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.failed`, `email.scheduled`, `email.suppressed`, `email.received`, `email.replied`, `email.unsubscribed`, `contact.created`, `contact.updated`, `contact.deleted`, `domain.created`, `domain.updated` and `domain.deleted`. Anything else is a 422.
323
+
324
+ `Webhooks.test` returns HTTP 200 even when the delivery failed — it does not raise. The outcome is `result["ok"]`, with `result["status"]` (your endpoint's HTTP status, when it responded) and `result["error"]` (e.g. `"lookup_failed"`):
325
+
326
+ ```ruby
327
+ result = Mailblastr::Webhooks.test(hook["id"])
328
+ warn "test delivery failed: #{result['error']}" unless result["ok"]
329
+ ```
330
+
275
331
  ### Verifying deliveries
276
332
 
277
333
  `verify_signature` checks the Svix-style HMAC-SHA256 signature locally (no HTTP request). Pass the **exact raw request body** — re-serializing parsed JSON breaks the signature.
@@ -296,9 +352,7 @@ Pass `tolerance: 0` to skip the timestamp freshness check (default 300 seconds).
296
352
  ## API keys, Logs & Polls
297
353
 
298
354
  ```ruby
299
- Mailblastr::ApiKeys.create({ name: "CI", permission: "sending_access" })
300
- Mailblastr::ApiKeys.list
301
- Mailblastr::ApiKeys.delete(key_id)
355
+ Mailblastr::ApiKeys.list # `token` is the 8-character display prefix, never the secret
302
356
 
303
357
  Mailblastr::Logs.list({ limit: 100, method: "POST", status: 429 })
304
358
  Mailblastr::Logs.get(log_id)
@@ -307,22 +361,49 @@ Mailblastr::Polls.list
307
361
  Mailblastr::Polls.get(email_id) # aggregated answer breakdown
308
362
  ```
309
363
 
364
+ `Mailblastr::ApiKeys.list` is the whole API-key surface: the SDK deliberately
365
+ exposes no method to create, re-scope or revoke a key. Key lifecycle belongs to
366
+ a signed-in dashboard session, and the API enforces it — `POST /api-keys`,
367
+ `PATCH /api-keys/:id` and `DELETE /api-keys/:id` answer `403 dashboard_only` to
368
+ any API-key caller, whatever its permission. That is the point: a key that leaks
369
+ cannot mint itself a replacement, widen its own access, or revoke the keys you
370
+ would use to shut it off. Create and revoke keys at
371
+ [mailblastr.com](https://www.mailblastr.com).
372
+
310
373
  ## Pagination
311
374
 
312
375
  `list` methods accept cursor pagination — `{ limit:, after:, before: }` — appended as a query string:
313
376
 
314
377
  ```ruby
315
- Mailblastr::Campaigns.list({ limit: 25, after: "cursor_abc" })
378
+ page = Mailblastr::Campaigns.list({ limit: 25, after: "cursor_abc" })
379
+ page["object"] # => "list"
380
+ page["has_more"] # => true when more rows exist beyond this page
381
+ page["data"] # => [...]
316
382
  ```
317
383
 
384
+ `limit` is an integer between 1 and 100 (default 20); `after` and `before` are item ids and cannot be combined. An unknown cursor returns an empty page, not an error. There is no `total` and no `next_cursor` — page forward with the last `data` entry's `id` as `after`.
385
+
386
+ Defaults differ per endpoint. `GET /templates`, `/webhooks`, `/audiences`, `/automations`, `/events` and `/automations/:id/runs` cap an unpaginated call at 20 rows, while `/domains`, `/api-keys`, `/topics`, `/campaigns`, `/contacts`, `/contact-properties`, `/segments` and `/polls` return the whole collection when you pass neither `limit` nor a cursor. Always pass `limit` if you depend on page size.
387
+
318
388
  ## Idempotency
319
389
 
320
- Pass an idempotency key to safely retry a create (24h window):
390
+ Pass an idempotency key to safely retry a send.
321
391
 
322
392
  ```ruby
323
393
  Mailblastr::Emails.send(payload, { idempotency_key: "order-123" })
394
+ Mailblastr::Batch.send(payloads, { idempotency_key: "orders-2026-08-08" })
324
395
  ```
325
396
 
397
+ The key must be **1–255 characters**, measured after the server trims it — 255, not 256. `Mailblastr::Client::IDEMPOTENCY_KEY_MAX_LENGTH` carries that number. The SDK sends the key verbatim and lets the **server** be the authority: an out-of-range key comes back as `400 invalid_idempotency_key` (a `Mailblastr::Error` with `name == "invalid_idempotency_key"`).
398
+
399
+ Reusing a key replays the original response; reusing it with a *different* body is a 409 (`invalid_idempotent_request`), and a second request while the first is still in flight is a 409 (`concurrent_idempotent_requests`).
400
+
401
+ Only `Emails.send` and `Batch.send` honour the header. Every other endpoint — including `Events.send` — accepts and forwards it but the API ignores it, so a retry there creates a second record. De-duplicate on your side instead.
402
+
403
+ ## Rate limits
404
+
405
+ Only the `/emails` routes are rate-limited: **30 requests per minute per IP**, covering reads as well as sends. Those responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers (on successes too) so you can throttle before being rejected. The SDK retries a 429 or 503 automatically — up to `Mailblastr.max_retries` times (default 2), honouring `Retry-After`.
406
+
326
407
  ## Documentation
327
408
 
328
409
  Full docs: <https://www.mailblastr.com/docs>
@@ -1,25 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailblastr
4
+ # Listing only, by design. Keys are created, re-scoped and revoked in the
5
+ # MailBlastr dashboard by a signed-in user — POST /api-keys,
6
+ # PATCH /api-keys/:id and DELETE /api-keys/:id answer 403 `dashboard_only`
7
+ # to every API-key caller, whatever its permission. Exposing only `list`
8
+ # means a leaked key cannot mint itself a replacement or widen its access.
4
9
  module ApiKeys
5
10
  class << self
6
- # Create an API key the full token is returned only once, here.
7
- # POST /api-keys params: { name:, permission: "full_access"|"sending_access", domain_id:, domain_ids: }
8
- # domain_ids: scopes the key to one or more domains (works with both
9
- # permissions); domain_id: is the legacy single-domain form. Providing
10
- # both is a 422.
11
- def create(params)
12
- Client.request(:post, "/api-keys", body: params)
13
- end
14
-
15
- # GET /api-keys
16
- def list
17
- Client.request(:get, "/api-keys")
18
- end
19
-
20
- # DELETE /api-keys/:id
21
- def delete(api_key_id)
22
- Client.request(:delete, "/api-keys/#{Client.path_escape(api_key_id)}")
11
+ # GET /api-keyswith no pagination params every non-revoked key is
12
+ # returned. `token` here is the 8-character display prefix, never the
13
+ # secret.
14
+ def list(params = {})
15
+ Client.request(:get, "/api-keys", query: Client.pagination(params))
23
16
  end
24
17
  end
25
18
  end
@@ -35,18 +35,45 @@ module Mailblastr
35
35
  end
36
36
 
37
37
  # Append a step. POST /automations/:id/steps — params: { type:, config:, key: }
38
+ # The automation must be disabled first, and `type: "trigger"` is
39
+ # rejected here (the trigger lives on the automation, not in `steps`).
38
40
  def add_step(automation_id, params)
39
41
  Client.request(:post, "/automations/#{Client.path_escape(automation_id)}/steps", body: params)
40
42
  end
41
43
 
44
+ # Edit a step in place (automation must be disabled).
45
+ # PATCH /automations/:id/steps/:step_id
46
+ def update_step(automation_id, step_id, params)
47
+ Client.request(
48
+ :patch,
49
+ "/automations/#{Client.path_escape(automation_id)}/steps/#{Client.path_escape(step_id)}",
50
+ body: params
51
+ )
52
+ end
53
+
42
54
  # Delete a step. DELETE /automations/:id/steps/:step_id
43
55
  def delete_step(automation_id, step_id)
44
56
  Client.request(:delete, "/automations/#{Client.path_escape(automation_id)}/steps/#{Client.path_escape(step_id)}")
45
57
  end
46
58
 
47
- # List an automation's runs. GET /automations/:id/runs
59
+ # Build (or extend) the automation's steps from a prompt.
60
+ # POST /automations/:id/ai — params: { prompt:, template_ids:, events:, attach: }
61
+ # `prompt` is required and capped at 2000 characters. Without `attach` the
62
+ # automation must have no steps yet; pass `attach` ({ from:, type:,
63
+ # before: }) to append to an existing graph. The automation must be
64
+ # stopped, and the route is limited to 20 requests per minute per account.
65
+ def create_with_ai(automation_id, params)
66
+ Client.request(:post, "/automations/#{Client.path_escape(automation_id)}/ai", body: params)
67
+ end
68
+
69
+ # List an automation's runs. `status` filters to specific run statuses
70
+ # ("running", "completed", "failed", "skipped") and accepts an Array or a
71
+ # comma-separated String. GET /automations/:id/runs
48
72
  def runs(automation_id, params = {})
49
- Client.request(:get, "/automations/#{Client.path_escape(automation_id)}/runs", query: Client.pagination(params))
73
+ query = Client.pagination(params)
74
+ status = Client.opt(params, :status)
75
+ query[:status] = status.is_a?(Array) ? status.join(",") : status unless status.nil?
76
+ Client.request(:get, "/automations/#{Client.path_escape(automation_id)}/runs", query: query)
50
77
  end
51
78
 
52
79
  # Retrieve a single run with its step trace. GET /automations/:id/runs/:run_id
@@ -52,6 +52,13 @@ module Mailblastr
52
52
  Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}/stats")
53
53
  end
54
54
 
55
+ # Who opened, clicked and replied, contact by contact. Each list is
56
+ # capped at 500 rows and there is no pagination.
57
+ # GET /campaigns/:id/engagement
58
+ def engagement(campaign_id)
59
+ Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}/engagement")
60
+ end
61
+
55
62
  # A/B winner evaluation for an A/B campaign. GET /campaigns/:id/ab
56
63
  def ab(campaign_id)
57
64
  Client.request(:get, "/campaigns/#{Client.path_escape(campaign_id)}/ab")
@@ -27,6 +27,16 @@ module Mailblastr
27
27
  # Upper bound (seconds) on any single backoff wait.
28
28
  MAX_BACKOFF_SECONDS = 30.0
29
29
 
30
+ # `Idempotency-Key` is stored in a VARCHAR(255) column, so the API accepts
31
+ # 1-255 characters measured after it trims the value — 255, not 256 — and
32
+ # answers anything else with 400 invalid_idempotency_key. Only
33
+ # POST /emails and POST /emails/batch read the header; every other endpoint
34
+ # ignores it, so a retry there creates a second resource.
35
+ #
36
+ # Exposed for discoverability only: the SDK sends the key as given and lets
37
+ # the server be the authority.
38
+ IDEMPOTENCY_KEY_MAX_LENGTH = 255
39
+
30
40
  # Perform an API request and return the parsed JSON body (a Hash/Array),
31
41
  # or the raw body String when `raw: true` (binary download endpoints).
32
42
  # Raises Mailblastr::Error on any non-2xx response.
@@ -114,7 +124,7 @@ module Mailblastr
114
124
  req["Authorization"] = "Bearer #{key}"
115
125
  req["User-Agent"] = "mailblastr-ruby/#{Mailblastr::VERSION}"
116
126
  req["Accept"] = "application/json"
117
- idem = opt(options, :idempotency_key)
127
+ idem = idempotency_key(opt(options, :idempotency_key))
118
128
  req["Idempotency-Key"] = idem if idem
119
129
  unless body.nil?
120
130
  req["Content-Type"] = "application/json"
@@ -157,10 +167,14 @@ module Mailblastr
157
167
  nil
158
168
  end
159
169
  parsed = {} unless parsed.is_a?(Hash)
170
+ # The whole body rides along: plan/quota errors add `limit`, reputation
171
+ # gates add `reputation`, and a partial batch failure adds
172
+ # `sent`/`sent_count` (see Mailblastr::Error).
160
173
  raise Mailblastr::Error.new(
161
174
  parsed["message"] || "Request failed with status #{code}",
162
175
  status_code: parsed["statusCode"] || code,
163
- error_name: parsed["name"] || "application_error"
176
+ error_name: parsed["name"] || "application_error",
177
+ body: parsed
164
178
  )
165
179
  end
166
180
  end
@@ -171,6 +185,20 @@ module Mailblastr
171
185
  CGI.escape(value.to_s).gsub("+", "%20")
172
186
  end
173
187
 
188
+ # Normalize an `idempotency_key` option into the header value. nil/absent or
189
+ # an empty string means "no header"; anything else is sent VERBATIM.
190
+ #
191
+ # The 1-255 bound (IDEMPOTENCY_KEY_MAX_LENGTH) is the server's to enforce —
192
+ # it trims the value and answers an out-of-range key with
193
+ # 400 invalid_idempotency_key. Checking here would only risk drifting from
194
+ # the server, and would disagree with the other MailBlastr SDKs.
195
+ def idempotency_key(value)
196
+ return nil if value.nil?
197
+
198
+ key = value.to_s
199
+ key.empty? ? nil : key
200
+ end
201
+
174
202
  # Read a hash param by symbol or string key.
175
203
  def opt(params, key)
176
204
  return nil unless params.is_a?(Hash)
@@ -194,6 +222,16 @@ module Mailblastr
194
222
  q
195
223
  end
196
224
 
225
+ # Copy the given keys out of `params` into a query hash, skipping the
226
+ # ones the caller left out. Used to expose an endpoint's server-side
227
+ # filters without forwarding unrelated params.
228
+ def filters(params, *keys)
229
+ keys.each_with_object({}) do |k, q|
230
+ v = opt(params, k)
231
+ q[k] = v unless v.nil?
232
+ end
233
+ end
234
+
197
235
  # Domain-first guard: several resources require the sending domain.
198
236
  def require_domain!(params, context)
199
237
  v = opt(params, :domain)
@@ -96,24 +96,44 @@ module Mailblastr
96
96
  )
97
97
  end
98
98
 
99
- # Bulk-import contacts from CSV text (header row optional; upsert by
100
- # email). Non-builtin columns auto-register as custom properties unless
101
- # `create_properties: false`. POST /audiences/:id/contacts/import
99
+ # Bulk-import contacts from CSV (header row optional; upsert by email).
100
+ # Non-builtin columns auto-register as custom properties unless
101
+ # `create_properties: false`. Pass `segment_id` to also add every
102
+ # imported email to one of this audience's segments.
103
+ # POST /audiences/:id/contacts/import
104
+ #
105
+ # Inline CSV text (capped at 5 MB and 10,000 rows):
102
106
  # Contacts.import({ audience_id: "aud_1", csv: "email\na@b.com" })
107
+ # Or a file already uploaded via import_upload (no row cap — the
108
+ # overflow past your contact limit comes back as `limit_skipped`):
109
+ # Contacts.import({ audience_id: "aud_1", storage_key: key })
103
110
  def import(params)
104
111
  audience_id = Client.opt(params, :audience_id)
105
- query = {}
106
- on_conflict = Client.opt(params, :on_conflict)
107
- query[:on_conflict] = on_conflict if on_conflict
112
+ query = Client.filters(params, :on_conflict, :segment_id)
108
113
  query[:create_properties] = "false" if Client.opt(params, :create_properties) == false
114
+ body = Client.filters(params, :csv, :file_name, :storage_key)
109
115
  Client.request(
110
116
  :post,
111
117
  "/audiences/#{Client.path_escape(audience_id)}/contacts/import",
112
- body: { csv: Client.opt(params, :csv) },
118
+ body: body,
113
119
  query: query
114
120
  )
115
121
  end
116
122
 
123
+ # Mint a presigned direct-upload URL for a CSV too large to inline
124
+ # (up to 256 MB). Upload the file to `upload_url`, then pass the returned
125
+ # `storage_key` to Contacts.import.
126
+ # POST /audiences/:id/contacts/import/upload — params: { filename:, size: }
127
+ # The `upload_url` is a bearer credential — do not log it.
128
+ def import_upload(params)
129
+ audience_id = Client.opt(params, :audience_id)
130
+ Client.request(
131
+ :post,
132
+ "/audiences/#{Client.path_escape(audience_id)}/contacts/import/upload",
133
+ body: Client.without(params, :audience_id)
134
+ )
135
+ end
136
+
117
137
  # Add a contact to a segment. POST /contacts/:id/segments/:segment_id
118
138
  def add_to_segment(contact_id, segment_id)
119
139
  Client.request(:post, "/contacts/#{Client.path_escape(contact_id)}/segments/#{Client.path_escape(segment_id)}")
@@ -124,14 +144,23 @@ module Mailblastr
124
144
  Client.request(:delete, "/contacts/#{Client.path_escape(contact_id)}/segments/#{Client.path_escape(segment_id)}")
125
145
  end
126
146
 
127
- # List the segments a contact belongs to. GET /contacts/:id/segments
128
- def list_segments(contact_id)
129
- Client.request(:get, "/contacts/#{Client.path_escape(contact_id)}/segments")
147
+ # List the segments a contact belongs to items carry id/name/created_at
148
+ # only, not the full segment object. GET /contacts/:id/segments
149
+ def list_segments(contact_id, params = {})
150
+ Client.request(
151
+ :get,
152
+ "/contacts/#{Client.path_escape(contact_id)}/segments",
153
+ query: Client.pagination(params)
154
+ )
130
155
  end
131
156
 
132
157
  # Get a contact's topic subscriptions. GET /contacts/:id/topics
133
- def get_topics(contact_id)
134
- Client.request(:get, "/contacts/#{Client.path_escape(contact_id)}/topics")
158
+ def get_topics(contact_id, params = {})
159
+ Client.request(
160
+ :get,
161
+ "/contacts/#{Client.path_escape(contact_id)}/topics",
162
+ query: Client.pagination(params)
163
+ )
135
164
  end
136
165
 
137
166
  # Update a contact's topic subscriptions. PATCH /contacts/:id/topics
@@ -13,11 +13,27 @@ module Mailblastr
13
13
  Client.request(:get, "/domains/#{Client.path_escape(domain_id)}")
14
14
  end
15
15
 
16
- # GET /domains
16
+ # GET /domains — with no pagination params every domain is returned.
17
+ # Rows still pending a DNS-TXT ownership claim are excluded; read those
18
+ # through get_claim instead.
17
19
  def list(params = {})
18
20
  Client.request(:get, "/domains", query: Client.pagination(params))
19
21
  end
20
22
 
23
+ # Check a domain's live MX records before adding receiving.
24
+ # `ours` is true only when every MX host is MailBlastr's. A DNS failure
25
+ # answers { has_mx: false, ours: false, records: [] }, not an error.
26
+ # GET /domains/mx-check?name=
27
+ def mx_check(name)
28
+ Client.request(:get, "/domains/mx-check", query: { name: name })
29
+ end
30
+
31
+ # Download the domain's DNS records as CSV text (a String, not JSON).
32
+ # GET /domains/:id/records.csv
33
+ def records_csv(domain_id)
34
+ Client.request(:get, "/domains/#{Client.path_escape(domain_id)}/records.csv", raw: true)
35
+ end
36
+
21
37
  # PATCH /domains/:id (returns the slim ack { object: "domain", id }).
22
38
  def update(domain_id, params)
23
39
  Client.request(:patch, "/domains/#{Client.path_escape(domain_id)}", body: params)
@@ -63,8 +79,8 @@ module Mailblastr
63
79
 
64
80
  # Apply DNS records via the Namecheap API (existing records preserved),
65
81
  # then auto-verify. POST /domains/:id/dns/namecheap
66
- # params: { api_user: "...", api_key: "...", user_name: "..." } — note the
67
- # backend expects camelCase keys (apiUser/apiKey/userName), so pass those.
82
+ # params: { api_user: "...", api_key: "...", user_name: "..." } — the
83
+ # camelCase spellings (apiUser/apiKey/userName) are accepted too.
68
84
  def apply_namecheap_dns(domain_id, params)
69
85
  Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/dns/namecheap", body: params)
70
86
  end
@@ -19,17 +19,24 @@ module Mailblastr
19
19
  end
20
20
 
21
21
  # List sent emails (trimmed list items) — cursor pagination plus optional
22
- # server-side `campaign_id`, `automation_id`, `source` ("individual"),
23
- # and `domain_id` filters. GET /emails
22
+ # server-side filters: `campaign_id`, `automation_id`, `source`
23
+ # ("individual"), `domain_id`, `status` (matched case-insensitively
24
+ # against the row's `last_event`) and `search` (recipients, subject and
25
+ # sender). `q` is the server's alias for `search`, honoured only when
26
+ # `search` is absent. GET /emails
24
27
  def list(params = {})
25
- query = Client.pagination(params)
26
- %i[campaign_id automation_id source domain_id].each do |k|
27
- v = Client.opt(params, k)
28
- query[k] = v unless v.nil?
29
- end
28
+ query = Client.pagination(params).merge(
29
+ Client.filters(params, :campaign_id, :automation_id, :source, :domain_id, :status, :search, :q)
30
+ )
30
31
  Client.request(:get, "/emails", query: query)
31
32
  end
32
33
 
34
+ # Per-source send metrics, one row per campaign / automation / individual
35
+ # origin. Not paginated. GET /emails/sources
36
+ def sources
37
+ Client.request(:get, "/emails/sources")
38
+ end
39
+
33
40
  # Retrieve a sent email and its events. GET /emails/:id
34
41
  def get(email_id)
35
42
  Client.request(:get, "/emails/#{Client.path_escape(email_id)}")
@@ -62,22 +69,34 @@ module Mailblastr
62
69
  class << self
63
70
  # List received emails — cursor pagination plus an optional
64
71
  # `received_for` filter (only messages received for that address).
72
+ # With no `limit` and no cursor the endpoint returns up to 1000 rows in
73
+ # one response; pass `limit` to get normal 1-100 pages.
65
74
  # GET /emails/receiving
66
75
  def list(params = {})
67
- query = Client.pagination(params)
68
- received_for = Client.opt(params, :received_for)
69
- query[:received_for] = received_for unless received_for.nil?
76
+ query = Client.pagination(params).merge(Client.filters(params, :received_for))
70
77
  Client.request(:get, "/emails/receiving", query: query)
71
78
  end
72
79
 
80
+ # Per-address inbound stats (totals, replies, last received).
81
+ # Not paginated. GET /emails/receiving/addresses
82
+ def addresses
83
+ Client.request(:get, "/emails/receiving/addresses")
84
+ end
85
+
73
86
  # Retrieve a received email. GET /emails/receiving/:id
74
87
  def get(email_id)
75
88
  Client.request(:get, "/emails/receiving/#{Client.path_escape(email_id)}")
76
89
  end
77
90
 
78
- # List a received email's attachments. GET /emails/receiving/:id/attachments
79
- def list_attachments(email_id)
80
- Client.request(:get, "/emails/receiving/#{Client.path_escape(email_id)}/attachments")
91
+ # List a received email's attachments. With no `limit` and no `after`
92
+ # every attachment is returned; supplying either paginates normally.
93
+ # GET /emails/receiving/:id/attachments
94
+ def list_attachments(email_id, params = {})
95
+ Client.request(
96
+ :get,
97
+ "/emails/receiving/#{Client.path_escape(email_id)}/attachments",
98
+ query: Client.pagination(params)
99
+ )
81
100
  end
82
101
 
83
102
  # Download one attachment as raw bytes (binary String).
@@ -11,18 +11,81 @@ module Mailblastr
11
11
  # e.name # => "validation_error"
12
12
  # e.message # => "The `from` address must use a verified domain."
13
13
  # end
14
+ #
15
+ # Match on #name, never on #message — messages are scrubbed of provider
16
+ # identifiers server-side and are not a stable contract. A handler may also
17
+ # answer with a status other than the one a name usually maps to, so read
18
+ # #status_code rather than assuming one from the name.
19
+ #
20
+ # Some errors carry additive fields on top of those three. The whole parsed
21
+ # body is kept on #body, with the common extras surfaced as readers that
22
+ # return nil on an ordinary error:
23
+ #
24
+ # rescue Mailblastr::Error => e
25
+ # if (cap = e.limit) # WHICH quota ran out
26
+ # cap["kind"] # => "emails_daily"
27
+ # cap["used"]; cap["limit"] # => 100, 100
28
+ # cap.dig("next_plan", "name") # => "Pro"
29
+ # end
30
+ # e.reputation # reputation gates
31
+ # e.sent # a batch that failed part way through
32
+ # e.sent_count # — do NOT resend these
33
+ # end
14
34
  class Error < StandardError
15
35
  attr_reader :status_code, :error_name
16
36
 
17
- def initialize(message = nil, status_code: nil, error_name: nil)
37
+ # The full parsed error body ({} when the response was not a JSON object).
38
+ # Read it for any additive field newer than this SDK version.
39
+ attr_reader :body
40
+
41
+ def initialize(message = nil, status_code: nil, error_name: nil, body: nil)
18
42
  super(message)
19
43
  @status_code = status_code
20
44
  @error_name = error_name
45
+ @body = body.is_a?(Hash) ? body : {}
21
46
  end
22
47
 
23
48
  # The API error `name` (e.g. "validation_error", "not_found").
24
49
  def name
25
50
  @error_name
26
51
  end
52
+
53
+ # The plan/quota cap this request hit, else nil. Carried by
54
+ # plan_limit_reached, every *_quota_exceeded, contact_limit_reached and
55
+ # ai_credits_exceeded — it says WHICH quota ran out, how much of it was
56
+ # used, and the cheapest plan that would fit.
57
+ def limit
58
+ hash_field("limit")
59
+ end
60
+
61
+ # The reputation-gate detail on reputation_paused /
62
+ # reputation_limit_exceeded, else nil. Carries at least "retryable" and
63
+ # "scope" ("tenant" | "domain" | "platform").
64
+ def reputation
65
+ hash_field("reputation")
66
+ end
67
+
68
+ # The emails that were already sent before a batch failed part way through
69
+ # (POST /emails/batch with an idempotency_key), else nil. Do NOT resend them.
70
+ def sent
71
+ value = @body["sent"]
72
+ value.is_a?(Array) ? value : nil
73
+ end
74
+
75
+ # How many emails went out before a batch failed part way through, else nil.
76
+ # Falls back to #sent's size when the body carried the list but not the count.
77
+ def sent_count
78
+ count = @body["sent_count"]
79
+ return count if count.is_a?(Integer)
80
+
81
+ sent&.size
82
+ end
83
+
84
+ private
85
+
86
+ def hash_field(key)
87
+ value = @body[key]
88
+ value.is_a?(Hash) ? value : nil
89
+ end
27
90
  end
28
91
  end
@@ -8,19 +8,37 @@ module Mailblastr
8
8
  # Send a custom event. POST /events/send
9
9
  # Mailblastr::Events.send({ event: "signup.completed", domain: "yourdomain.com",
10
10
  # email: "user@example.com", payload: { plan: "pro" } })
11
- # Identify the contact by `contact_id` OR `email`. Pass
12
- # { idempotency_key: "..." } as the second argument to retry safely.
11
+ # Identify the contact by `contact_id` OR `email`. Event names cannot
12
+ # start with the reserved "mailblastr:" prefix.
13
+ #
14
+ # NOTE: only POST /emails and POST /emails/batch honour `Idempotency-Key`.
15
+ # An `idempotency_key` passed here is still forwarded, but the server
16
+ # ignores it, so a retry ingests a SECOND event and can enroll the contact
17
+ # twice — de-duplicate on your side instead.
13
18
  def send(params, options = {})
14
19
  Client.require_domain!(params, "Events.send")
15
20
  Client.request(:post, "/events/send", body: params, options: options)
16
21
  end
17
22
 
18
- # Create a custom-event definition (name + optional payload schema). POST /events
23
+ # Create a custom-event definition (name + optional payload schema).
24
+ # Schema values are one of "string", "number", "boolean", "date".
25
+ # POST /events
19
26
  # Mailblastr::Events.create({ name: "signup.completed", schema: { plan: "string" } })
27
+ #
28
+ # NOTE: `options[:idempotency_key]` carries no guarantee here — see
29
+ # `send`. A duplicate event name is already a 422 validation_error.
20
30
  def create(params, options = {})
21
31
  Client.request(:post, "/events", body: params, options: options)
22
32
  end
23
33
 
34
+ # Update a definition's payload schema. PATCH /events/:id
35
+ # The event NAME is immutable (automations reference it) — passing `name`
36
+ # is a 422; pass `schema: nil` to clear the schema.
37
+ # Mailblastr::Events.update("evt_1", { schema: { plan: "string" } })
38
+ def update(event_id, params)
39
+ Client.request(:patch, "/events/#{Client.path_escape(event_id)}", body: params)
40
+ end
41
+
24
42
  # List custom-event definitions. GET /events
25
43
  def list(params = {})
26
44
  Client.request(:get, "/events", query: Client.pagination(params))
@@ -25,9 +25,15 @@ module Mailblastr
25
25
  Client.request(:get, "/segments", query: { domain: domain }.merge(Client.pagination(params)))
26
26
  end
27
27
 
28
- # Preview the contacts a segment currently resolves to. GET /segments/:id/contacts
29
- def contacts(segment_id)
30
- Client.request(:get, "/segments/#{Client.path_escape(segment_id)}/contacts")
28
+ # Preview the contacts a segment currently resolves to (filter matches
29
+ # plus explicit memberships). With no pagination params every contact is
30
+ # returned. GET /segments/:id/contacts
31
+ def contacts(segment_id, params = {})
32
+ Client.request(
33
+ :get,
34
+ "/segments/#{Client.path_escape(segment_id)}/contacts",
35
+ query: Client.pagination(params)
36
+ )
31
37
  end
32
38
 
33
39
  # PATCH /segments/:id
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailblastr
4
- VERSION = "1.2.0"
4
+ VERSION = "2.0.0"
5
5
  end
@@ -34,6 +34,22 @@ module Mailblastr
34
34
 
35
35
  # Send a synchronous test delivery and return the endpoint's live result.
36
36
  # POST /webhooks/:id/test
37
+ #
38
+ # A FAILED delivery is still HTTP 200, so this does NOT raise when your
39
+ # endpoint rejects the test. The outcome is the "ok" key:
40
+ #
41
+ # { "object" => "webhook_test", "id" => "<id>",
42
+ # "ok" => true, "status" => 200 } # endpoint accepted it
43
+ # { "object" => "webhook_test", "id" => "<id>",
44
+ # "ok" => false, "error" => "lookup_failed" } # it did not
45
+ #
46
+ # result = Mailblastr::Webhooks.test(id)
47
+ # warn "test delivery failed: #{result['error']}" unless result["ok"]
48
+ #
49
+ # "status" is your endpoint's HTTP status when it responded at all;
50
+ # "error" says why the delivery failed (e.g. "lookup_failed",
51
+ # "webhook missing or disabled"). It is a single attempt — no retries
52
+ # are scheduled.
37
53
  def test(webhook_id)
38
54
  Client.request(:post, "/webhooks/#{Client.path_escape(webhook_id)}/test")
39
55
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailblastr
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - MailBlastr
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-25 00:00:00.000000000 Z
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest