mailblastr 3.0.1 → 5.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 +14 -7
- data/lib/mailblastr/api_keys.rb +3 -3
- data/lib/mailblastr/automations.rb +6 -1
- data/lib/mailblastr/campaigns.rb +9 -1
- data/lib/mailblastr/domains.rb +9 -5
- data/lib/mailblastr/emails.rb +2 -1
- data/lib/mailblastr/segments.rb +4 -2
- data/lib/mailblastr/version.rb +1 -1
- data/lib/mailblastr/webhooks.rb +49 -9
- data/lib/mailblastr.rb +6 -1
- 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: 53c08e2365d3814e563987d9625310f3cf42ed6d8aa5f1869b1603472dc72318
|
|
4
|
+
data.tar.gz: 0f0f31d046c3e508ed8a6812f79c1f8c502ec1354f7ac3885636bd4e8c5e9eb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e0eb93701e40a42117102a427d80b7d3003669515e31d3f58d96ad46680993a9989e98e205227d434e7227a7fead48e16e2bc541491920fe30104177fab8059
|
|
7
|
+
data.tar.gz: 219ab57ee04faaf8817f98711a46887b87686c40df79189fd3b75d9076f804bcf579d76a3b126d21d714f329453936fcf548733c00a1636d0f30d1f6c2e31a84
|
data/README.md
CHANGED
|
@@ -35,13 +35,15 @@ end
|
|
|
35
35
|
```ruby
|
|
36
36
|
sent = Mailblastr::Emails.send({
|
|
37
37
|
from: "Acme <hello@yourdomain.com>",
|
|
38
|
-
to: ["
|
|
38
|
+
to: ["delivered@mailblastr.dev"], # the mailbox simulator (see below)
|
|
39
39
|
subject: "Hello from MailBlastr",
|
|
40
40
|
html: "<p>Your first email 🎉</p>"
|
|
41
41
|
})
|
|
42
42
|
puts sent["id"]
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
`delivered@mailblastr.dev` is MailBlastr's mailbox simulator: the send is accepted, produces a real email object and a delivery event, and never reaches a provider. `bounced@`, `complained@` and `suppressed@mailblastr.dev` exercise the other outcomes, and `delivered@` / `bounced@` / `complained@` accept a `+label` suffix (`delivered+signup@mailblastr.dev`). Do **not** point a send at `example.com`, `example.net`, `example.org`, or an address under `.test`, `.invalid`, `.localhost` or `.example`: those are reserved for documentation, so every recipient is suppressed and the call comes back 422 `validation_error` ("All `to` recipients are suppressed") having sent nothing. They are fine as *contact* records — only the send path rejects them.
|
|
46
|
+
|
|
45
47
|
Params are plain hashes with snake_case keys, passed through as JSON. Successful calls return the parsed response (a Hash, or a raw String for binary downloads). Any non-2xx response raises `Mailblastr::Error`:
|
|
46
48
|
|
|
47
49
|
```ruby
|
|
@@ -109,10 +111,11 @@ Mailblastr::Emails.get_attachment(email_id, attachment_id)
|
|
|
109
111
|
Mailblastr::Emails.update(email_id, { scheduled_at: "2026-08-01T09:00:00Z" }) # reschedule
|
|
110
112
|
Mailblastr::Emails.cancel(email_id)
|
|
111
113
|
|
|
112
|
-
# Batch send — up to 100 emails in one request
|
|
114
|
+
# Batch send — up to 100 emails in one request.
|
|
115
|
+
# Batch items reject `attachments` and `scheduled_at` (422) — send those individually.
|
|
113
116
|
Mailblastr::Batch.send([
|
|
114
|
-
{ from: from, to: ["a@
|
|
115
|
-
{ from: from, to: ["b@
|
|
117
|
+
{ from: from, to: ["delivered+a@mailblastr.dev"], subject: "Hi A", html: "<p>A</p>" },
|
|
118
|
+
{ from: from, to: ["delivered+b@mailblastr.dev"], subject: "Hi B", html: "<p>B</p>" }
|
|
116
119
|
])
|
|
117
120
|
|
|
118
121
|
# Attachments: hosted URL (path) or inline base64 (content)
|
|
@@ -274,7 +277,11 @@ automation = Mailblastr::Automations.create({
|
|
|
274
277
|
})
|
|
275
278
|
|
|
276
279
|
Mailblastr::Automations.add_step(automation["id"], { type: "send_email", config: { template_id: tmpl_id } })
|
|
277
|
-
|
|
280
|
+
# `type` is REQUIRED on update_step: PATCH re-validates the whole step and
|
|
281
|
+
# `config` REPLACES the stored config wholesale (there is no merge), so resend
|
|
282
|
+
# every key you want to keep. A step's graph `key` is create-only — settable on
|
|
283
|
+
# add_step, ignored here — so delete and re-add a step to re-key it.
|
|
284
|
+
Mailblastr::Automations.update_step(automation["id"], step_id, { type: "send_email", config: { template_id: tmpl_id, subject: "New subject" } })
|
|
278
285
|
Mailblastr::Automations.update(automation["id"], { status: "enabled" })
|
|
279
286
|
|
|
280
287
|
# Or describe the flow and let the server build the steps (automation must be stopped)
|
|
@@ -383,7 +390,7 @@ page["data"] # => [...]
|
|
|
383
390
|
|
|
384
391
|
`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
392
|
|
|
386
|
-
Defaults differ per endpoint. `GET /templates`, `/webhooks`, `/audiences`, `/automations`, `/events` and `/automations/:id/runs` cap an unpaginated call at 20 rows
|
|
393
|
+
Defaults differ per endpoint. `GET /templates`, `/webhooks`, `/audiences`, `/automations`, `/events` and `/automations/:id/runs` cap an unpaginated call at 20 rows. `/domains`, `/api-keys`, `/topics`, `/campaigns`, `/contacts`, `/contact-properties`, `/segments` and `/polls` instead return the collection in one response when you pass neither `limit` nor a cursor — but still bounded, at 1,000 rows. That ceiling is not silent: `has_more` is `true` when it bites, so keep paging with `after` rather than treating the first response as the whole table. Always pass `limit` if you depend on page size.
|
|
387
394
|
|
|
388
395
|
## Idempotency
|
|
389
396
|
|
|
@@ -402,7 +409,7 @@ Only `Emails.send` and `Batch.send` honour the header. Every other endpoint —
|
|
|
402
409
|
|
|
403
410
|
## Rate limits
|
|
404
411
|
|
|
405
|
-
Only the `/emails` routes are rate-limited: **30 requests per minute per IP
|
|
412
|
+
Only the `/emails` **send** routes are rate-limited: **30 requests per minute per IP**. Reads (`GET /emails`, `GET /emails/:id`, the `receiving` subtree and attachment listings) are NOT subject to that cap, so paging a large list no longer risks a 429. Capped 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
413
|
|
|
407
414
|
## Documentation
|
|
408
415
|
|
data/lib/mailblastr/api_keys.rb
CHANGED
|
@@ -8,9 +8,9 @@ module Mailblastr
|
|
|
8
8
|
# means a leaked key cannot mint itself a replacement or widen its access.
|
|
9
9
|
module ApiKeys
|
|
10
10
|
class << self
|
|
11
|
-
# GET /api-keys — with no pagination params
|
|
12
|
-
#
|
|
13
|
-
# secret.
|
|
11
|
+
# GET /api-keys — with no pagination params one page carries up to 1,000
|
|
12
|
+
# non-revoked keys and `has_more` reports any truncation. `token` here is
|
|
13
|
+
# the 8-character display prefix, never the secret.
|
|
14
14
|
def list(params = {})
|
|
15
15
|
Client.request(:get, "/api-keys", query: Client.pagination(params))
|
|
16
16
|
end
|
|
@@ -42,7 +42,12 @@ module Mailblastr
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Edit a step in place (automation must be disabled).
|
|
45
|
-
# PATCH /automations/:id/steps/:step_id
|
|
45
|
+
# PATCH /automations/:id/steps/:step_id — params: { type:, config: }
|
|
46
|
+
# `type` is REQUIRED: the server re-validates the whole step, so omitting
|
|
47
|
+
# it is a validation_error naming the valid types. `config` REPLACES the
|
|
48
|
+
# stored config wholesale (there is no merge) — resend every key you want
|
|
49
|
+
# to keep. `key` is not accepted here; a step's graph key is create-only
|
|
50
|
+
# (set on add_step), so delete and re-add a step to re-key it.
|
|
46
51
|
def update_step(automation_id, step_id, params)
|
|
47
52
|
Client.request(
|
|
48
53
|
:patch,
|
data/lib/mailblastr/campaigns.rb
CHANGED
|
@@ -42,7 +42,15 @@ module Mailblastr
|
|
|
42
42
|
Client.request(:post, "/campaigns/#{Client.path_escape(campaign_id)}/send", body: params)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# Stop a campaign's remaining work. Accepted on `scheduled`, `recurring`,
|
|
46
|
+
# `paused` and `queued`; anything else is a validation_error.
|
|
47
|
+
# `scheduled`/`recurring`/`paused` return to `draft` (editable, re-sendable);
|
|
48
|
+
# a `queued` campaign already fanning out becomes the TERMINAL `canceled`,
|
|
49
|
+
# which can never be edited, re-sent or deleted. Copies already handed to
|
|
50
|
+
# the mail service cannot be recalled; what stops is every remaining
|
|
51
|
+
# recipient (for a staggered campaign, every future batch-day).
|
|
52
|
+
# Read the returned `status` to see which happened.
|
|
53
|
+
# POST /campaigns/:id/cancel
|
|
46
54
|
def cancel(campaign_id)
|
|
47
55
|
Client.request(:post, "/campaigns/#{Client.path_escape(campaign_id)}/cancel")
|
|
48
56
|
end
|
data/lib/mailblastr/domains.rb
CHANGED
|
@@ -13,9 +13,10 @@ module Mailblastr
|
|
|
13
13
|
Client.request(:get, "/domains/#{Client.path_escape(domain_id)}")
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# GET /domains — with no pagination params
|
|
17
|
-
#
|
|
18
|
-
#
|
|
16
|
+
# GET /domains — with no pagination params one page carries up to 1,000
|
|
17
|
+
# domains and `has_more` reports any truncation; pass `limit` and walk
|
|
18
|
+
# `after` to read past that. Rows still pending a DNS-TXT ownership claim
|
|
19
|
+
# are excluded; read those through get_claim instead.
|
|
19
20
|
def list(params = {})
|
|
20
21
|
Client.request(:get, "/domains", query: Client.pagination(params))
|
|
21
22
|
end
|
|
@@ -79,8 +80,11 @@ module Mailblastr
|
|
|
79
80
|
|
|
80
81
|
# Apply DNS records via the Namecheap API (existing records preserved),
|
|
81
82
|
# then auto-verify. POST /domains/:id/dns/namecheap
|
|
82
|
-
# params: {
|
|
83
|
-
#
|
|
83
|
+
# params: { apiUser: "...", apiKey: "...", userName: "..." } — camelCase,
|
|
84
|
+
# the same spelling every MailBlastr SDK uses. `api_user` and `api_key`
|
|
85
|
+
# are accepted aliases, and the optional username may also be spelled
|
|
86
|
+
# `username`, but there is NO `user_name` alias: the API ignores that key,
|
|
87
|
+
# so a username sent under it is silently dropped.
|
|
84
88
|
def apply_namecheap_dns(domain_id, params)
|
|
85
89
|
Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/dns/namecheap", body: params)
|
|
86
90
|
end
|
data/lib/mailblastr/emails.rb
CHANGED
|
@@ -96,7 +96,8 @@ module Mailblastr
|
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
# List a received email's attachments. With no `limit` and no `after`
|
|
99
|
-
#
|
|
99
|
+
# one page carries up to 1,000 of them and `has_more` reports any
|
|
100
|
+
# truncation; supplying either paginates normally.
|
|
100
101
|
# GET /emails/receiving/:id/attachments
|
|
101
102
|
def list_attachments(email_id, params = {})
|
|
102
103
|
Client.request(
|
data/lib/mailblastr/segments.rb
CHANGED
|
@@ -26,8 +26,10 @@ module Mailblastr
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Preview the contacts a segment currently resolves to (filter matches
|
|
29
|
-
# plus explicit memberships). With no pagination params
|
|
30
|
-
#
|
|
29
|
+
# plus explicit memberships). With no pagination params the response is
|
|
30
|
+
# capped at 1,000 contacts and sets `has_more` — a segment can hold far
|
|
31
|
+
# more than that, so page with `limit` + `after` to read all of it.
|
|
32
|
+
# GET /segments/:id/contacts
|
|
31
33
|
def contacts(segment_id, params = {})
|
|
32
34
|
Client.request(
|
|
33
35
|
:get,
|
data/lib/mailblastr/version.rb
CHANGED
data/lib/mailblastr/webhooks.rb
CHANGED
|
@@ -63,14 +63,18 @@ module Mailblastr
|
|
|
63
63
|
# signing secret. Pure local computation (OpenSSL HMAC-SHA256) — no HTTP.
|
|
64
64
|
#
|
|
65
65
|
# `payload` MUST be the exact raw request body string (do not re-serialize
|
|
66
|
-
# parsed JSON). `headers`
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# `
|
|
66
|
+
# parsed JSON). `headers` may be a Hash OR any pair-yielding header
|
|
67
|
+
# container (e.g. Rails' `request.headers`), carrying svix-id /
|
|
68
|
+
# svix-timestamp / svix-signature (read case-insensitively, rack
|
|
69
|
+
# `HTTP_SVIX_ID` spellings included; array values use the first element).
|
|
70
|
+
# The signature header may carry multiple space-separated `v1,<base64>`
|
|
71
|
+
# entries — any one match makes the delivery valid.
|
|
70
72
|
#
|
|
71
73
|
# Returns { valid: true } or { valid: false, reason: "..." }.
|
|
72
74
|
#
|
|
73
|
-
#
|
|
75
|
+
# # Rails: pass request.headers straight through — NOT .to_h, which
|
|
76
|
+
# # hands you the rack env spellings instead of the header names.
|
|
77
|
+
# result = Mailblastr::Webhooks.verify(request.raw_post, request.headers, secret)
|
|
74
78
|
# head :unauthorized unless result[:valid]
|
|
75
79
|
def verify(payload, headers, secret, tolerance: 300)
|
|
76
80
|
id = read_header(headers, "svix-id")
|
|
@@ -107,12 +111,38 @@ module Mailblastr
|
|
|
107
111
|
private
|
|
108
112
|
|
|
109
113
|
# Case-insensitively read one header value (first element if an array).
|
|
114
|
+
#
|
|
115
|
+
# Accepts a Hash AND any pair-yielding container, because Rails'
|
|
116
|
+
# `request.headers` is an ActionDispatch::Http::Headers — Enumerable, not
|
|
117
|
+
# a Hash — and the old `is_a?(Hash)` guard rejected it before any lookup,
|
|
118
|
+
# so the most natural call answered `missing_headers` for every genuinely
|
|
119
|
+
# signed delivery. Rack/WSGI spellings (`HTTP_SVIX_ID`) are matched too,
|
|
120
|
+
# since ActionDispatch's #each delegates to the raw rack env: that is what
|
|
121
|
+
# `request.headers.to_h` and a bare rack `env` actually contain, and
|
|
122
|
+
# downcased `http_svix_id` never equals `svix-id`.
|
|
123
|
+
#
|
|
124
|
+
# Enumerating is guarded: an exotic container whose #each demands a block
|
|
125
|
+
# would otherwise raise LocalJumpError out of `verify`, turning a 401 into
|
|
126
|
+
# a 500 inside a webhook controller. Caller-supplied input must never
|
|
127
|
+
# raise here — an unreadable container is just `missing_headers`.
|
|
110
128
|
def read_header(headers, name)
|
|
111
|
-
return nil
|
|
129
|
+
return nil if headers.nil?
|
|
112
130
|
|
|
113
131
|
lower = name.downcase
|
|
114
|
-
|
|
115
|
-
|
|
132
|
+
rack = "http_#{lower.tr('-', '_')}"
|
|
133
|
+
pairs =
|
|
134
|
+
begin
|
|
135
|
+
if headers.respond_to?(:each_pair) then headers.each_pair.to_a
|
|
136
|
+
elsif headers.respond_to?(:each) then headers.each.to_a
|
|
137
|
+
else return nil
|
|
138
|
+
end
|
|
139
|
+
rescue StandardError
|
|
140
|
+
return nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
pairs.each do |k, v|
|
|
144
|
+
key = k.to_s.downcase
|
|
145
|
+
next unless key == lower || key == rack
|
|
116
146
|
|
|
117
147
|
return v.is_a?(Array) ? v.first : v
|
|
118
148
|
end
|
|
@@ -121,10 +151,20 @@ module Mailblastr
|
|
|
121
151
|
|
|
122
152
|
# Derive the HMAC key from a `whsec_`-prefixed secret (base64-decode the
|
|
123
153
|
# suffix); a secret without the prefix is used as raw UTF-8 bytes.
|
|
154
|
+
#
|
|
155
|
+
# `tr` FIRST: the signer decodes with Node's Buffer.from(suffix, 'base64')
|
|
156
|
+
# (lib/crypto.ts secretToKey), which reads "-"/"_" as the URL-safe
|
|
157
|
+
# spellings of "+"/"/". `unpack1("m")` tolerates missing padding but
|
|
158
|
+
# DROPS those two characters instead of translating them, yielding a
|
|
159
|
+
# SHORTER, different key than the signer derived — so a caller-supplied
|
|
160
|
+
# URL-safe secret (POST /webhooks accepts `secret` verbatim, unvalidated)
|
|
161
|
+
# made every genuinely signed delivery come back `no_match`. Standard,
|
|
162
|
+
# padded and unpadded suffixes are unaffected by the `tr`, which is why
|
|
163
|
+
# the failure looked arbitrary and secret-specific.
|
|
124
164
|
def secret_to_key(secret)
|
|
125
165
|
s = secret.to_s
|
|
126
166
|
if s.start_with?("whsec_")
|
|
127
|
-
decoded = s["whsec_".length..].unpack1("m")
|
|
167
|
+
decoded = s["whsec_".length..].tr("-_", "+/").unpack1("m")
|
|
128
168
|
return decoded if decoded && !decoded.empty?
|
|
129
169
|
end
|
|
130
170
|
s
|
data/lib/mailblastr.rb
CHANGED
|
@@ -25,11 +25,16 @@ require "mailblastr/polls"
|
|
|
25
25
|
#
|
|
26
26
|
# sent = Mailblastr::Emails.send({
|
|
27
27
|
# from: "Acme <hello@yourdomain.com>",
|
|
28
|
-
# to: ["
|
|
28
|
+
# to: ["delivered@mailblastr.dev"],
|
|
29
29
|
# subject: "Hello from MailBlastr",
|
|
30
30
|
# html: "<p>Your first email</p>"
|
|
31
31
|
# })
|
|
32
32
|
# sent["id"] # => "..."
|
|
33
|
+
#
|
|
34
|
+
# `delivered@mailblastr.dev` is the mailbox simulator: the send is accepted and
|
|
35
|
+
# produces a real email object without reaching a provider. Swap in a real
|
|
36
|
+
# recipient when you go live — an address on a reserved documentation domain
|
|
37
|
+
# (example.com, .test, .invalid) is refused with 422 `reserved_recipient`.
|
|
33
38
|
module Mailblastr
|
|
34
39
|
DEFAULT_BASE_URL = "https://www.mailblastr.com/api"
|
|
35
40
|
|
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: 5.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-08-
|
|
11
|
+
date: 2026-08-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|