mailblastr 1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +332 -0
- data/lib/mailblastr/api_keys.rb +23 -0
- data/lib/mailblastr/audiences.rb +39 -0
- data/lib/mailblastr/automations.rb +62 -0
- data/lib/mailblastr/campaigns.rb +57 -0
- data/lib/mailblastr/client.rb +208 -0
- data/lib/mailblastr/contact_properties.rb +33 -0
- data/lib/mailblastr/contacts.rb +144 -0
- data/lib/mailblastr/domains.rb +78 -0
- data/lib/mailblastr/emails.rb +113 -0
- data/lib/mailblastr/error.rb +28 -0
- data/lib/mailblastr/events.rb +35 -0
- data/lib/mailblastr/logs.rb +24 -0
- data/lib/mailblastr/polls.rb +18 -0
- data/lib/mailblastr/segments.rb +44 -0
- data/lib/mailblastr/templates.rb +42 -0
- data/lib/mailblastr/topics.rb +38 -0
- data/lib/mailblastr/version.rb +5 -0
- data/lib/mailblastr/webhooks.rb +130 -0
- data/lib/mailblastr.rb +76 -0
- metadata +83 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "cgi"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Mailblastr
|
|
10
|
+
# Internal HTTP layer. Every resource funnels through Client.request;
|
|
11
|
+
# tests stub Client.deliver to avoid the network.
|
|
12
|
+
module Client
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
VERBS = {
|
|
16
|
+
get: Net::HTTP::Get,
|
|
17
|
+
post: Net::HTTP::Post,
|
|
18
|
+
patch: Net::HTTP::Patch,
|
|
19
|
+
delete: Net::HTTP::Delete
|
|
20
|
+
}.freeze
|
|
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.
|
|
25
|
+
RETRYABLE_STATUSES = [429, 503].freeze
|
|
26
|
+
|
|
27
|
+
# Upper bound (seconds) on any single backoff wait.
|
|
28
|
+
MAX_BACKOFF_SECONDS = 30.0
|
|
29
|
+
|
|
30
|
+
# Perform an API request and return the parsed JSON body (a Hash/Array),
|
|
31
|
+
# or the raw body String when `raw: true` (binary download endpoints).
|
|
32
|
+
# Raises Mailblastr::Error on any non-2xx response.
|
|
33
|
+
def request(verb, path, body: nil, query: nil, options: {}, raw: false)
|
|
34
|
+
key = Mailblastr.api_key
|
|
35
|
+
if key.nil? || key.to_s.strip.empty?
|
|
36
|
+
raise Mailblastr::Error.new(
|
|
37
|
+
'Mailblastr.api_key is not set. Configure it first: Mailblastr.api_key = "mb_xxxxxxxxx"',
|
|
38
|
+
error_name: "missing_api_key"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
uri = URI.parse("#{Mailblastr.base_url.to_s.sub(%r{/+\z}, '')}#{path}")
|
|
43
|
+
if query && !query.empty?
|
|
44
|
+
uri.query = [uri.query, URI.encode_www_form(query)].compact.reject(&:empty?).join("&")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
req = build_request(verb, uri, body, options, key)
|
|
48
|
+
handle_response(deliver_with_retries(req, uri), raw: raw)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The single request chokepoint: both the JSON and raw/binary paths funnel
|
|
52
|
+
# through here, so both get the timeout (applied inside deliver) and the
|
|
53
|
+
# bounded 429/503 retry loop. Non-retryable responses return immediately;
|
|
54
|
+
# network/timeout errors are NOT caught here (they propagate as before).
|
|
55
|
+
def deliver_with_retries(req, uri)
|
|
56
|
+
max = Mailblastr.max_retries.to_i
|
|
57
|
+
attempt = 0
|
|
58
|
+
loop do
|
|
59
|
+
resp = deliver(req, uri)
|
|
60
|
+
code = resp.code.to_i
|
|
61
|
+
return resp unless RETRYABLE_STATUSES.include?(code) && attempt < max
|
|
62
|
+
|
|
63
|
+
wait = retry_after_seconds(response_header(resp, "Retry-After"))
|
|
64
|
+
wait ||= [MAX_BACKOFF_SECONDS, 0.5 * (2**attempt)].min
|
|
65
|
+
backoff_sleep(wait) if wait.positive?
|
|
66
|
+
attempt += 1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wraps Kernel#sleep so tests can stub out the wait. Extracted so the
|
|
71
|
+
# retry loop stays deterministic under test.
|
|
72
|
+
def backoff_sleep(seconds)
|
|
73
|
+
sleep(seconds)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Read a response header without assuming the response object shape
|
|
77
|
+
# (Net::HTTPResponse supports #[]; test doubles may not).
|
|
78
|
+
def response_header(resp, name)
|
|
79
|
+
return nil unless resp.respond_to?(:[])
|
|
80
|
+
|
|
81
|
+
resp[name]
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Parse a Retry-After header into a number of seconds to wait:
|
|
87
|
+
# a numeric delta-seconds value, or an HTTP-date to wait until.
|
|
88
|
+
# Negative is treated as 0, and the wait is capped at 30 seconds.
|
|
89
|
+
# Returns nil when the header is absent or unparseable.
|
|
90
|
+
def retry_after_seconds(value)
|
|
91
|
+
return nil if value.nil?
|
|
92
|
+
|
|
93
|
+
str = value.to_s.strip
|
|
94
|
+
return nil if str.empty?
|
|
95
|
+
|
|
96
|
+
seconds =
|
|
97
|
+
begin
|
|
98
|
+
Float(str)
|
|
99
|
+
rescue ArgumentError, TypeError
|
|
100
|
+
begin
|
|
101
|
+
Time.httpdate(str) - Time.now
|
|
102
|
+
rescue ArgumentError
|
|
103
|
+
return nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
seconds = 0.0 if seconds.negative?
|
|
108
|
+
[seconds.to_f, MAX_BACKOFF_SECONDS].min
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_request(verb, uri, body, options, key)
|
|
112
|
+
klass = VERBS.fetch(verb) { raise ArgumentError, "unsupported HTTP verb: #{verb.inspect}" }
|
|
113
|
+
req = klass.new(uri)
|
|
114
|
+
req["Authorization"] = "Bearer #{key}"
|
|
115
|
+
req["User-Agent"] = "mailblastr-ruby/#{Mailblastr::VERSION}"
|
|
116
|
+
req["Accept"] = "application/json"
|
|
117
|
+
idem = opt(options, :idempotency_key)
|
|
118
|
+
req["Idempotency-Key"] = idem if idem
|
|
119
|
+
unless body.nil?
|
|
120
|
+
req["Content-Type"] = "application/json"
|
|
121
|
+
req.body = JSON.generate(body)
|
|
122
|
+
end
|
|
123
|
+
req
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The single seam that touches the network (stub me in tests).
|
|
127
|
+
# Applies the configured open/read timeout; 0 or nil means "no timeout".
|
|
128
|
+
def deliver(req, uri)
|
|
129
|
+
opts = { use_ssl: uri.scheme == "https" }
|
|
130
|
+
t = Mailblastr.timeout
|
|
131
|
+
if !t.nil? && t.to_f > 0
|
|
132
|
+
opts[:open_timeout] = t
|
|
133
|
+
opts[:read_timeout] = t
|
|
134
|
+
end
|
|
135
|
+
Net::HTTP.start(uri.host, uri.port, **opts) do |http|
|
|
136
|
+
http.request(req)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def handle_response(resp, raw: false)
|
|
141
|
+
code = resp.code.to_i
|
|
142
|
+
body = resp.body
|
|
143
|
+
|
|
144
|
+
if code >= 200 && code < 300
|
|
145
|
+
return body if raw
|
|
146
|
+
return nil if body.nil? || body.empty?
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
JSON.parse(body)
|
|
150
|
+
rescue JSON::ParserError
|
|
151
|
+
body
|
|
152
|
+
end
|
|
153
|
+
else
|
|
154
|
+
parsed = begin
|
|
155
|
+
JSON.parse(body.to_s)
|
|
156
|
+
rescue JSON::ParserError, TypeError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
160
|
+
raise Mailblastr::Error.new(
|
|
161
|
+
parsed["message"] || "Request failed with status #{code}",
|
|
162
|
+
status_code: parsed["statusCode"] || code,
|
|
163
|
+
error_name: parsed["name"] || "application_error"
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Percent-encode one path segment so an id like "../api-keys" cannot
|
|
169
|
+
# traverse the URL path (spaces become %20, "/" becomes %2F).
|
|
170
|
+
def path_escape(value)
|
|
171
|
+
CGI.escape(value.to_s).gsub("+", "%20")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Read a hash param by symbol or string key.
|
|
175
|
+
def opt(params, key)
|
|
176
|
+
return nil unless params.is_a?(Hash)
|
|
177
|
+
|
|
178
|
+
params.key?(key) ? params[key] : params[key.to_s]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A copy of `params` without the given keys (symbol or string forms).
|
|
182
|
+
def without(params, *keys)
|
|
183
|
+
strs = keys.map(&:to_s)
|
|
184
|
+
params.reject { |k, _| strs.include?(k.to_s) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Extract the cursor-pagination params ({ limit, after, before }).
|
|
188
|
+
def pagination(params)
|
|
189
|
+
q = {}
|
|
190
|
+
%i[limit after before].each do |k|
|
|
191
|
+
v = opt(params, k)
|
|
192
|
+
q[k] = v unless v.nil?
|
|
193
|
+
end
|
|
194
|
+
q
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Domain-first guard: several resources require the sending domain.
|
|
198
|
+
def require_domain!(params, context)
|
|
199
|
+
v = opt(params, :domain)
|
|
200
|
+
if v.nil? || v.to_s.strip.empty?
|
|
201
|
+
raise ArgumentError,
|
|
202
|
+
"#{context} requires `domain` — the sending domain whose contact pool it targets, " \
|
|
203
|
+
'e.g. { domain: "yourdomain.com", ... }'
|
|
204
|
+
end
|
|
205
|
+
v
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailblastr
|
|
4
|
+
# Custom contact fields, usable as {{merge_tags}}.
|
|
5
|
+
module ContactProperties
|
|
6
|
+
class << self
|
|
7
|
+
# POST /contact-properties — params: { key:, type: "string"|"number", fallback_value: }
|
|
8
|
+
def create(params)
|
|
9
|
+
Client.request(:post, "/contact-properties", body: params)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# GET /contact-properties/:id
|
|
13
|
+
def get(property_id)
|
|
14
|
+
Client.request(:get, "/contact-properties/#{Client.path_escape(property_id)}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# GET /contact-properties
|
|
18
|
+
def list(params = {})
|
|
19
|
+
Client.request(:get, "/contact-properties", query: Client.pagination(params))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# PATCH /contact-properties/:id — only fallback_value is mutable.
|
|
23
|
+
def update(property_id, params)
|
|
24
|
+
Client.request(:patch, "/contact-properties/#{Client.path_escape(property_id)}", body: params)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# DELETE /contact-properties/:id
|
|
28
|
+
def delete(property_id)
|
|
29
|
+
Client.request(:delete, "/contact-properties/#{Client.path_escape(property_id)}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailblastr
|
|
4
|
+
# Contacts are DOMAIN-FIRST: each sending domain has its own contact pool,
|
|
5
|
+
# so the flat /contacts API takes `domain` (required on create/list). The
|
|
6
|
+
# nested audience variants (`audience_id:`) derive the pool from the path.
|
|
7
|
+
module Contacts
|
|
8
|
+
class << self
|
|
9
|
+
# Create a contact. POST /contacts (flat, `domain` required) or
|
|
10
|
+
# POST /audiences/:id/contacts when `audience_id` is given.
|
|
11
|
+
# Mailblastr::Contacts.create({ domain: "yourdomain.com", email: "a@b.com" })
|
|
12
|
+
# Mailblastr::Contacts.create({ audience_id: "aud_1", email: "a@b.com" })
|
|
13
|
+
def create(params)
|
|
14
|
+
audience_id = Client.opt(params, :audience_id)
|
|
15
|
+
if audience_id
|
|
16
|
+
body = Client.without(params, :audience_id, :domain)
|
|
17
|
+
Client.request(:post, "/audiences/#{Client.path_escape(audience_id)}/contacts", body: body)
|
|
18
|
+
else
|
|
19
|
+
Client.require_domain!(params, "Contacts.create (flat /contacts API)")
|
|
20
|
+
Client.request(:post, "/contacts", body: Client.without(params, :audience_id))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Retrieve a contact by id (exact) or by email. An email can exist in
|
|
25
|
+
# several domains' pools, so pass `domain` to pick the pool.
|
|
26
|
+
# Contacts.get({ id: "cont_1" })
|
|
27
|
+
# Contacts.get({ id: "a@b.com", domain: "yourdomain.com" })
|
|
28
|
+
# Contacts.get({ id: "cont_1", audience_id: "aud_1" })
|
|
29
|
+
def get(params)
|
|
30
|
+
id = Client.path_escape(Client.opt(params, :id))
|
|
31
|
+
audience_id = Client.opt(params, :audience_id)
|
|
32
|
+
return Client.request(:get, "/audiences/#{Client.path_escape(audience_id)}/contacts/#{id}") if audience_id
|
|
33
|
+
|
|
34
|
+
domain = Client.opt(params, :domain)
|
|
35
|
+
query = domain ? { domain: domain } : nil
|
|
36
|
+
Client.request(:get, "/contacts/#{id}", query: query)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# List contacts. Flat /contacts requires `domain` (names the pool);
|
|
40
|
+
# pass `audience_id` instead to use the nested API. `segment_id`
|
|
41
|
+
# filters either variant; limit/after/before paginate.
|
|
42
|
+
def list(params = {})
|
|
43
|
+
audience_id = Client.opt(params, :audience_id)
|
|
44
|
+
query = Client.pagination(params)
|
|
45
|
+
segment_id = Client.opt(params, :segment_id)
|
|
46
|
+
query[:segment_id] = segment_id if segment_id
|
|
47
|
+
|
|
48
|
+
if audience_id
|
|
49
|
+
Client.request(:get, "/audiences/#{Client.path_escape(audience_id)}/contacts", query: query)
|
|
50
|
+
else
|
|
51
|
+
query = { domain: Client.require_domain!(params, "Contacts.list (flat /contacts API)") }.merge(query)
|
|
52
|
+
Client.request(:get, "/contacts", query: query)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Update a contact (id or email). PATCH /contacts/:id, or the nested
|
|
57
|
+
# route when `audience_id` is given. On the flat API pass `domain` when
|
|
58
|
+
# `id` is an email (disambiguates across pools).
|
|
59
|
+
# Contacts.update({ id: "cont_1", unsubscribed: true })
|
|
60
|
+
def update(params)
|
|
61
|
+
id = Client.path_escape(Client.opt(params, :id))
|
|
62
|
+
audience_id = Client.opt(params, :audience_id)
|
|
63
|
+
if audience_id
|
|
64
|
+
body = Client.without(params, :audience_id, :domain, :id)
|
|
65
|
+
Client.request(:patch, "/audiences/#{Client.path_escape(audience_id)}/contacts/#{id}", body: body)
|
|
66
|
+
else
|
|
67
|
+
Client.request(:patch, "/contacts/#{id}", body: Client.without(params, :audience_id, :id))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Delete a contact. DELETE /contacts/:id (pass `domain` when `id` is an
|
|
72
|
+
# email), or the nested route when `audience_id` is given.
|
|
73
|
+
def delete(params)
|
|
74
|
+
id = Client.path_escape(Client.opt(params, :id))
|
|
75
|
+
audience_id = Client.opt(params, :audience_id)
|
|
76
|
+
return Client.request(:delete, "/audiences/#{Client.path_escape(audience_id)}/contacts/#{id}") if audience_id
|
|
77
|
+
|
|
78
|
+
domain = Client.opt(params, :domain)
|
|
79
|
+
query = domain ? { domain: domain } : nil
|
|
80
|
+
Client.request(:delete, "/contacts/#{id}", query: query)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Bulk-import contacts from an array (upsert by email; max 10,000).
|
|
84
|
+
# POST /audiences/:id/contacts/batch
|
|
85
|
+
# Contacts.batch({ audience_id: "aud_1", contacts: [{ email: "a@b.com" }], on_conflict: "skip" })
|
|
86
|
+
def batch(params)
|
|
87
|
+
audience_id = Client.opt(params, :audience_id)
|
|
88
|
+
query = {}
|
|
89
|
+
on_conflict = Client.opt(params, :on_conflict)
|
|
90
|
+
query[:on_conflict] = on_conflict if on_conflict
|
|
91
|
+
Client.request(
|
|
92
|
+
:post,
|
|
93
|
+
"/audiences/#{Client.path_escape(audience_id)}/contacts/batch",
|
|
94
|
+
body: { contacts: Client.opt(params, :contacts) },
|
|
95
|
+
query: query
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Bulk-import contacts from CSV text (header row optional; upsert by
|
|
100
|
+
# email). Non-builtin columns auto-register as custom properties unless
|
|
101
|
+
# `create_properties: false`. POST /audiences/:id/contacts/import
|
|
102
|
+
# Contacts.import({ audience_id: "aud_1", csv: "email\na@b.com" })
|
|
103
|
+
def import(params)
|
|
104
|
+
audience_id = Client.opt(params, :audience_id)
|
|
105
|
+
query = {}
|
|
106
|
+
on_conflict = Client.opt(params, :on_conflict)
|
|
107
|
+
query[:on_conflict] = on_conflict if on_conflict
|
|
108
|
+
query[:create_properties] = "false" if Client.opt(params, :create_properties) == false
|
|
109
|
+
Client.request(
|
|
110
|
+
:post,
|
|
111
|
+
"/audiences/#{Client.path_escape(audience_id)}/contacts/import",
|
|
112
|
+
body: { csv: Client.opt(params, :csv) },
|
|
113
|
+
query: query
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Add a contact to a segment. POST /contacts/:id/segments/:segment_id
|
|
118
|
+
def add_to_segment(contact_id, segment_id)
|
|
119
|
+
Client.request(:post, "/contacts/#{Client.path_escape(contact_id)}/segments/#{Client.path_escape(segment_id)}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Remove a contact from a segment. DELETE /contacts/:id/segments/:segment_id
|
|
123
|
+
def remove_from_segment(contact_id, segment_id)
|
|
124
|
+
Client.request(:delete, "/contacts/#{Client.path_escape(contact_id)}/segments/#{Client.path_escape(segment_id)}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# List the segments a contact belongs to. GET /contacts/:id/segments
|
|
128
|
+
def list_segments(contact_id)
|
|
129
|
+
Client.request(:get, "/contacts/#{Client.path_escape(contact_id)}/segments")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get a contact's topic subscriptions. GET /contacts/:id/topics
|
|
133
|
+
def get_topics(contact_id)
|
|
134
|
+
Client.request(:get, "/contacts/#{Client.path_escape(contact_id)}/topics")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Update a contact's topic subscriptions. PATCH /contacts/:id/topics
|
|
138
|
+
# Contacts.update_topics("cont_1", { topics: [{ id: "top_1", subscription: "opt_in" }] })
|
|
139
|
+
def update_topics(contact_id, params)
|
|
140
|
+
Client.request(:patch, "/contacts/#{Client.path_escape(contact_id)}/topics", body: params)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailblastr
|
|
4
|
+
module Domains
|
|
5
|
+
class << self
|
|
6
|
+
# Register a sending domain. POST /domains
|
|
7
|
+
def create(params)
|
|
8
|
+
Client.request(:post, "/domains", body: params)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# GET /domains/:id
|
|
12
|
+
def get(domain_id)
|
|
13
|
+
Client.request(:get, "/domains/#{Client.path_escape(domain_id)}")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# GET /domains
|
|
17
|
+
def list(params = {})
|
|
18
|
+
Client.request(:get, "/domains", query: Client.pagination(params))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# PATCH /domains/:id (returns the slim ack { object: "domain", id }).
|
|
22
|
+
def update(domain_id, params)
|
|
23
|
+
Client.request(:patch, "/domains/#{Client.path_escape(domain_id)}", body: params)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Trigger DNS verification. POST /domains/:id/verify
|
|
27
|
+
def verify(domain_id)
|
|
28
|
+
Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/verify")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Claim a domain already verified in another account. POST /domains/claim
|
|
32
|
+
def claim(params)
|
|
33
|
+
Client.request(:post, "/domains/claim", body: params)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Retrieve a domain's claim record. GET /domains/:id/claim
|
|
37
|
+
def get_claim(domain_id)
|
|
38
|
+
Client.request(:get, "/domains/#{Client.path_escape(domain_id)}/claim")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Verify a domain claim's TXT record. POST /domains/:id/claim/verify
|
|
42
|
+
def verify_claim(domain_id)
|
|
43
|
+
Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/claim/verify")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Detect the DNS provider and available one-click apply methods.
|
|
47
|
+
# GET /domains/:id/dns/detect
|
|
48
|
+
def detect_dns(domain_id)
|
|
49
|
+
Client.request(:get, "/domains/#{Client.path_escape(domain_id)}/dns/detect")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Apply DNS records via the Cloudflare API, then auto-verify.
|
|
53
|
+
# POST /domains/:id/dns/cloudflare — params: { token: "..." }
|
|
54
|
+
def apply_cloudflare_dns(domain_id, params)
|
|
55
|
+
Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/dns/cloudflare", body: params)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Apply DNS records via the GoDaddy API, then auto-verify.
|
|
59
|
+
# POST /domains/:id/dns/godaddy — params: { key: "...", secret: "..." }
|
|
60
|
+
def apply_godaddy_dns(domain_id, params)
|
|
61
|
+
Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/dns/godaddy", body: params)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Apply DNS records via the Namecheap API (existing records preserved),
|
|
65
|
+
# then auto-verify. POST /domains/:id/dns/namecheap
|
|
66
|
+
# params: { api_user: "...", api_key: "...", user_name: "..." } — note the
|
|
67
|
+
# backend expects camelCase keys (apiUser/apiKey/userName), so pass those.
|
|
68
|
+
def apply_namecheap_dns(domain_id, params)
|
|
69
|
+
Client.request(:post, "/domains/#{Client.path_escape(domain_id)}/dns/namecheap", body: params)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# DELETE /domains/:id
|
|
73
|
+
def delete(domain_id)
|
|
74
|
+
Client.request(:delete, "/domains/#{Client.path_escape(domain_id)}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailblastr
|
|
4
|
+
# Send and inspect emails. Inbound mail lives under Mailblastr::Emails::Receiving.
|
|
5
|
+
module Emails
|
|
6
|
+
class << self
|
|
7
|
+
# Send a single email. POST /emails
|
|
8
|
+
# Mailblastr::Emails.send({ from: "...", to: ["..."], subject: "...", html: "..." })
|
|
9
|
+
# Pass { idempotency_key: "order-123" } as the second argument to retry safely.
|
|
10
|
+
def send(params, options = {})
|
|
11
|
+
Client.request(:post, "/emails", body: params, options: options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Send up to 100 emails in one request. POST /emails/batch (alias of Batch.send).
|
|
15
|
+
def batch(payloads, options = {})
|
|
16
|
+
Client.request(:post, "/emails/batch", body: payloads, options: options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# List sent emails (trimmed list items). GET /emails
|
|
20
|
+
def list(params = {})
|
|
21
|
+
Client.request(:get, "/emails", query: Client.pagination(params))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Retrieve a sent email and its events. GET /emails/:id
|
|
25
|
+
def get(email_id)
|
|
26
|
+
Client.request(:get, "/emails/#{Client.path_escape(email_id)}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# List a sent email's attachments. GET /emails/:id/attachments
|
|
30
|
+
def list_attachments(email_id)
|
|
31
|
+
Client.request(:get, "/emails/#{Client.path_escape(email_id)}/attachments")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Retrieve one attachment's metadata. GET /emails/:id/attachments/:attachment_id
|
|
35
|
+
def get_attachment(email_id, attachment_id)
|
|
36
|
+
Client.request(:get, "/emails/#{Client.path_escape(email_id)}/attachments/#{Client.path_escape(attachment_id)}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Reschedule a scheduled email. PATCH /emails/:id
|
|
40
|
+
# Mailblastr::Emails.update("email_id", { scheduled_at: "2026-08-01T09:00:00Z" })
|
|
41
|
+
def update(email_id, params)
|
|
42
|
+
Client.request(:patch, "/emails/#{Client.path_escape(email_id)}", body: params)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Cancel a scheduled email. POST /emails/:id/cancel
|
|
46
|
+
def cancel(email_id)
|
|
47
|
+
Client.request(:post, "/emails/#{Client.path_escape(email_id)}/cancel")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Inbound (received) email.
|
|
52
|
+
module Receiving
|
|
53
|
+
class << self
|
|
54
|
+
# List received emails. GET /emails/receiving
|
|
55
|
+
def list(params = {})
|
|
56
|
+
Client.request(:get, "/emails/receiving", query: Client.pagination(params))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Retrieve a received email. GET /emails/receiving/:id
|
|
60
|
+
def get(email_id)
|
|
61
|
+
Client.request(:get, "/emails/receiving/#{Client.path_escape(email_id)}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# List a received email's attachments. GET /emails/receiving/:id/attachments
|
|
65
|
+
def list_attachments(email_id)
|
|
66
|
+
Client.request(:get, "/emails/receiving/#{Client.path_escape(email_id)}/attachments")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Download one attachment as raw bytes (binary String).
|
|
70
|
+
# GET /emails/receiving/:id/attachments/:attachment_id
|
|
71
|
+
def get_attachment(email_id, attachment_id)
|
|
72
|
+
Client.request(
|
|
73
|
+
:get,
|
|
74
|
+
"/emails/receiving/#{Client.path_escape(email_id)}/attachments/#{Client.path_escape(attachment_id)}",
|
|
75
|
+
raw: true
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Download the original RFC822/MIME message as raw bytes (binary String).
|
|
80
|
+
# GET /emails/receiving/:id/raw
|
|
81
|
+
def raw(email_id)
|
|
82
|
+
Client.request(:get, "/emails/receiving/#{Client.path_escape(email_id)}/raw", raw: true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Forward a received email. POST /emails/receiving/:id/forward
|
|
86
|
+
# Receiving.forward(id, { from: "you@yourdomain.com", to: "team@you.com" })
|
|
87
|
+
def forward(email_id, params)
|
|
88
|
+
Client.request(:post, "/emails/receiving/#{Client.path_escape(email_id)}/forward", body: params)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Reply to a received email's sender, threaded into the conversation.
|
|
92
|
+
# POST /emails/receiving/:id/reply
|
|
93
|
+
def reply(email_id, params)
|
|
94
|
+
Client.request(:post, "/emails/receiving/#{Client.path_escape(email_id)}/reply", body: params)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Delete a received email. DELETE /emails/receiving/:id
|
|
98
|
+
def delete(email_id)
|
|
99
|
+
Client.request(:delete, "/emails/receiving/#{Client.path_escape(email_id)}")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Batch send — Mailblastr::Batch.send([...]). POST /emails/batch
|
|
106
|
+
module Batch
|
|
107
|
+
class << self
|
|
108
|
+
def send(payloads, options = {})
|
|
109
|
+
Client.request(:post, "/emails/batch", body: payloads, options: options)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailblastr
|
|
4
|
+
# Raised for every non-2xx API response. Mirrors the API error shape
|
|
5
|
+
# { statusCode, name, message }:
|
|
6
|
+
#
|
|
7
|
+
# begin
|
|
8
|
+
# Mailblastr::Emails.send(params)
|
|
9
|
+
# rescue Mailblastr::Error => e
|
|
10
|
+
# e.status_code # => 422
|
|
11
|
+
# e.name # => "validation_error"
|
|
12
|
+
# e.message # => "The `from` address must use a verified domain."
|
|
13
|
+
# end
|
|
14
|
+
class Error < StandardError
|
|
15
|
+
attr_reader :status_code, :error_name
|
|
16
|
+
|
|
17
|
+
def initialize(message = nil, status_code: nil, error_name: nil)
|
|
18
|
+
super(message)
|
|
19
|
+
@status_code = status_code
|
|
20
|
+
@error_name = error_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The API error `name` (e.g. "validation_error", "not_found").
|
|
24
|
+
def name
|
|
25
|
+
@error_name
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailblastr
|
|
4
|
+
# Custom events that automations trigger on. Events are DOMAIN-FIRST:
|
|
5
|
+
# `domain` is required on send, and only that domain's automations fire.
|
|
6
|
+
module Events
|
|
7
|
+
class << self
|
|
8
|
+
# Send a custom event. POST /events/send
|
|
9
|
+
# Mailblastr::Events.send({ event: "signup.completed", domain: "yourdomain.com",
|
|
10
|
+
# email: "user@example.com", payload: { plan: "pro" } })
|
|
11
|
+
# Identify the contact by `contact_id` OR `email`. Pass
|
|
12
|
+
# { idempotency_key: "..." } as the second argument to retry safely.
|
|
13
|
+
def send(params, options = {})
|
|
14
|
+
Client.require_domain!(params, "Events.send")
|
|
15
|
+
Client.request(:post, "/events/send", body: params, options: options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Create a custom-event definition (name + optional payload schema). POST /events
|
|
19
|
+
# Mailblastr::Events.create({ name: "signup.completed", schema: { plan: "string" } })
|
|
20
|
+
def create(params, options = {})
|
|
21
|
+
Client.request(:post, "/events", body: params, options: options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# List custom-event definitions. GET /events
|
|
25
|
+
def list(params = {})
|
|
26
|
+
Client.request(:get, "/events", query: Client.pagination(params))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Delete a custom-event definition. DELETE /events/:id
|
|
30
|
+
def delete(event_id)
|
|
31
|
+
Client.request(:delete, "/events/#{Client.path_escape(event_id)}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailblastr
|
|
4
|
+
# API request logs.
|
|
5
|
+
module Logs
|
|
6
|
+
class << self
|
|
7
|
+
# List logs — cursor pagination plus optional server-side `method`
|
|
8
|
+
# (e.g. "POST") and `status` (e.g. 429) filters. GET /logs
|
|
9
|
+
def list(params = {})
|
|
10
|
+
query = Client.pagination(params)
|
|
11
|
+
method = Client.opt(params, :method)
|
|
12
|
+
status = Client.opt(params, :status)
|
|
13
|
+
query[:method] = method if method
|
|
14
|
+
query[:status] = status unless status.nil?
|
|
15
|
+
Client.request(:get, "/logs", query: query)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Retrieve one log with request/response bodies. GET /logs/:id
|
|
19
|
+
def get(log_id)
|
|
20
|
+
Client.request(:get, "/logs/#{Client.path_escape(log_id)}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailblastr
|
|
4
|
+
# Read-only results of the in-email poll widget.
|
|
5
|
+
module Polls
|
|
6
|
+
class << self
|
|
7
|
+
# One summary row per email that has poll responses. GET /polls
|
|
8
|
+
def list(params = {})
|
|
9
|
+
Client.request(:get, "/polls", query: Client.pagination(params))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The aggregated answer breakdown for one email. GET /polls/:email_id
|
|
13
|
+
def get(email_id)
|
|
14
|
+
Client.request(:get, "/polls/#{Client.path_escape(email_id)}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|