haskimail 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,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "util"
4
+ require_relative "http/response_parser"
5
+
6
+ module Haskimail
7
+ # Immutable wrapper around a JSON object returned by the API.
8
+ #
9
+ # Field access is lossless: the original keys are kept, and snake_case
10
+ # names are resolved to them on the fly.
11
+ #
12
+ # result = client.emails.deliver(...)
13
+ # result.message_id # => "6f1c0b2d..."
14
+ # result[:message_id] # same
15
+ # result["MessageID"] # exact API key
16
+ # result.to_h # => { "MessageID" => "...", ... } (mutable deep copy)
17
+ #
18
+ # Keys that are not identifiers (e.g. "Apple Mail" in e-mail client stats)
19
+ # are available through #[] with either spelling: `stats["Apple Mail"]`,
20
+ # `stats[:apple_mail]`.
21
+ class ApiObject
22
+ # @param data [Hash] parsed API object (deep-copied and frozen)
23
+ def initialize(data)
24
+ raise ArgumentError, "ApiObject expects a Hash, got #{data.class}" unless data.is_a?(Hash)
25
+
26
+ setup(Util.deep_freeze(Util.deep_dup(data)))
27
+ end
28
+
29
+ # @param key [String, Symbol] exact API key or snake_case alias
30
+ # @return [Object, ApiObject, Array, nil]
31
+ def [](key)
32
+ original = resolve(key)
33
+ original.nil? ? nil : self.class.wrap(@data[original])
34
+ end
35
+
36
+ # Like Hash#fetch, with the same key resolution as #[].
37
+ def fetch(key, *default, &block)
38
+ original = resolve(key)
39
+ return self.class.wrap(@data[original]) unless original.nil?
40
+ return yield(key) if block
41
+ return default.first unless default.empty?
42
+
43
+ raise KeyError.new("key not found: #{key.inspect}", receiver: self, key: key)
44
+ end
45
+
46
+ # True when the key exists under any accepted spelling.
47
+ def key?(key)
48
+ !resolve(key).nil?
49
+ end
50
+ alias include? key?
51
+
52
+ # Like Hash#dig, with the same key resolution as #[].
53
+ def dig(key, *rest)
54
+ value = self[key]
55
+ return value if rest.empty? || value.nil?
56
+
57
+ value.respond_to?(:dig) ? value.dig(*rest) : nil
58
+ end
59
+
60
+ # Original API keys.
61
+ def keys
62
+ @data.keys
63
+ end
64
+
65
+ # Yields each original key with its (wrapped) value.
66
+ def each(&block)
67
+ return enum_for(:each) unless block
68
+
69
+ @data.each_key { |key| yield key, self[key] }
70
+ self
71
+ end
72
+
73
+ # @return [Hash] deep, mutable copy of the raw API data (String keys)
74
+ def to_h
75
+ Util.deep_dup(@data)
76
+ end
77
+ alias to_hash to_h
78
+
79
+ # JSON of the original API data.
80
+ def to_json(*args)
81
+ @data.to_json(*args)
82
+ end
83
+
84
+ # Value equality: same class and same data.
85
+ def ==(other)
86
+ other.instance_of?(self.class) && other.data == @data
87
+ end
88
+
89
+ # Strict equality (numbers compared by type too), consistent with #hash.
90
+ def eql?(other)
91
+ other.instance_of?(self.class) && other.data.eql?(@data)
92
+ end
93
+
94
+ # Consistent with #eql?.
95
+ def hash
96
+ [self.class, @data].hash
97
+ end
98
+
99
+ # Marshal support (e.g. Rails.cache): only the data is stored, and the
100
+ # loaded object is frozen again.
101
+ def marshal_dump
102
+ @data
103
+ end
104
+
105
+ # Restores an object written by #marshal_dump, frozen again.
106
+ # @api private
107
+ def marshal_load(data)
108
+ setup(Util.deep_freeze(data))
109
+ end
110
+
111
+ # Full data, for debugging.
112
+ def inspect
113
+ "#<#{self.class.name} #{@data.inspect}>"
114
+ end
115
+ alias to_s inspect
116
+
117
+ # `result.message_id` reads "MessageID". Setters, bang and predicate
118
+ # names are never resolved: the object is read-only.
119
+ def method_missing(name, *args, &block)
120
+ return super unless args.empty? && block.nil? && reader_name?(name)
121
+
122
+ original = resolve(name)
123
+ original.nil? ? super : self.class.wrap(@data[original])
124
+ end
125
+
126
+ def respond_to_missing?(name, include_private = false)
127
+ (reader_name?(name) && !resolve(name).nil?) || super
128
+ end
129
+
130
+ # Key form used for case- and punctuation-insensitive lookup:
131
+ # "MessageID", :message_id and "message-id" all become "messageid".
132
+ # @api private
133
+ def self.normalize(key)
134
+ key.to_s.downcase.gsub(/[^[:alnum:]]/, "")
135
+ end
136
+
137
+ # @api private
138
+ # Wraps already-frozen nested data without copying it again.
139
+ def self.wrap(value)
140
+ case value
141
+ when Hash then ApiObject.allocate.tap { |object| object.send(:setup, value) }
142
+ when Array then value.map { |item| wrap(item) }.freeze
143
+ else value
144
+ end
145
+ end
146
+
147
+ protected
148
+
149
+ # Frozen raw data, for comparisons between objects.
150
+ # @api private
151
+ attr_reader :data
152
+
153
+ private
154
+
155
+ def reader_name?(name)
156
+ !name.end_with?("=", "!", "?")
157
+ end
158
+
159
+ # Value under the exact API key, without case-insensitive matching.
160
+ def exact(key)
161
+ self.class.wrap(@data[key])
162
+ end
163
+
164
+ def setup(frozen_data)
165
+ @data = frozen_data
166
+ @index = @data.keys.each_with_object({}) do |key, index|
167
+ index[self.class.normalize(key)] ||= key
168
+ end.freeze
169
+ freeze
170
+ end
171
+
172
+ def resolve(key)
173
+ return key if key.is_a?(String) && @data.key?(key)
174
+ return nil unless key.is_a?(String) || key.is_a?(Symbol)
175
+
176
+ @index[self.class.normalize(key)]
177
+ end
178
+ end
179
+
180
+ # Result of sending one email (also one element of a batch result).
181
+ class SendResult < ApiObject
182
+ # @return [Integer, nil] nil when the API did not send a numeric code
183
+ def error_code
184
+ HTTP::ResponseParser.error_code(@data)
185
+ end
186
+
187
+ # True only for ErrorCode 0. `false` appears inside batch results —
188
+ # single sends raise instead.
189
+ def success?
190
+ error_code&.zero? == true
191
+ end
192
+
193
+ # MessageID, one per recipient (the API joins them with commas).
194
+ # @return [Array<String>]
195
+ def message_ids
196
+ self["MessageID"].to_s.split(",").map(&:strip).reject(&:empty?)
197
+ end
198
+
199
+ # @return [Time, nil]
200
+ def submitted_at_time
201
+ Util.parse_time(self["SubmittedAt"])
202
+ end
203
+ end
204
+
205
+ # Result of /email/batch and /email/batchWithTemplates.
206
+ # The API answers 200 even if some emails were rejected,
207
+ # so check #success? or #failed.
208
+ class BatchResult
209
+ include Enumerable
210
+
211
+ # @return [Array<SendResult>] frozen, in the order the emails were given
212
+ attr_reader :results
213
+
214
+ # @param results [Array<SendResult>]
215
+ def initialize(results)
216
+ @results = results.dup.freeze
217
+ freeze
218
+ end
219
+
220
+ # Yields each SendResult.
221
+ def each(&block)
222
+ return enum_for(:each) unless block
223
+
224
+ @results.each(&block)
225
+ self
226
+ end
227
+
228
+ # @return [SendResult, nil]
229
+ def [](index)
230
+ @results[index]
231
+ end
232
+
233
+ # Number of results (equals the number of emails sent).
234
+ def size
235
+ @results.size
236
+ end
237
+ alias length size
238
+
239
+ def empty?
240
+ @results.empty?
241
+ end
242
+
243
+ # True when every email was accepted.
244
+ def success?
245
+ @results.all?(&:success?)
246
+ end
247
+
248
+ # @return [Array<SendResult>]
249
+ def successful
250
+ @results.select(&:success?)
251
+ end
252
+
253
+ # @return [Array<SendResult>]
254
+ def failed
255
+ @results.reject(&:success?)
256
+ end
257
+
258
+ # @return [Array<SendResult>] mutable copy
259
+ def to_a
260
+ @results.dup
261
+ end
262
+
263
+ # @return [Array<Hash>] raw API data of every result
264
+ def as_json(*)
265
+ @results.map(&:to_h)
266
+ end
267
+
268
+ # JSON array of the raw API results.
269
+ # json's RBS types the state argument of Array#to_json too narrowly.
270
+ def to_json(*args)
271
+ as_json.to_json(*args) # steep:ignore
272
+ end
273
+
274
+ # Same class and equal results.
275
+ def ==(other)
276
+ other.instance_of?(self.class) && other.results == @results
277
+ end
278
+
279
+ def eql?(other)
280
+ other.instance_of?(self.class) && other.results.eql?(@results)
281
+ end
282
+
283
+ # Consistent with #eql?.
284
+ def hash
285
+ [self.class, @results].hash
286
+ end
287
+
288
+ # Summary: number of results and failures.
289
+ def inspect
290
+ "#<#{self.class.name} size=#{size} failed=#{failed.size}>"
291
+ end
292
+ alias to_s inspect
293
+ end
294
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "util"
5
+
6
+ module Haskimail
7
+ # An email attachment. Encodes content to Base64 and refuses file types
8
+ # that Haskimail rejects, before anything goes over the network.
9
+ #
10
+ # Haskimail::Attachment.from_file("invoice.pdf")
11
+ # Haskimail::Attachment.new(name: "report.csv", content: csv_string, content_type: "text/csv")
12
+ # Haskimail::Attachment.from_file("logo.png", content_id: "cid:logo") # inline: <img src="cid:logo">
13
+ #
14
+ # Instances can be put straight into `attachments:` of any send method.
15
+ class Attachment
16
+ # https://haskimail.ru/developer/user-guide/send-email-with-api#attachments
17
+ FORBIDDEN_EXTENSIONS = %w[
18
+ vbs exe bin bat chm com cpl crt hlp hta inf ins isp jse lnk mdb pcd pif reg
19
+ scr sct shs vbe vba wsf wsh wsl msc msi msp mst
20
+ ].freeze
21
+
22
+ # Total message size limit, attachments counted after Base64 encoding.
23
+ MAX_MESSAGE_SIZE = 10 * 1024 * 1024
24
+
25
+ # Content type guessed from the file extension.
26
+ MIME_TYPES = {
27
+ "pdf" => "application/pdf", "zip" => "application/zip", "json" => "application/json",
28
+ "xml" => "application/xml", "txt" => "text/plain", "csv" => "text/csv", "html" => "text/html",
29
+ "htm" => "text/html", "ics" => "text/calendar", "png" => "image/png", "jpg" => "image/jpeg",
30
+ "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml",
31
+ "doc" => "application/msword", "xls" => "application/vnd.ms-excel",
32
+ "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
33
+ "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
34
+ "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
35
+ "odt" => "application/vnd.oasis.opendocument.text", "rtf" => "application/rtf",
36
+ "mp3" => "audio/mpeg", "mp4" => "video/mp4", "eml" => "message/rfc822"
37
+ }.freeze
38
+
39
+ # Content type for unknown extensions.
40
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
41
+
42
+ # RFC 2045 type/subtype, optionally followed by parameters
43
+ # ("text/plain; charset=utf-8").
44
+ CONTENT_TYPE_FORMAT = %r{\A[!#$%&'*+.^_`|~0-9A-Za-z-]+/[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:[ \t]*;.*)?\z}
45
+
46
+ attr_reader :name, :content_type, :content_id
47
+
48
+ # @param path [String, Pathname]
49
+ # @raise [ValidationError] when the file cannot be read
50
+ def self.from_file(path, name: nil, content_type: nil, content_id: nil)
51
+ content = begin
52
+ File.binread(path.to_s)
53
+ rescue SystemCallError, IOError => e
54
+ raise ValidationError, "Attachment file #{path.to_s.inspect} cannot be read: #{e.message}"
55
+ end
56
+ new(name: name || File.basename(path.to_s), content: content, content_type: content_type, content_id: content_id)
57
+ end
58
+
59
+ # @param name [String] file name shown to the recipient
60
+ # @param content [String] raw (not encoded) bytes
61
+ # @param content_type [String, nil] detected from the extension when nil
62
+ # @param content_id [String, nil] makes the attachment inline (`cid:...`)
63
+ def initialize(name:, content:, content_type: nil, content_id: nil)
64
+ @name = self.class.validate_name!(name)
65
+ raise ValidationError, "Attachment content must be a String" unless content.is_a?(String)
66
+
67
+ @encoded = [content].pack("m0").freeze
68
+ @content_type = self.class.validate_content_type!(content_type || self.class.detect_content_type(@name))
69
+ @content_id = content_id.nil? ? nil : self.class.validate_content_id!(content_id)
70
+ if @encoded.bytesize > MAX_MESSAGE_SIZE
71
+ raise ValidationError, "Attachment #{@name} exceeds 10 MB after Base64 encoding"
72
+ end
73
+
74
+ freeze
75
+ end
76
+
77
+ # True for inline attachments (with a content ID).
78
+ def inline?
79
+ !@content_id.nil?
80
+ end
81
+
82
+ # Size after Base64 encoding — the number Haskimail checks against its limit.
83
+ def encoded_size
84
+ @encoded.bytesize
85
+ end
86
+
87
+ # @return [Hash] API representation
88
+ def to_haskimail
89
+ hash = { "Name" => @name, "Content" => @encoded, "ContentType" => @content_type }
90
+ content_id = @content_id
91
+ hash["ContentID"] = content_id if content_id
92
+ hash
93
+ end
94
+ alias to_h to_haskimail
95
+
96
+ # Name, type and size; never the content.
97
+ def inspect
98
+ "#<#{self.class.name} name=#{@name.inspect} content_type=#{@content_type.inspect} " \
99
+ "encoded_size=#{encoded_size}#{" content_id=#{@content_id.inspect}" if inline?}>"
100
+ end
101
+
102
+ # @api private
103
+ def self.validate_name!(name)
104
+ text = name.to_s
105
+ raise ValidationError, "Attachment name must be valid #{text.encoding} text" unless text.valid_encoding?
106
+
107
+ text = Util.utf8(text) # binary names (e.g. from a multipart upload) are relabelled
108
+ raise ValidationError, "Attachment name must not be blank" if text.strip.empty?
109
+ if text.match?(/[[:cntrl:]]/) || Util::UNSAFE_HEADER_CHARS.match?(text)
110
+ raise ValidationError, "Attachment name must not contain control characters or line separators"
111
+ end
112
+
113
+ extension = extension_of(text)
114
+ if FORBIDDEN_EXTENSIONS.include?(extension)
115
+ raise ValidationError, "Attachments of type .#{extension} are not accepted by Haskimail"
116
+ end
117
+
118
+ text.dup.freeze
119
+ end
120
+
121
+ # @api private
122
+ # @return [String] frozen, valid content type
123
+ def self.validate_content_type!(value)
124
+ text = header_text!(value, "Attachment content type")
125
+ raise ValidationError, "Invalid attachment content type #{text.inspect}" unless CONTENT_TYPE_FORMAT.match?(text)
126
+
127
+ text
128
+ end
129
+
130
+ # @api private
131
+ # @return [String] frozen content ID ("cid:logo" or "logo")
132
+ def self.validate_content_id!(value)
133
+ text = header_text!(value, "Attachment content ID")
134
+ raise ValidationError, "Attachment content ID must not be blank" if text.strip.empty?
135
+
136
+ text
137
+ end
138
+
139
+ # @api private
140
+ def self.header_text!(value, label)
141
+ unless value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer)
142
+ raise ValidationError, "#{label} must be a String"
143
+ end
144
+
145
+ text = Util.utf8(value.to_s)
146
+ # TAB is allowed: it may separate parameters ("text/plain;\tcharset=utf-8").
147
+ raise ValidationError, "#{label} must not contain control characters" if text.match?(/[[:cntrl:]&&[^\t]]/)
148
+ raise ValidationError, "#{label} must not contain line separators" if Util::UNSAFE_HEADER_CHARS.match?(text)
149
+
150
+ text.dup.freeze
151
+ end
152
+ private_class_method :header_text!
153
+
154
+ # Windows ignores trailing dots and spaces ("virus.exe. " runs as .exe),
155
+ # so they are stripped before the check.
156
+ # @api private
157
+ def self.extension_of(name)
158
+ base = File.basename(name.sub(/[.\s]+\z/, ""))
159
+ # A name like ".exe" has no extname, but Windows still treats it as an .exe.
160
+ extension = File.extname(base).delete_prefix(".")
161
+ extension = base.delete_prefix(".") if extension.empty? && base.start_with?(".")
162
+ extension.downcase
163
+ end
164
+
165
+ # Content type for a file name, from MIME_TYPES.
166
+ # @api private
167
+ def self.detect_content_type(name)
168
+ MIME_TYPES.fetch(extension_of(name), DEFAULT_CONTENT_TYPE)
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+ require_relative "redacted"
5
+ require_relative "errors"
6
+ require_relative "configuration"
7
+ require_relative "util"
8
+ require_relative "api_object"
9
+ require_relative "http/net_http_transport"
10
+ require_relative "http/retry_policy"
11
+ require_relative "http/response_parser"
12
+
13
+ module Haskimail
14
+ # Shared HTTP plumbing for {ServerClient} and {AccountClient}:
15
+ # authentication, JSON, error mapping, retries and logging.
16
+ class BaseClient
17
+ include Redacted
18
+
19
+ # Methods accepted by #request.
20
+ HTTP_METHODS = %i[get post put patch delete].freeze
21
+
22
+ attr_reader :config
23
+
24
+ # @param token [String] API token
25
+ # @param transport [#call, nil] custom HTTP transport (see HTTP::NetHttpTransport)
26
+ # @param sleeper [#call, nil] used between retries; injectable for tests
27
+ # @param options [Hash] overrides of {Configuration} attributes
28
+ def initialize(token, transport: nil, sleeper: nil, **options)
29
+ @token = self.class.validate_token!(token)
30
+ @config = Haskimail.configuration.dup.update(**options).freeze
31
+ @transport = transport || HTTP::NetHttpTransport.new(proxy: @config.proxy, ca_file: @config.ca_file)
32
+ @retry_policy = HTTP::RetryPolicy.new(max_retries: @config.max_retries)
33
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
34
+ @user_agent = build_user_agent
35
+ build_resources
36
+ freeze
37
+ end
38
+
39
+ # Low-level escape hatch for endpoints not wrapped by the SDK.
40
+ #
41
+ # client.request(:get, "/messages", query: { "count" => 10 })
42
+ #
43
+ # @param method [Symbol] :get, :post, :put, :patch, :delete
44
+ # @param path [String] path starting with "/"
45
+ # @param query [Hash] query string parameters (sent verbatim)
46
+ # @param body [Hash, Array, nil] JSON body (sent verbatim, strings converted to UTF-8)
47
+ # @return [ApiObject, Array, nil]
48
+ def request(method, path, query: nil, body: nil)
49
+ wrap(perform(method, path, query: query, body: body))
50
+ end
51
+
52
+ # The token is never shown.
53
+ def inspect
54
+ "#<#{self.class.name} base_url=#{config.base_url.inspect} token=[FILTERED]>"
55
+ end
56
+ alias to_s inspect
57
+
58
+ # @api private
59
+ def self.validate_token!(token)
60
+ raise ConfigurationError, "API token is required" if Util.blank?(token)
61
+
62
+ value = token.to_s.b
63
+ unless value.match?(/\A[\x21-\x7e]+\z/n)
64
+ raise ConfigurationError, "API token contains forbidden characters (spaces, line breaks or non-ASCII)"
65
+ end
66
+
67
+ value.dup.freeze
68
+ end
69
+
70
+ # @api private
71
+ # Performs the request with retries and returns the parsed JSON (Hash/Array/nil).
72
+ def perform(method, path, query: nil, body: nil, idempotent: nil)
73
+ request = build_request(method, path, query, body, idempotent)
74
+ attempt = 0
75
+
76
+ loop do
77
+ response = attempt_request(request, attempt)
78
+ data = HTTP::ResponseParser.parse(response)
79
+ if @retry_policy.retry_response?(request, response, HTTP::ResponseParser.error_code(data), attempt)
80
+ attempt += 1
81
+ pause(attempt, response, "HTTP #{response.status}")
82
+ next
83
+ end
84
+
85
+ return checked(request, response, data)
86
+ rescue ConnectionError => e
87
+ raise unless @retry_policy.retry_error?(request, e, attempt)
88
+
89
+ attempt += 1
90
+ pause(attempt, nil, e.class.name)
91
+ end
92
+ end
93
+
94
+ # @api private
95
+ # Ensures a documented JSON-object response.
96
+ def expect_object(data)
97
+ return data if data.is_a?(Hash)
98
+
99
+ raise UnexpectedResponseError.new("Expected a JSON object from Haskimail, got #{data.class}. " \
100
+ "If this was a send request, the email MAY have been accepted — " \
101
+ "check the message log before re-sending", http_status: 200)
102
+ end
103
+
104
+ # Wraps parsed JSON: objects become ApiObject, arrays are mapped.
105
+ # @api private
106
+ def wrap(data)
107
+ case data
108
+ when Hash then ApiObject.new(data)
109
+ when Array then data.map { |item| item.is_a?(Hash) ? ApiObject.new(item) : item }
110
+ else data
111
+ end
112
+ end
113
+
114
+ protected
115
+
116
+ # Name of the authentication header.
117
+ def token_header
118
+ raise NotImplementedError
119
+ end
120
+
121
+ # Subclasses create their resource objects here.
122
+ def build_resources; end
123
+
124
+ private
125
+
126
+ def build_request(method, path, query, body, idempotent)
127
+ unless HTTP_METHODS.include?(method)
128
+ raise ValidationError, "HTTP method must be one of #{HTTP_METHODS.map(&:inspect).join(', ')}"
129
+ end
130
+ raise ValidationError, "path must start with '/'" unless path.is_a?(String) && path.start_with?("/")
131
+
132
+ headers = { "Accept" => "application/json", "User-Agent" => @user_agent, token_header => @token }
133
+ payload = nil
134
+ unless body.nil?
135
+ headers["Content-Type"] = "application/json"
136
+ payload = Util.generate_json(Util.utf8_strings(body))
137
+ end
138
+ HTTP::Request.new(method: method, url: build_url(path, query), headers: headers.freeze, body: payload,
139
+ idempotent: idempotent)
140
+ end
141
+
142
+ def build_url(path, query)
143
+ url = config.base_url + path
144
+ url += "?#{URI.encode_www_form(query)}" if query && !query.empty?
145
+ URI.parse(url)
146
+ url
147
+ rescue URI::InvalidURIError
148
+ raise ValidationError, "Invalid request path #{path.inspect}"
149
+ end
150
+
151
+ def checked(request, response, data)
152
+ if (200..299).cover?(response.status) && HTTP::ResponseParser.malformed_error_code?(data)
153
+ raise HTTP::ResponseParser.unexpected_body_error(request, response, "a non-numeric ErrorCode")
154
+ end
155
+ raise HTTP::ResponseParser.api_error(response, data) unless HTTP::ResponseParser.success?(response, data)
156
+ if data.nil? && !HTTP::ResponseParser.blank_body?(response.body)
157
+ raise HTTP::ResponseParser.unexpected_body_error(request, response)
158
+ end
159
+
160
+ data
161
+ end
162
+
163
+ def attempt_request(request, attempt)
164
+ started = monotonic_now
165
+ response = @transport.call(request, open_timeout: config.open_timeout,
166
+ read_timeout: config.read_timeout,
167
+ write_timeout: config.write_timeout)
168
+ log(:debug) do
169
+ "Haskimail #{request.method.upcase} #{log_path(request)} -> #{response.status} " \
170
+ "(#{((monotonic_now - started) * 1000).round} ms, attempt #{attempt + 1})"
171
+ end
172
+ response
173
+ end
174
+
175
+ def pause(attempt, response, reason)
176
+ delay = @retry_policy.delay(attempt, response)
177
+ log(:warn) { "Haskimail retry #{attempt}/#{config.max_retries} in #{delay.round(2)}s (#{reason})" }
178
+ @sleeper.call(delay)
179
+ end
180
+
181
+ # The query string is never logged: it contains recipient addresses (personal data).
182
+ def log_path(request)
183
+ URI.parse(request.url).path
184
+ end
185
+
186
+ def log(level, &)
187
+ config.logger&.public_send(level, &)
188
+ end
189
+
190
+ def monotonic_now
191
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
192
+ end
193
+
194
+ def build_user_agent
195
+ agent = "haskimail-ruby/#{VERSION} ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})"
196
+ config.user_agent ? "#{agent} #{config.user_agent}" : agent
197
+ end
198
+ end
199
+ end