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 +4 -4
- data/README.md +48 -1
- data/lib/mailblastr/client.rb +25 -6
- data/lib/mailblastr/contacts.rb +3 -0
- data/lib/mailblastr/domains.rb +5 -0
- data/lib/mailblastr/emails.rb +19 -10
- data/lib/mailblastr/error.rb +18 -0
- data/lib/mailblastr/events.rb +1 -1
- data/lib/mailblastr/version.rb +1 -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: 2f6ca9c697ddc14eea6b2e355749bfc000d513224e6afe32e04796b3c1cf954d
|
|
4
|
+
data.tar.gz: d0522dd6dfd919babce3fac4324e1adaafc7a9dff6b801815512a53a3d99147e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
data/lib/mailblastr/client.rb
CHANGED
|
@@ -19,9 +19,8 @@ module Mailblastr
|
|
|
19
19
|
delete: Net::HTTP::Delete
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
-
# Only 429
|
|
23
|
-
#
|
|
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
|
|
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:
|
|
194
|
+
status_code: code,
|
|
176
195
|
error_name: parsed["name"] || "application_error",
|
|
177
196
|
body: parsed
|
|
178
197
|
)
|
data/lib/mailblastr/contacts.rb
CHANGED
|
@@ -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
|
data/lib/mailblastr/domains.rb
CHANGED
|
@@ -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)
|
data/lib/mailblastr/emails.rb
CHANGED
|
@@ -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"
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
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
|
|
42
|
-
#
|
|
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
|
data/lib/mailblastr/error.rb
CHANGED
|
@@ -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
|
data/lib/mailblastr/events.rb
CHANGED
|
@@ -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
|
|
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.
|
data/lib/mailblastr/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-09-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|