mailblastr 5.1.0 → 5.2.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: a028b42e26c044b29d184717824743d50dc909638a11f26182373c29f39a801e
4
- data.tar.gz: 9bc74f8a2d2dd6b8c795bde5c92e6fa3490e9e9aa4fe14ff87cca1d3ca3fe710
3
+ metadata.gz: 2f6ca9c697ddc14eea6b2e355749bfc000d513224e6afe32e04796b3c1cf954d
4
+ data.tar.gz: d0522dd6dfd919babce3fac4324e1adaafc7a9dff6b801815512a53a3d99147e
5
5
  SHA512:
6
- metadata.gz: e9c966eea358f8d09c5f2087a7f209e9d78d642ccdff1a8e8a38b510ed0df4b32074474cfa06d8b4dc14743af868878f837d249be2303c3c53b017ebb8f3c236
7
- data.tar.gz: '082c972bf7ed332f0b6b1ae5d851718ec0dab54c9ca2b9746da27047cb263bd19640a03b77f3ee7de18b618fde84d00adbeae9b8fa0936536b065c97c7c1a367'
6
+ metadata.gz: cd2e5834d366d29d01a797ef11db73cc074181084d76be044a2223684d76f0f1935c75df81b449c0f344234a96b9e31d4a941a2e4ff86e4b518c5727c53e7f5e
7
+ data.tar.gz: 977c69e7fcee5e616cc1e55166279d8b784bedeffb45e9d980e288cc6cdbb3a632556ff23a8e73c7412a22e5094413900e1e164cb7a098e0bc6e526a495b88db
data/README.md CHANGED
@@ -104,6 +104,7 @@ That means `domain` (the sending domain, e.g. `"yourdomain.com"` — one of your
104
104
  Mailblastr::Emails.send({ from: from, to: to, subject: subject, html: html })
105
105
  Mailblastr::Emails.list({ limit: 20, after: cursor }) # cursor pagination
106
106
  Mailblastr::Emails.list({ status: "bounced", search: "acme.com" }) # filters
107
+ Mailblastr::Emails.list({ folder: "scheduled" }) # one of outbox | sent | scheduled | failed — any other value is rejected (422)
107
108
  Mailblastr::Emails.sources # per-campaign/automation send metrics
108
109
  Mailblastr::Emails.get(email_id)
109
110
  Mailblastr::Emails.list_attachments(email_id)
@@ -407,7 +408,7 @@ The key must be **1–255 characters**, measured after the server trims it — 2
407
408
 
408
409
  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`).
409
410
 
410
- 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.
411
+ `Emails.send`, `Batch.send`, and received-email reply/forward 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.
411
412
 
412
413
  ## Rate limits
413
414
 
@@ -420,3 +421,49 @@ Full docs: <https://www.mailblastr.com/docs>
420
421
  ## License
421
422
 
422
423
  MIT
424
+
425
+ ## Recovery and tracking contracts
426
+
427
+ Use a stable, unique operation key for each intended send, batch, reply, or
428
+ forward. Keep the same key and payload when recovering that operation. These
429
+ are the supported idempotent send endpoints; events do not implement this
430
+ header. Existing calls without options still work.
431
+
432
+ ```ruby
433
+ Mailblastr::Emails::Receiving.reply(id, reply, idempotency_key: "reply-operation-1")
434
+ Mailblastr::Emails::Receiving.forward(id, forward, idempotency_key: "forward-operation-1")
435
+ health = Mailblastr::Domains.tracking_health(domain_id)
436
+ ```
437
+
438
+ Automatic retries consider only 429/503. They stop on an original email `id`,
439
+ positive `sent_count`, nonempty `sent` or `reserved`, or `batch_incomplete`.
440
+ An ordinary rate limit can retry; a generic 503 can retry a read or a send with
441
+ the same supported key. Other writes retry only documented pre-processing
442
+ rejections (`service_unavailable`, `sending_service_unavailable`,
443
+ `sending_configuration_unavailable`, `contacts_busy`, `contacts_timeout`).
444
+ No network/body-read failure, 409, 422, or other 5xx is retried automatically.
445
+ The default transport refuses redirects; a custom transport/client must enforce
446
+ its own policy.
447
+
448
+ On a failed or unconfirmed send, inspect `id` with the email retrieval method
449
+ before creating another send. A 422 with an ID can identify an uncertain
450
+ provider handoff; 422 does not always mean nothing happened. For interrupted
451
+ batches, `sent` contains confirmed sends, `reserved` contains the original
452
+ attempted prefix (including uncertain handoffs), and `unsent_count` counts the
453
+ never-attempted tail. Do not resend the full batch or the reserved prefix under
454
+ a new key. Reconcile original IDs first, then submit only known unattempted
455
+ items as a new operation. Recovery fields remain available in the full error
456
+ body as well as language-specific fields/accessors.
457
+
458
+ Tracking health returns `custom_host`, `status` (`shared`, `ready`, or
459
+ `unavailable`), and `checked_at`. Configure custom tracking through the domain
460
+ API and check health before relying on it. A healthy endpoint cannot guarantee
461
+ an open event: recipients may block images, and coupon redemption alone is not
462
+ proof that the tracking pixel loaded. SDKs preserve supplied HTML/text and do
463
+ not infer opens or rewrite editor spacing.
464
+
465
+ Campaign cancellation also stops pending follow-ups for an already-sent
466
+ campaign while retaining its sent history. Permanent received-email deletion
467
+ acknowledges a durable cleanup request; attachment/object cleanup can finish
468
+ asynchronously. Retrying that deletion is safe; it cannot be undone after the
469
+ purge request is accepted.
@@ -19,9 +19,8 @@ module Mailblastr
19
19
  delete: Net::HTTP::Delete
20
20
  }.freeze
21
21
 
22
- # Only 429 and 503 are safe to retry automatically: the server guarantees
23
- # it did NOT apply the request, so a retry cannot duplicate a non-idempotent
24
- # side effect (e.g. sending an email twice). Everything else propagates.
22
+ # Only 429/503 are candidates; retry_allowed? also checks progress and
23
+ # whether a write was rejected before processing or protected by a key.
25
24
  RETRYABLE_STATUSES = [429, 503].freeze
26
25
 
27
26
  # Upper bound (seconds) on any single backoff wait.
@@ -30,7 +29,7 @@ module Mailblastr
30
29
  # `Idempotency-Key` is stored in a VARCHAR(255) column, so the API accepts
31
30
  # 1-255 characters measured after it trims the value — 255, not 256 — and
32
31
  # answers anything else with 400 invalid_idempotency_key. Only
33
- # POST /emails and POST /emails/batch read the header; every other endpoint
32
+ # POST /emails, POST /emails/batch, and received-email reply/forward read the header; every other endpoint
34
33
  # ignores it, so a retry there creates a second resource.
35
34
  #
36
35
  # Exposed for discoverability only: the SDK sends the key as given and lets
@@ -68,7 +67,7 @@ module Mailblastr
68
67
  loop do
69
68
  resp = deliver(req, uri)
70
69
  code = resp.code.to_i
71
- return resp unless RETRYABLE_STATUSES.include?(code) && attempt < max
70
+ return resp unless RETRYABLE_STATUSES.include?(code) && attempt < max && retry_allowed?(req, uri, code, resp.body)
72
71
 
73
72
  wait = retry_after_seconds(response_header(resp, "Retry-After"))
74
73
  wait ||= [MAX_BACKOFF_SECONDS, 0.5 * (2**attempt)].min
@@ -77,6 +76,25 @@ module Mailblastr
77
76
  end
78
77
  end
79
78
 
79
+ def retry_allowed?(req, uri, status, raw)
80
+ body = begin
81
+ JSON.parse(raw.to_s)
82
+ rescue JSON::ParserError
83
+ {}
84
+ end
85
+ body = {} unless body.is_a?(Hash)
86
+ return false if (body["id"].is_a?(String) && !body["id"].empty?) ||
87
+ (body["sent_count"].is_a?(Numeric) && body["sent_count"].positive?) ||
88
+ (body["sent"].is_a?(Array) && !body["sent"].empty?) ||
89
+ (body["reserved"].is_a?(Array) && !body["reserved"].empty?) ||
90
+ body["name"] == "batch_incomplete"
91
+ return true if status != 503 || %w[GET HEAD].include?(req.method)
92
+ return true if %w[service_unavailable sending_service_unavailable sending_configuration_unavailable contacts_busy contacts_timeout].include?(body["name"])
93
+
94
+ req.method == "POST" && !req["Idempotency-Key"].to_s.strip.empty? &&
95
+ %r{/emails(?:/batch|/receiving/[^/]+/(?:reply|forward))?\z}.match?(uri.path)
96
+ end
97
+
80
98
  # Wraps Kernel#sleep so tests can stub out the wait. Extracted so the
81
99
  # retry loop stays deterministic under test.
82
100
  def backoff_sleep(seconds)
@@ -114,6 +132,7 @@ module Mailblastr
114
132
  end
115
133
  end
116
134
 
135
+ return nil unless seconds.finite?
117
136
  seconds = 0.0 if seconds.negative?
118
137
  [seconds.to_f, MAX_BACKOFF_SECONDS].min
119
138
  end
@@ -172,7 +191,7 @@ module Mailblastr
172
191
  # `sent`/`sent_count` (see Mailblastr::Error).
173
192
  raise Mailblastr::Error.new(
174
193
  parsed["message"] || "Request failed with status #{code}",
175
- status_code: parsed["statusCode"] || code,
194
+ status_code: code,
176
195
  error_name: parsed["name"] || "application_error",
177
196
  body: parsed
178
197
  )
@@ -90,6 +90,9 @@ module Mailblastr
90
90
  # Contacts.batch({ audience_id: "aud_1", contacts: [{ email: "a@b.com" }], on_conflict: "skip" })
91
91
  def batch(params)
92
92
  audience_id = Client.opt(params, :audience_id)
93
+ # "" is truthy in Ruby but names no audience — treat it as absent and
94
+ # take the flat route, matching the other MailBlastr SDKs.
95
+ audience_id = nil if audience_id == ""
93
96
  query = {}
94
97
  on_conflict = Client.opt(params, :on_conflict)
95
98
  query[:on_conflict] = on_conflict if on_conflict
@@ -3,6 +3,11 @@
3
3
  module Mailblastr
4
4
  module Domains
5
5
  class << self
6
+ # HTTPS readiness; an unavailable host schedules server-side repair.
7
+ def tracking_health(domain_id)
8
+ Client.request(:get, "/domains/#{Client.path_escape(domain_id)}/tracking-health")
9
+ end
10
+
6
11
  # Register a sending domain. POST /domains
7
12
  def create(params)
8
13
  Client.request(:post, "/domains", body: params)
@@ -27,10 +27,16 @@ module Mailblastr
27
27
 
28
28
  # List sent emails (trimmed list items) — cursor pagination plus optional
29
29
  # server-side filters: `campaign_id`, `automation_id`, `source`
30
- # ("individual"), `domain_id`, `status` (matched case-insensitively
31
- # against the row's `last_event`) and `search` (recipients, subject and
32
- # sender). `q` is the server's alias for `search`, honoured only when
33
- # `search` is absent. GET /emails
30
+ # ("individual" for all one-off sends with no campaign/automation origin,
31
+ # "api" for one-off sends made with an API key — which also covers mail
32
+ # sent before the send origin was recorded or "dashboard" for one-off
33
+ # mail composed in the dashboard: the composer and inbox replies/forwards;
34
+ # honoured only when neither `campaign_id` nor `automation_id` is
35
+ # supplied), `domain_id`, `status` (matched case-insensitively
36
+ # against the row's `last_event`), `folder` (one of "outbox", "sent",
37
+ # "scheduled" or "failed" — any other value is rejected with a 422) and
38
+ # `search` (recipients, subject and sender). `q` is the server's alias
39
+ # for `search`, honoured only when `search` is absent. GET /emails
34
40
  def list(params = {})
35
41
  query = Client.pagination(params).merge(
36
42
  Client.filters(params, :campaign_id, :automation_id, :source, :domain_id, :status, :search, :q, :folder)
@@ -38,8 +44,11 @@ module Mailblastr
38
44
  Client.request(:get, "/emails", query: query)
39
45
  end
40
46
 
41
- # Per-source send metrics, one row per campaign / automation / individual
42
- # origin. Not paginated. GET /emails/sources
47
+ # Per-source send metrics, one row per origin. `kind` is "campaign",
48
+ # "automation", "api" (one-off API-key sends, including mail sent before
49
+ # the send origin was recorded) or "individual" (dashboard-composed
50
+ # one-offs); `id`, `name`, `subject` and `status` are null for both the
51
+ # "api" and "individual" rows. Not paginated. GET /emails/sources
43
52
  def sources
44
53
  Client.request(:get, "/emails/sources")
45
54
  end
@@ -125,14 +134,14 @@ module Mailblastr
125
134
 
126
135
  # Forward a received email. POST /emails/receiving/:id/forward
127
136
  # Receiving.forward(id, { from: "you@yourdomain.com", to: "team@you.com" })
128
- def forward(email_id, params)
129
- Client.request(:post, "/emails/receiving/#{Client.path_escape(email_id)}/forward", body: params)
137
+ def forward(email_id, params, options = {})
138
+ Client.request(:post, "/emails/receiving/#{Client.path_escape(email_id)}/forward", body: params, options: options)
130
139
  end
131
140
 
132
141
  # Reply to a received email's sender, threaded into the conversation.
133
142
  # POST /emails/receiving/:id/reply
134
- def reply(email_id, params)
135
- Client.request(:post, "/emails/receiving/#{Client.path_escape(email_id)}/reply", body: params)
143
+ def reply(email_id, params, options = {})
144
+ Client.request(:post, "/emails/receiving/#{Client.path_escape(email_id)}/reply", body: params, options: options)
136
145
  end
137
146
 
138
147
  # Delete a received email. DELETE /emails/receiving/:id
@@ -50,6 +50,24 @@ module Mailblastr
50
50
  @error_name
51
51
  end
52
52
 
53
+ # Original email for a failed or unconfirmed send; inspect before resending.
54
+ def id
55
+ value = @body["id"]
56
+ value.is_a?(String) ? value : nil
57
+ end
58
+
59
+ # Reserved prefix, including uncertain sends. Never automatically resend it.
60
+ def reserved
61
+ value = @body["reserved"]
62
+ value.is_a?(Array) ? value : nil
63
+ end
64
+
65
+ # The never-attempted tail; distinct from uncertain reserved items.
66
+ def unsent_count
67
+ value = @body["unsent_count"]
68
+ value.is_a?(Integer) ? value : nil
69
+ end
70
+
53
71
  # The plan/quota cap this request hit, else nil. Carried by
54
72
  # plan_limit_reached, every *_quota_exceeded, contact_limit_reached and
55
73
  # ai_credits_exceeded — it says WHICH quota ran out, how much of it was
@@ -11,7 +11,7 @@ module Mailblastr
11
11
  # Identify the contact by `contact_id` OR `email`. Event names cannot
12
12
  # start with the reserved "mailblastr:" prefix.
13
13
  #
14
- # NOTE: only POST /emails and POST /emails/batch honour `Idempotency-Key`.
14
+ # NOTE: only POST /emails, POST /emails/batch, and received-email reply/forward honour `Idempotency-Key`.
15
15
  # An `idempotency_key` passed here is still forwarded, but the server
16
16
  # ignores it, so a retry ingests a SECOND event and can enroll the contact
17
17
  # twice — de-duplicate on your side instead.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailblastr
4
- VERSION = "5.1.0"
4
+ VERSION = "5.2.0"
5
5
  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: 5.1.0
4
+ version: 5.2.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-29 00:00:00.000000000 Z
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest