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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af406b751cac271f65de584e43a1b405887cbfa13448eace04d4ba8a58f57567
4
- data.tar.gz: 46c015562325cd114404e4f34e17ac44b7481ae781bb013aba951349fe016fc9
3
+ metadata.gz: ec78acb7d963058af8b922481262fcf7b3c1d6692876f304bdd373572b7a25b9
4
+ data.tar.gz: 29f2b545a15086968ad83e8f6e687868d9d4c575e292839852c540a797897d1d
5
5
  SHA512:
6
- metadata.gz: b65ef435e4c88cc4415eba880b0be79ae107f7f20d374e6f30a4b19e7c85b5afb4c49833910a671979150407659b13e39a097d84cef3174a168cae3d0514b835
7
- data.tar.gz: 157b75d0c9c47f2d0a78561238ab61ce03779ddf8da3b2a1a633cca472cfe0a66a2f392f2da54554aa2fa1dac319adac0ceddc03f313c871fde79dcd6dcd394c
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: ["user@example.com"],
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@example.com"], subject: "Hi A", html: "<p>A</p>" },
115
- { from: from, to: ["b@example.com"], subject: "Hi B", html: "<p>B</p>" }
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
- Mailblastr::Automations.update_step(automation["id"], step_id, { config: { subject: "New subject" } })
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, 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.
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
 
@@ -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 every non-revoked key is
12
- # returned. `token` here is the 8-character display prefix, never the
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,
@@ -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
- # Cancel a scheduled campaign (returns it to draft). POST /campaigns/:id/cancel
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
@@ -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 every domain is returned.
17
- # Rows still pending a DNS-TXT ownership claim are excluded; read those
18
- # through get_claim instead.
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: { api_user: "...", api_key: "...", user_name: "..." } — the
83
- # camelCase spellings (apiUser/apiKey/userName) are accepted too.
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
@@ -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
- # every attachment is returned; supplying either paginates normally.
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(
@@ -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 every contact is
30
- # returned. GET /segments/:id/contacts
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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailblastr
4
- VERSION = "4.0.0"
4
+ VERSION = "5.0.1"
5
5
  end
@@ -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` is a Hash carrying svix-id / svix-timestamp /
67
- # svix-signature (read case-insensitively; array values use the first
68
- # element). The signature header may carry multiple space-separated
69
- # `v1,<base64>` entries any one match makes the delivery valid.
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
- # result = Mailblastr::Webhooks.verify(request.raw_post, request.headers.to_h, secret)
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 unless headers.is_a?(Hash)
129
+ return nil if headers.nil?
112
130
 
113
131
  lower = name.downcase
114
- headers.each do |k, v|
115
- next unless k.to_s.downcase == lower
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 (base64-decode the
123
- # suffix); a secret without the prefix is used as raw UTF-8 bytes.
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
- if s.start_with?("whsec_")
127
- decoded = s["whsec_".length..].unpack1("m") # lenient base64 decode
128
- return decoded if decoded && !decoded.empty?
129
- end
130
- s
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: ["user@example.com"],
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.0.0
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-19 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest