zapsmtp 0.1.0.beta3

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b3eecf99fc7d192df20863a57f9a728064f6b91e815c5a2dfda2ea4da5655116
4
+ data.tar.gz: 246b0b32ffddefa7069851bad0fd9533d872e19e96d6e17a4ce053aade020585
5
+ SHA512:
6
+ metadata.gz: 9b767a2187a512585f43c94be011503b14575ce32a8fba60610abab52c838f1e3103c78d1e3579f970211918b7aee7729082cc041e8126c28876acb9ea9b55c9
7
+ data.tar.gz: e1ed416c5bcaaac0c78a381e6448b51322b0e91f2fa2f0bec40ef38a91a600de3508a3449fdd71224e3b690f583a9f6c4a3108d4cc9ebf5cf04921f258af89ef
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZapSMTP
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,326 @@
1
+ # ZapSMTP Ruby SDK
2
+
3
+ The official Ruby library for the [ZapSMTP](https://zapsmtp.com) email API: send, receive and track
4
+ email, manage templates, domains, webhooks and everything else the API does. It includes an
5
+ ActionMailer delivery method for Rails.
6
+
7
+ - Every API operation, with a reader for every documented response field
8
+ - Safe automatic retries: sends carry an `Idempotency-Key`, so a retry never sends twice
9
+ - Paging over every page of any list, lazily
10
+ - Webhook signature verification, with a Rack middleware and a Rails controller concern
11
+ - `config.action_mailer.delivery_method = :zapsmtp`
12
+ - Ruby 3.0+, no dependencies beyond the standard library
13
+
14
+ ## Install
15
+
16
+ ```ruby
17
+ # Gemfile
18
+ gem "zapsmtp"
19
+ ```
20
+
21
+ ## Send an email
22
+
23
+ ```ruby
24
+ require "zapsmtp"
25
+
26
+ zap = ZapSMTP::Client.new(api_key: "zap_...") # or set ZAP_API_KEY and call ZapSMTP::Client.new
27
+
28
+ email = zap.emails.send(
29
+ from: "Acme <hello@acme.com>",
30
+ to: "jane@example.org",
31
+ subject: "Your receipt",
32
+ html: "<p>Thanks for your order.</p>",
33
+ tags: [{ name: "category", value: "receipt" }],
34
+ metadata: { order_id: "A-1042" }
35
+ )
36
+ email.id # => "019a1f2e-..."
37
+ ```
38
+
39
+ Parameters are keyword arguments, or a Hash, with the API's names (`reply_to`, `scheduled_at`...).
40
+ A `Time` is sent as RFC 3339: `scheduled_at: Time.now + 3600`.
41
+
42
+ With a template:
43
+
44
+ ```ruby
45
+ zap.emails.send(
46
+ from: "Acme <hello@acme.com>",
47
+ to: "jane@example.org",
48
+ template: { id: "receipt", variables: { name: "Jane", total: 42 } }
49
+ )
50
+ ```
51
+
52
+ Attachments:
53
+
54
+ ```ruby
55
+ zap.emails.send(
56
+ from: "Acme <hello@acme.com>",
57
+ to: "jane@example.org",
58
+ subject: "Your invoice",
59
+ html: '<p>Attached. <img src="cid:logo"></p>',
60
+ attachments: [
61
+ ZapSMTP::Attachment.from_file("invoice.pdf"),
62
+ ZapSMTP::Attachment.from_file("logo.png", content_id: "logo"), # inline
63
+ ZapSMTP::Attachment.from_string("report.csv", csv)
64
+ ]
65
+ )
66
+ ```
67
+
68
+ Up to 100 emails at once:
69
+
70
+ ```ruby
71
+ result = zap.emails.send_batch(emails, validation: "permissive")
72
+ result.data # the sent ones
73
+ result.errors # the refused ones, with their index
74
+ ```
75
+
76
+ ## Responses
77
+
78
+ A response is a model with a reader for every documented field. Every field, documented or not,
79
+ can be read with `[]` and `to_h` (keys as symbols), and models work with pattern matching:
80
+
81
+ ```ruby
82
+ email = zap.emails.get(id)
83
+ email.status # => "delivered"
84
+ email[:status] # the same
85
+ email.to_h # => { id: "...", status: "delivered", ... }
86
+
87
+ case email
88
+ in { status: "bounced", to: } then suppress(to)
89
+ end
90
+ ```
91
+
92
+ A field the API adds later is kept, never an error. A field whose name is a Ruby method of every
93
+ object, such as `method` on a log entry, is read with `[]`: `log[:method]`.
94
+
95
+ ## Lists
96
+
97
+ A list call answers one page; `auto_paging_each` walks every page, asking for the next one only when
98
+ the previous is used up.
99
+
100
+ ```ruby
101
+ page = zap.emails.list(limit: 50, status: "bounced")
102
+ page.data # this page's emails
103
+ page.next_page? # makes no request
104
+ page.next_page # the next page, or nil
105
+
106
+ zap.emails.list(status: "bounced").auto_paging_each do |email|
107
+ puts email.id
108
+ end
109
+
110
+ # an Enumerator: stop early without loading more pages
111
+ zap.emails.list.auto_paging_each.lazy.select { |e| e.status == "bounced" }.first(10)
112
+ ```
113
+
114
+ ## Errors
115
+
116
+ Every API error raises a `ZapSMTP::Error` subclass with `status`, `code`, `message`, `type`,
117
+ `request_id`, `headers` and `body`.
118
+
119
+ ```ruby
120
+ begin
121
+ zap.emails.send(**email)
122
+ rescue ZapSMTP::ValidationError => e
123
+ warn "#{e.code}: #{e.message}" # e.g. invalid_parameter
124
+ rescue ZapSMTP::RateLimitError => e
125
+ warn "retry in #{e.retry_after} s"
126
+ end
127
+ ```
128
+
129
+ | Class | When |
130
+ |---|---|
131
+ | `ZapSMTP::AuthenticationError` | 401: missing or invalid key |
132
+ | `ZapSMTP::PermissionError` | 403: the key may not do this, or the domain is not verified |
133
+ | `ZapSMTP::NotFoundError` | 404 |
134
+ | `ZapSMTP::ConflictError` | 409 |
135
+ | `ZapSMTP::ValidationError` | 400, 413, 422: the request is wrong (also an id that is empty, `.` or `..`, refused before sending) |
136
+ | `ZapSMTP::RateLimitError` | 429: `retry_after` says how long to wait |
137
+ | `ZapSMTP::ServerError` | 5xx |
138
+ | `ZapSMTP::ConnectionError` | no answer: network failure or timeout (`cause` holds the underlying error) |
139
+
140
+ ## Retries and timeouts
141
+
142
+ A request that failed on the way (429, 408, 5xx, a lost connection) is tried again twice by default,
143
+ waiting as the API's `Retry-After` says or backing off exponentially. Only requests that are safe to
144
+ repeat are retried: reads, updates by `PUT`, deletes, and sends, which always carry an
145
+ `Idempotency-Key` (pass your own to make retries of your own safe too).
146
+
147
+ ```ruby
148
+ zap = ZapSMTP::Client.new(api_key: "zap_...", max_retries: 4, timeout: 20)
149
+ zap.emails.get(id, request_options: { timeout: 5, max_retries: 0 })
150
+ zap.emails.send(**email, request_options: { idempotency_key: "order-1042-receipt" })
151
+ ```
152
+
153
+ `request_options` takes `idempotency_key`, `headers`, `timeout` and `max_retries`.
154
+
155
+ ## Response headers
156
+
157
+ Every model knows the answer it came from; `with_response` gives the response with any result,
158
+ `nil` ones included:
159
+
160
+ ```ruby
161
+ email = zap.emails.send(**email)
162
+ email.http_response.request_id
163
+ email.http_response["ratelimit-remaining"]
164
+
165
+ result, response = zap.with_response.domains.delete(domain_id)
166
+ response.status # => 204
167
+ ```
168
+
169
+ ## Template assets
170
+
171
+ ```ruby
172
+ zap.template_assets.upload_file("logo.png", name: "Logo") # a path (String or Pathname)
173
+ zap.template_assets.upload_file(File.open("banner.webp")) # or a File, StringIO...
174
+ zap.template_assets.upload(url: "https://acme.com/logo.png") # or a URL
175
+ ```
176
+
177
+ ## Webhooks
178
+
179
+ Check the signature on the raw body, before parsing it. `verify` returns the event, or raises
180
+ `ZapSMTP::WebhookVerificationError` when the signature is missing, wrong or older than five minutes.
181
+ A webhook with batch delivery sends an array of events: use `verify_events`, which returns every
182
+ event (one event as a list of one).
183
+
184
+ ```ruby
185
+ event = ZapSMTP::Webhook.verify(raw_body, headers, ENV["ZAP_WEBHOOK_SECRET"])
186
+ event.type # => "email.bounced"
187
+ event.data # => { email_id: "...", to: "jane@example.org", ... }
188
+
189
+ events = ZapSMTP::Webhook.verify_events(raw_body, headers, ENV["ZAP_WEBHOOK_SECRET"])
190
+ ```
191
+
192
+ `headers` may be a Hash in any case, a Rack env, Rails' `request.headers`, or the `Zap-Signature`
193
+ value itself. `zap.webhooks.verify` and `zap.webhooks.verify_events` do the same.
194
+
195
+ Rails:
196
+
197
+ ```ruby
198
+ class ZapsmtpWebhooksController < ActionController::API
199
+ include ZapSMTP::Rails::WebhookVerification # answers 400 to a request that fails verification
200
+
201
+ def create
202
+ event = zapsmtp_webhook_event # zapsmtp_webhook_events with batch delivery
203
+ Bounce.record!(event.data) if event.type == "email.bounced"
204
+ head :ok
205
+ end
206
+
207
+ private
208
+
209
+ # default: ENV["ZAP_WEBHOOK_SECRET"]
210
+ def zapsmtp_webhook_secret
211
+ Rails.application.credentials.zapsmtp_webhook_secret
212
+ end
213
+ end
214
+ ```
215
+
216
+ In an `ActionController::Base` controller, add `skip_forgery_protection`.
217
+
218
+ Sinatra:
219
+
220
+ ```ruby
221
+ post "/webhooks/zapsmtp" do
222
+ event = ZapSMTP::Webhook.verify(request.body.read, request.env, ENV["ZAP_WEBHOOK_SECRET"])
223
+ # ...
224
+ 200
225
+ rescue ZapSMTP::WebhookVerificationError
226
+ 400
227
+ end
228
+ ```
229
+
230
+ Any Rack app, with the middleware (it answers 400 itself, and passes the events on in
231
+ `env["zapsmtp.events"]`, and the one event of a request that is not a batch in
232
+ `env["zapsmtp.event"]`):
233
+
234
+ ```ruby
235
+ use ZapSMTP::WebhookMiddleware, path: "/webhooks/zapsmtp", secret: ENV["ZAP_WEBHOOK_SECRET"]
236
+ ```
237
+
238
+ ## Rails: the delivery method
239
+
240
+ ```ruby
241
+ # config/environments/production.rb
242
+ config.action_mailer.delivery_method = :zapsmtp
243
+ config.action_mailer.zapsmtp_settings = { api_key: Rails.application.credentials.zapsmtp_api_key }
244
+ ```
245
+
246
+ Mailers stay as they are. From, to, cc, bcc, reply-to, the subject, the HTML and text parts, custom
247
+ headers (`In-Reply-To`, `List-Unsubscribe`, `X-...`) and attachments, inline ones included, go to
248
+ `emails.send`. ZapSMTP's own options go in `zapsmtp:`:
249
+
250
+ ```ruby
251
+ class UserMailer < ApplicationMailer
252
+ def welcome(user)
253
+ attachments.inline["logo.png"] = File.binread("app/assets/images/logo.png")
254
+ mail(
255
+ to: user.email,
256
+ subject: "Welcome, #{user.name}",
257
+ zapsmtp: {
258
+ tags: [{ name: "category", value: "welcome" }],
259
+ metadata: { user_id: user.id.to_s },
260
+ stream: "broadcast",
261
+ idempotency_key: "welcome-#{user.id}" # a retried job never sends twice
262
+ }
263
+ )
264
+ end
265
+
266
+ # a stored template: no view needed
267
+ def receipt(order)
268
+ mail(to: order.email, body: "", zapsmtp: {
269
+ template: { id: "receipt", variables: { total: order.total } },
270
+ subject: nil # nil: use the template's subject, not the mailer's
271
+ })
272
+ end
273
+ end
274
+ ```
275
+
276
+ The options travel in a private header, `X-Zapsmtp-Options`, which is never sent and is removed
277
+ once the API has taken the message; any `emails.send` parameter can be set there. Every message is
278
+ sent with an `Idempotency-Key`: yours, or one made on its first attempt and kept on the message, so
279
+ delivering the same message again after a failure (a lost answer included) never sends it twice. After a send the message carries the email's id in
280
+ `X-Zapsmtp-Email-Id`, and with `return_response: true` in the settings `deliver!` answers the API's
281
+ response. API errors raise as above (ActionMailer's `raise_delivery_errors` applies).
282
+
283
+ Settings: `api_key`, `base_url`, `timeout`, `max_retries`, `default_headers`, `transport`, or
284
+ `client:` an existing `ZapSMTP::Client`.
285
+
286
+ Without Rails, the Mail gem can use it too:
287
+
288
+ ```ruby
289
+ require "zapsmtp/rails"
290
+
291
+ message = Mail.new(from: "hello@acme.com", to: "jane@example.org", subject: "Hi", body: "Hello")
292
+ ZapSMTP::Rails.set_options(message, { tags: [{ name: "category", value: "hello" }] })
293
+ message.delivery_method(ZapSMTP::Rails::DeliveryMethod, api_key: ENV["ZAP_API_KEY"])
294
+ message.deliver
295
+ ```
296
+
297
+ ## Configuration
298
+
299
+ | Option | Environment | Default |
300
+ |---|---|---|
301
+ | `api_key` | `ZAP_API_KEY` | required |
302
+ | `base_url` | `ZAP_BASE_URL` | `https://api.zapsmtp.com/v1` |
303
+ | `timeout` | | 60 s per attempt: connecting, each write and each read, the body's included |
304
+ | `max_retries` | | 2 |
305
+ | `default_headers` | | |
306
+ | `transport` | | `ZapSMTP::Transport::NetHTTP` (keeps a connection alive per thread) |
307
+
308
+ A client is safe to share between threads, and a forked process (Puma, Unicorn) never reuses its
309
+ parent's connections. A transport is any object with `call(request)`; tests can pass a stub.
310
+
311
+ ## Reference
312
+
313
+ Every method is listed with its parameters in the [API reference](https://zapsmtp.com/docs/), and in
314
+ the YARD docs of `lib/zapsmtp/generated/resources.rb`. The resources mirror it: `zap.emails`,
315
+ `zap.emails.receiving`, `zap.threads`, `zap.domains`, `zap.webhooks`, `zap.actions`, `zap.templates`,
316
+ `zap.variables`, `zap.template_modules`, `zap.template_assets`, `zap.template_timers`, `zap.inbound`,
317
+ `zap.suppressions`, `zap.stats`, `zap.api_keys`, `zap.smtp_credentials`, `zap.logs`, `zap.usage`,
318
+ `zap.simulator`, `zap.dedicated_ips`.
319
+
320
+ ## Development
321
+
322
+ The resources and models in `lib/zapsmtp/generated` are generated from the API reference by
323
+ `sdks/codegen` (`node sdks/codegen/generate.mjs --only ruby`); change the generator, never those
324
+ files. `bundle exec rake` runs the tests (every API operation against the conformance server in
325
+ `sdks/conformance`, the core, webhooks, the Rails integration; 100% line and branch coverage) and
326
+ RuboCop.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZapSMTP
4
+ # Builds attachments for emails.send: the content is sent base64-encoded,
5
+ # and the content type follows the file name's extension unless given.
6
+ #
7
+ # zap.emails.send(..., attachments: [ZapSMTP::Attachment.from_file("invoice.pdf")])
8
+ # ZapSMTP::Attachment.from_string("report.csv", csv)
9
+ # ZapSMTP::Attachment.from_file("logo.png", content_id: "logo") # inline: <img src="cid:logo">
10
+ module Attachment
11
+ CONTENT_TYPES = {
12
+ ".pdf" => "application/pdf", ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
13
+ ".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml", ".txt" => "text/plain",
14
+ ".csv" => "text/csv", ".html" => "text/html", ".ics" => "text/calendar", ".json" => "application/json",
15
+ ".xml" => "application/xml", ".zip" => "application/zip", ".doc" => "application/msword",
16
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17
+ ".xls" => "application/vnd.ms-excel",
18
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
19
+ }.freeze
20
+
21
+ module_function
22
+
23
+ # An attachment from bytes or text.
24
+ #
25
+ # @param filename [String]
26
+ # @param data [String] the content (any encoding: its bytes are sent)
27
+ # @param content_type [String, nil] default: from the file name's extension
28
+ # @param content_id [String, nil] makes it inline: reference it as cid:<content_id>
29
+ # @return [Hash] filename, content (base64), content_type, content_id
30
+ def from_string(filename, data, content_type: nil, content_id: nil)
31
+ attachment = { filename: filename, content: [data.to_s].pack("m0") }
32
+ type = content_type || content_type_for(filename)
33
+ attachment[:content_type] = type if type
34
+ attachment[:content_id] = content_id if content_id
35
+ attachment
36
+ end
37
+
38
+ # An attachment read from a file.
39
+ #
40
+ # @param path [String, Pathname]
41
+ # @param filename [String, nil] default: the file's own name
42
+ # @return [Hash] filename, content (base64), content_type, content_id
43
+ def from_file(path, filename: nil, content_type: nil, content_id: nil)
44
+ from_string(filename || File.basename(path), File.binread(path),
45
+ content_type: content_type, content_id: content_id)
46
+ end
47
+
48
+ # The content type of a file name's extension, when it is a known one.
49
+ #
50
+ # @return [String, nil]
51
+ def content_type_for(filename)
52
+ CONTENT_TYPES[File.extname(filename.to_s).downcase]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module ZapSMTP
7
+ # The hand-written core of the client: HTTP, retries, errors and paging.
8
+ # The resources (ZapSMTP::Client) are generated from the API reference.
9
+ class BaseClient
10
+ DEFAULT_BASE_URL = "https://api.zapsmtp.com/v1"
11
+ USER_AGENT = "zapsmtp-ruby/#{VERSION} (ruby/#{RUBY_VERSION})".freeze
12
+ REQUEST_OPTIONS = %i[idempotency_key headers timeout max_retries].freeze
13
+ SAFE_METHODS = %w[GET PUT DELETE].freeze
14
+
15
+ # One API request, as the resources describe it.
16
+ #
17
+ # @api private
18
+ APIRequest = Struct.new(:http_method, :path, :query, :body, :form, :headers, :idempotent, keyword_init: true)
19
+
20
+ # @return [String]
21
+ attr_reader :api_key
22
+ # @return [String] without a trailing slash
23
+ attr_reader :base_url
24
+ # @return [Numeric] seconds per attempt
25
+ attr_reader :timeout
26
+ # @return [Integer] retries of a failed request that is safe to retry
27
+ attr_reader :max_retries
28
+ # @return [Hash{String => String}] headers sent with every request
29
+ attr_reader :default_headers
30
+ # @return [#call] see ZapSMTP::Transport
31
+ attr_reader :transport
32
+
33
+ # @param api_key [String, nil] default: ENV["ZAP_API_KEY"]
34
+ # @param base_url [String, nil] default: ENV["ZAP_BASE_URL"], else https://api.zapsmtp.com/v1
35
+ # @param timeout [Numeric] seconds per attempt (connecting, each write and each read)
36
+ # @param max_retries [Integer] retries of a failed request that is safe to retry
37
+ # @param default_headers [Hash{String => String}] sent with every request
38
+ # @param transport [#call, nil] default: ZapSMTP::Transport::NetHTTP
39
+ def initialize(api_key: nil, base_url: nil, timeout: 60, max_retries: 2, default_headers: {}, transport: nil)
40
+ @api_key = presence(api_key) || presence(ENV.fetch("ZAP_API_KEY", nil))
41
+ raise Error, "Missing API key: pass api_key: to ZapSMTP::Client.new or set ZAP_API_KEY" unless @api_key
42
+
43
+ @base_url = (presence(base_url) || presence(ENV.fetch("ZAP_BASE_URL", nil)) || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
44
+ @timeout = timeout
45
+ @max_retries = max_retries
46
+ @default_headers = default_headers.to_h { |k, v| [k.to_s, v.to_s] }
47
+ @transport = transport || Transport::NetHTTP.new
48
+ @with_response = false
49
+ build_resources
50
+ end
51
+
52
+ # The same client, whose calls answer a ZapSMTP::Result: the data and the
53
+ # ZapSMTP::Response (status, headers, request id) together.
54
+ #
55
+ # email, response = zap.with_response.emails.send(...)
56
+ # response.request_id
57
+ # response["ratelimit-remaining"]
58
+ #
59
+ # @return [ZapSMTP::Client]
60
+ def with_response
61
+ copy = dup
62
+ copy.instance_variable_set(:@with_response, true)
63
+ copy
64
+ end
65
+
66
+ def initialize_copy(other)
67
+ super
68
+ build_resources
69
+ end
70
+
71
+ def inspect
72
+ "#<#{self.class.name} base_url=#{base_url.inspect}>"
73
+ end
74
+
75
+ # @api private
76
+ def request(method:, path:, query: nil, body: nil, form: nil, headers: nil, idempotent: false, model: nil,
77
+ request_options: {})
78
+ req = APIRequest.new(http_method: method.to_s.upcase, path: path, query: query, body: body, form: form,
79
+ headers: headers, idempotent: idempotent)
80
+ data, response = execute(req, request_options)
81
+ result = Model.build(model, data, response)
82
+ @with_response ? Result.new(result, response) : result
83
+ end
84
+
85
+ # @api private
86
+ def request_page(method:, path:, model:, paging:, query: nil, request_options: {})
87
+ req = APIRequest.new(http_method: method.to_s.upcase, path: path, query: query)
88
+ page = load_page(req, model, paging, request_options)
89
+ @with_response ? Result.new(page, page.http_response) : page
90
+ end
91
+
92
+ private
93
+
94
+ def build_resources; end
95
+
96
+ def presence(value)
97
+ value.nil? || value.to_s.empty? ? nil : value.to_s
98
+ end
99
+
100
+ def load_page(req, model, paging, request_options)
101
+ data, response = execute(req, request_options)
102
+ loader = ->(query) { load_page(req.dup.tap { |r| r.query = query }, model, paging, request_options) }
103
+ Page.new(data, model: model, query: req.query, paging: paging, loader: loader, http_response: response)
104
+ end
105
+
106
+ # Sends a request, retrying it as docs/DESIGN-sdks.md says; answers the
107
+ # parsed body and the response, or raises a ZapSMTP::Error.
108
+ def execute(req, request_options)
109
+ options = request_options(request_options)
110
+ attempt = build_attempt(req, options)
111
+ max_retries = options.fetch(:max_retries, @max_retries)
112
+ safe = SAFE_METHODS.include?(req.http_method) || req.idempotent == true
113
+ tries = 0
114
+ loop do
115
+ response, data, wait = attempt_once(attempt, safe, tries, tries < max_retries)
116
+ return [data, response] unless wait
117
+
118
+ sleep_for(wait)
119
+ tries += 1
120
+ end
121
+ end
122
+
123
+ # One attempt: [response, data, nil] on success, [nil, nil, seconds to
124
+ # wait] when it should be tried again; raises otherwise.
125
+ def attempt_once(attempt, safe, tries, may_retry)
126
+ begin
127
+ raw = @transport.call(attempt)
128
+ rescue StandardError => e
129
+ # a timeout or a broken connection (sending, or reading the answer):
130
+ # the request may have reached the server, so only a request that is
131
+ # safe to repeat is sent again
132
+ return [nil, nil, Retry.backoff(tries, nil)] if may_retry && safe
133
+
134
+ raise connection_error(e)
135
+ end
136
+ response = Response.new(raw.status.to_i, normalize_headers(raw.headers))
137
+ data = parse(raw.body)
138
+ return [response, data, nil] if response.status.between?(200, 299)
139
+
140
+ error = Error.from_response(response.status, data, response.headers)
141
+ raise error unless may_retry && Retry.retryable?(response.status, error.code, safe)
142
+
143
+ [nil, nil, Retry.backoff(tries, Retry.retry_after(response.headers))]
144
+ end
145
+
146
+ # A failed attempt as a ConnectionError; when the answer's head came
147
+ # before its body broke off, with its status, headers and request id.
148
+ def connection_error(error)
149
+ return ConnectionError.new(error.message) unless error.is_a?(Transport::ResponseBodyError)
150
+
151
+ headers = normalize_headers(error.headers)
152
+ ConnectionError.new(error.message, status: error.status, headers: headers, request_id: headers["x-request-id"])
153
+ end
154
+
155
+ def build_attempt(req, options)
156
+ headers = attempt_headers(req, options)
157
+ body = nil
158
+ if req.form
159
+ headers["Content-Type"] = req.form.content_type
160
+ body = req.form.body
161
+ elsif !req.body.nil?
162
+ headers["Content-Type"] = "application/json"
163
+ body = JSON.generate(Util.jsonable(req.body))
164
+ end
165
+ Transport::Request.new(http_method: req.http_method, url: url(req), headers: headers, body: body,
166
+ timeout: options.fetch(:timeout, @timeout))
167
+ end
168
+
169
+ # The headers of a request. Later ones replace earlier ones whatever
170
+ # their case: the client's defaults, the operation's, the
171
+ # Idempotency-Key, the caller's.
172
+ def attempt_headers(req, options)
173
+ headers = { "Authorization" => "Bearer #{@api_key}", "Accept" => "application/json", "User-Agent" => USER_AGENT }
174
+ @default_headers.each { |k, v| set_header(headers, k, v) }
175
+ (req.headers || {}).each { |k, v| set_header(headers, k, v) unless v.nil? }
176
+ set_header(headers, "Idempotency-Key", options[:idempotency_key] || SecureRandom.uuid) if req.idempotent
177
+ (options[:headers] || {}).each { |k, v| set_header(headers, k, v) }
178
+ # an empty key turns deduplication off on the server, so a retried
179
+ # send could go out twice: refused before anything is sent
180
+ key = headers.find { |k, _| k.casecmp?("idempotency-key") }
181
+ if key && key[1].strip.empty?
182
+ raise ValidationError.new("idempotency_key must not be empty", code: "invalid_parameter")
183
+ end
184
+
185
+ headers
186
+ end
187
+
188
+ def set_header(headers, name, value)
189
+ name = name.to_s
190
+ headers.delete_if { |k, _| k.casecmp?(name) }
191
+ headers[name] = value.to_s
192
+ end
193
+
194
+ def url(req)
195
+ query = Util.query_string(req.query)
196
+ query.empty? ? "#{@base_url}#{req.path}" : "#{@base_url}#{req.path}?#{query}"
197
+ end
198
+
199
+ def request_options(options)
200
+ options = (options || {}).transform_keys(&:to_sym)
201
+ unknown = options.keys - REQUEST_OPTIONS
202
+ raise ArgumentError, "unknown request option(s): #{unknown.join(", ")}" unless unknown.empty?
203
+
204
+ options
205
+ end
206
+
207
+ def normalize_headers(headers)
208
+ (headers || {}).each_with_object({}) do |(k, v), out|
209
+ out[k.to_s.downcase] = v.is_a?(Array) ? v.join(", ") : v.to_s
210
+ end
211
+ end
212
+
213
+ def parse(body)
214
+ return nil if body.nil? || body.empty?
215
+
216
+ Util.finite(JSON.parse(body, symbolize_names: true, allow_nan: true))
217
+ rescue JSON::ParserError
218
+ body
219
+ end
220
+
221
+ def sleep_for(seconds)
222
+ sleep(seconds)
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Everything but the Rails integration (lib/zapsmtp.rb adds it when Rails is loaded).
4
+ require_relative "version"
5
+ require_relative "errors"
6
+ require_relative "response"
7
+ require_relative "model"
8
+ require_relative "page"
9
+ require_relative "util"
10
+ require_relative "attachment"
11
+ require_relative "retry"
12
+ require_relative "transport"
13
+ require_relative "base_client"
14
+ require_relative "webhook"
15
+ require_relative "webhook_middleware"
16
+ require_relative "generated/models"
17
+ require_relative "generated/resources"
18
+ require_relative "generated/client"