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 +4 -4
- data/README.md +89 -8
- data/lib/mailblastr/api_keys.rb +10 -17
- data/lib/mailblastr/automations.rb +29 -2
- data/lib/mailblastr/campaigns.rb +7 -0
- data/lib/mailblastr/client.rb +40 -2
- data/lib/mailblastr/contacts.rb +41 -12
- data/lib/mailblastr/domains.rb +19 -3
- data/lib/mailblastr/emails.rb +32 -13
- data/lib/mailblastr/error.rb +64 -1
- data/lib/mailblastr/events.rb +21 -3
- data/lib/mailblastr/segments.rb +9 -3
- data/lib/mailblastr/version.rb +1 -1
- data/lib/mailblastr/webhooks.rb +16 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da4eac6f929de305deb6c395655cb2658cc1ac3ee7410355846092ff6b61f66a
|
|
4
|
+
data.tar.gz: 05bfee5848400946fef957671b8bf891916bda6b7847988c531f2d5995ecb58a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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", "
|
|
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.
|
|
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
|
|
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>
|
data/lib/mailblastr/api_keys.rb
CHANGED
|
@@ -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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
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-keys — with 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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/mailblastr/campaigns.rb
CHANGED
|
@@ -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")
|
data/lib/mailblastr/client.rb
CHANGED
|
@@ -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)
|
data/lib/mailblastr/contacts.rb
CHANGED
|
@@ -96,24 +96,44 @@ module Mailblastr
|
|
|
96
96
|
)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
# Bulk-import contacts from CSV
|
|
100
|
-
#
|
|
101
|
-
# `create_properties: false`.
|
|
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:
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
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
|
data/lib/mailblastr/domains.rb
CHANGED
|
@@ -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: "..." } —
|
|
67
|
-
#
|
|
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
|
data/lib/mailblastr/emails.rb
CHANGED
|
@@ -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`
|
|
23
|
-
#
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
79
|
-
|
|
80
|
-
|
|
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).
|
data/lib/mailblastr/error.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/mailblastr/events.rb
CHANGED
|
@@ -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`.
|
|
12
|
-
#
|
|
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).
|
|
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))
|
data/lib/mailblastr/segments.rb
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
data/lib/mailblastr/version.rb
CHANGED
data/lib/mailblastr/webhooks.rb
CHANGED
|
@@ -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:
|
|
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-
|
|
11
|
+
date: 2026-08-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|