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.
@@ -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