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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9de7ff5dac71bd3ba5db1b38369328167f9b2a52b59974201f868299f23d35c
4
- data.tar.gz: bc48385e4675d1c6ef5163e5fb7a2583b145562bf43e8a4d5d3d33f9797eecea
3
+ metadata.gz: 53c08e2365d3814e563987d9625310f3cf42ed6d8aa5f1869b1603472dc72318
4
+ data.tar.gz: 0f0f31d046c3e508ed8a6812f79c1f8c502ec1354f7ac3885636bd4e8c5e9eb3
5
5
  SHA512:
6
- metadata.gz: 40472dc57967817c0071973ecb6d7f353f5a927f9261c76f035f13552d47680481caad49fcf01da8a6698996278811f23c6a8b999a6aa8312461e92d88676a0e
7
- data.tar.gz: 63131860cae2becb8dddc98c36aed13e970433541b81e4c0b93732ca968ab9a7a2db9c6ee7d5630d114a2e46ea3d09c21d7a49193f7e337b98dce95bf42c7057
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: ["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
 
@@ -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**, 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`.
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
 
@@ -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 = "3.0.1"
4
+ VERSION = "5.0.0"
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,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 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
@@ -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") # lenient base64 decode
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: ["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: 3.0.1
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-14 00:00:00.000000000 Z
11
+ date: 2026-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest