mailblastr 5.1.1 → 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: 4827370704c9533943a763c3797f16b472f80655940a911111abff606769fe71
4
- data.tar.gz: 9a924fc0cc8bb4d7d29c78ef6a5b3e1290c82139dee33ea05fe0009b504b2a14
3
+ metadata.gz: 2f6ca9c697ddc14eea6b2e355749bfc000d513224e6afe32e04796b3c1cf954d
4
+ data.tar.gz: d0522dd6dfd919babce3fac4324e1adaafc7a9dff6b801815512a53a3d99147e
5
5
  SHA512:
6
- metadata.gz: 111d3d1c97ef0a2e4cf398b90327b48ac389de59f1dad3b80f8df34e16811f32385c2502c98a8672176339bdd7c5056a3467a8ed5a59f8dc14646803317c0dfc
7
- data.tar.gz: 4ddd8faaba6b557b100c7177f15b9bbcdbf06df0a413088763ad8b174dc6f3fb6f69aac814445a7f8bd9dce1a4f22e2eed2ef324040de2503f63272e48db927b
6
+ metadata.gz: cd2e5834d366d29d01a797ef11db73cc074181084d76be044a2223684d76f0f1935c75df81b449c0f344234a96b9e31d4a941a2e4ff86e4b518c5727c53e7f5e
7
+ data.tar.gz: 977c69e7fcee5e616cc1e55166279d8b784bedeffb45e9d980e288cc6cdbb3a632556ff23a8e73c7412a22e5094413900e1e164cb7a098e0bc6e526a495b88db
data/README.md CHANGED
@@ -408,7 +408,7 @@ The key must be **1–255 characters**, measured after the server trims it — 2
408
408
 
409
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`).
410
410
 
411
- 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.
412
412
 
413
413
  ## Rate limits
414
414
 
@@ -421,3 +421,49 @@ Full docs: <https://www.mailblastr.com/docs>
421
421
  ## License
422
422
 
423
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
  )
@@ -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)
@@ -134,14 +134,14 @@ module Mailblastr
134
134
 
135
135
  # Forward a received email. POST /emails/receiving/:id/forward
136
136
  # Receiving.forward(id, { from: "you@yourdomain.com", to: "team@you.com" })
137
- def forward(email_id, params)
138
- 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)
139
139
  end
140
140
 
141
141
  # Reply to a received email's sender, threaded into the conversation.
142
142
  # POST /emails/receiving/:id/reply
143
- def reply(email_id, params)
144
- 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)
145
145
  end
146
146
 
147
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.1"
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.1
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-30 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