mailblastr 4.0.0 → 5.0.1
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 +13 -6
- 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 +134 -15
- 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: ec78acb7d963058af8b922481262fcf7b3c1d6692876f304bdd373572b7a25b9
|
|
4
|
+
data.tar.gz: 29f2b545a15086968ad83e8f6e687868d9d4c575e292839852c540a797897d1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3d399fe4c0db84908d49c0aa590dadc869ef661dabf0a9b3efea01ca106ec4a36bb5050d1ac760e5bd875ebaa85117750b9d8df8607f069a303391bc4763402
|
|
7
|
+
data.tar.gz: 8be077ef5c686252a518edf896002f48af3e6ac07450b1fbd3f18cf3fdf252cb05b05d186e502d342b8a00a8e40d67ec2ee9fca11fc74889527720810d61dbfa
|
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
|
|
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,27 +111,142 @@ 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
|
|
119
149
|
nil
|
|
120
150
|
end
|
|
121
151
|
|
|
122
|
-
# Derive the HMAC key from a `whsec_`-prefixed secret
|
|
123
|
-
#
|
|
152
|
+
# Derive the HMAC key from a `whsec_`-prefixed secret the way the SIGNER
|
|
153
|
+
# does, byte for byte. The signer is Node: base64-decode the suffix with
|
|
154
|
+
# `Buffer.from(suffix, 'base64')` and, when that yields ZERO bytes, fall
|
|
155
|
+
# back to the UTF-8 bytes of the WHOLE secret — `whsec_` prefix INCLUDED
|
|
156
|
+
# (mailblastr_webapp/lib/crypto.ts secretToKey). A secret without the
|
|
157
|
+
# prefix is used as raw UTF-8 bytes.
|
|
158
|
+
#
|
|
159
|
+
# The zero-byte fallback is not a curiosity: POST /webhooks stores
|
|
160
|
+
# `secret` verbatim with no shape validation, so "whsec_", "whsec_=",
|
|
161
|
+
# "whsec_!!!!" and "whsec_=YWJj" are all secrets a customer can really
|
|
162
|
+
# create, and each one keys the HMAC with its own literal text. Keying
|
|
163
|
+
# with an empty string instead — the obvious reading of "decode, then
|
|
164
|
+
# use the result" — costs the whole endpoint silently, because a key
|
|
165
|
+
# that differs from the signer's does not fail loudly: verify answers
|
|
166
|
+
# `no_match` and a correctly configured endpoint treats every genuine
|
|
167
|
+
# delivery as forged.
|
|
124
168
|
def secret_to_key(secret)
|
|
125
169
|
s = secret.to_s
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
170
|
+
return s unless s.start_with?("whsec_")
|
|
171
|
+
|
|
172
|
+
decoded = node_base64_decode(s["whsec_".length..])
|
|
173
|
+
decoded.empty? ? s : decoded
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Node's `Buffer.from(str, 'base64')`, reproduced. Every rule here was
|
|
177
|
+
# read off Node itself, NOT off a base64 RFC — following the RFC is
|
|
178
|
+
# precisely how this shipped broken twice — and each one costs a real
|
|
179
|
+
# key when Ruby's own decoder is trusted instead:
|
|
180
|
+
#
|
|
181
|
+
# * "=" TERMINATES the input. Everything from the first one onward is
|
|
182
|
+
# DISCARDED; it is not "padding to be stripped". "YWJj====ZA" is
|
|
183
|
+
# "abc", NOT "abcd", and a leading "=" leaves nothing at all (so the
|
|
184
|
+
# caller's raw fallback takes over). `unpack1("m")` decodes straight
|
|
185
|
+
# past an interior "=", deriving a LONGER key than the signer's.
|
|
186
|
+
# * "-" and "_" are the URL-safe spellings of "+" and "/" and must be
|
|
187
|
+
# TRANSLATED. `unpack1("m")` silently DROPS them, shortening the key.
|
|
188
|
+
# * Any other out-of-alphabet byte is SKIPPED, never fatal: whitespace,
|
|
189
|
+
# punctuation and non-ASCII are ignored, so "YW!Jj" is "abc".
|
|
190
|
+
# * A trailing group of ONE character carries no whole byte, so it is
|
|
191
|
+
# dropped here rather than left to `unpack1`'s discretion (2 chars ->
|
|
192
|
+
# 1 byte, 3 -> 2, 4 -> 3).
|
|
193
|
+
# * The unit Node indexes the alphabet with is the LOW 8 BITS OF EACH
|
|
194
|
+
# UTF-16 CODE UNIT, applied FIRST — see `utf16_low_bytes` below.
|
|
195
|
+
#
|
|
196
|
+
# Bytes, not characters, from the mask onward: a caller's secret need not
|
|
197
|
+
# be valid UTF-8, and an Encoding::CompatibilityError escaping this method
|
|
198
|
+
# would turn a webhook controller's 401 into a 500.
|
|
199
|
+
def node_base64_decode(suffix)
|
|
200
|
+
# Rule 5 runs BEFORE the "=" split, not after: U+013D masks to 0x3D and
|
|
201
|
+
# must TERMINATE the input like a literal "=" — splitting on the
|
|
202
|
+
# unmasked text would decode straight past it and derive a longer key.
|
|
203
|
+
s = utf16_low_bytes(suffix)
|
|
204
|
+
terminator = s.index("=")
|
|
205
|
+
s = s[0, terminator] if terminator
|
|
206
|
+
|
|
207
|
+
chars = s.tr("-_", "+/").gsub(%r{[^A-Za-z0-9+/]}n, "")
|
|
208
|
+
chars = chars[0, chars.bytesize - 1] if (chars.bytesize % 4) == 1
|
|
209
|
+
return "" if chars.empty?
|
|
210
|
+
|
|
211
|
+
chars.unpack1("m") || ""
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Rule 5, the one no SDK had: Node masks every UTF-16 CODE UNIT with 0xFF
|
|
215
|
+
# before the base64 table lookup, so the alphabet is indexed by a code
|
|
216
|
+
# unit's low byte, NOT by the codepoint and NOT by the UTF-8 bytes.
|
|
217
|
+
#
|
|
218
|
+
# Ruby strings are UTF-8, so the code units have to be materialised first:
|
|
219
|
+
# "Ł" (U+0141) is two UTF-8 bytes but ONE code unit masking to 0x41 "A",
|
|
220
|
+
# and "𝑁" (U+1D441) is ASTRAL — its four UTF-8 bytes are Node's TWO
|
|
221
|
+
# surrogate halves 0xD835/0xDC41, masking to 0x35 "5" and 0x41 "A". Taking
|
|
222
|
+
# UTF-8 bytes instead feeds the decoder continuation bytes that are all
|
|
223
|
+
# out-of-alphabet, silently shortening the key.
|
|
224
|
+
#
|
|
225
|
+
# Every codepoint below 0x100 masks to itself and every one at or above it
|
|
226
|
+
# is a different character entirely, so nothing under 0x100 can expose
|
|
227
|
+
# this — which is exactly why it survived a 31-vector corpus and a
|
|
228
|
+
# 2000-case ASCII fuzz, and why the cost lands only on the customer whose
|
|
229
|
+
# secret happens to carry a "Ł", a fullwidth letter or an emoji: their
|
|
230
|
+
# endpoint answers `no_match` to every genuine delivery.
|
|
231
|
+
#
|
|
232
|
+
# Undecodable input never raises out of here: invalid bytes become U+FFFD,
|
|
233
|
+
# whose low byte 0xFD is out of alphabet and therefore skipped — the same
|
|
234
|
+
# answer the old byte-wise reader gave — and a total conversion failure
|
|
235
|
+
# falls back to the raw bytes rather than 500-ing a webhook controller.
|
|
236
|
+
def utf16_low_bytes(str)
|
|
237
|
+
s = str.to_s
|
|
238
|
+
s = s.dup.force_encoding(Encoding::UTF_8) if s.encoding == Encoding::BINARY
|
|
239
|
+
s.encode(Encoding::UTF_16LE, invalid: :replace, undef: :replace)
|
|
240
|
+
.b.unpack("v*").map { |unit| unit & 0xFF }.pack("C*")
|
|
241
|
+
rescue StandardError
|
|
242
|
+
# Unreachable in practice: invalid:/undef: :replace make the encode total
|
|
243
|
+
# for any String. Kept so a webhook controller cannot 500 on a pathological
|
|
244
|
+
# input -- but it must NOT return str.b. Those are the raw UTF-8 bytes,
|
|
245
|
+
# which is exactly the pre-5.0.1 behaviour this method exists to replace,
|
|
246
|
+
# and returning them would silently derive a WRONG key that verifies
|
|
247
|
+
# nothing. Empty routes to the documented whole-secret fallback instead:
|
|
248
|
+
# still a mismatch, but a defined one rather than a reinstated bug.
|
|
249
|
+
""
|
|
131
250
|
end
|
|
132
251
|
|
|
133
252
|
# Constant-time compare of two signature strings.
|
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.1
|
|
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-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|