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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +414 -0
- data/SECURITY.md +64 -0
- data/lib/haskimail/api_object.rb +294 -0
- data/lib/haskimail/attachment.rb +171 -0
- data/lib/haskimail/base_client.rb +199 -0
- data/lib/haskimail/clients.rb +146 -0
- data/lib/haskimail/configuration.rb +246 -0
- data/lib/haskimail/errors.rb +246 -0
- data/lib/haskimail/http/net_http_transport.rb +288 -0
- data/lib/haskimail/http/response_parser.rb +104 -0
- data/lib/haskimail/http/retry_policy.rb +92 -0
- data/lib/haskimail/redacted.rb +31 -0
- data/lib/haskimail/resources/account_resources.rb +157 -0
- data/lib/haskimail/resources/base.rb +193 -0
- data/lib/haskimail/resources/emails.rb +352 -0
- data/lib/haskimail/resources/messages.rb +178 -0
- data/lib/haskimail/resources/server_resources.rb +297 -0
- data/lib/haskimail/resources/templates.rb +107 -0
- data/lib/haskimail/test_addresses.rb +25 -0
- data/lib/haskimail/util.rb +250 -0
- data/lib/haskimail/version.rb +6 -0
- data/lib/haskimail/webhook.rb +395 -0
- data/lib/haskimail.rb +34 -0
- data/sig/haskimail/internal.rbs +247 -0
- data/sig/haskimail.rbs +619 -0
- metadata +78 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "zlib"
|
|
7
|
+
|
|
8
|
+
require_relative "../errors"
|
|
9
|
+
require_relative "../redacted"
|
|
10
|
+
require_relative "../configuration"
|
|
11
|
+
require_relative "../util"
|
|
12
|
+
|
|
13
|
+
module Haskimail
|
|
14
|
+
module HTTP
|
|
15
|
+
# Header names whose values are never printed.
|
|
16
|
+
SENSITIVE_HEADERS = %w[x-haskimail-server-token x-haskimail-account-token authorization].freeze
|
|
17
|
+
|
|
18
|
+
# Immutable HTTP request handed to a transport. A plain class (not a
|
|
19
|
+
# Struct) so that no generic accessor — #to_h, #to_a, pp — exposes the token.
|
|
20
|
+
class Request
|
|
21
|
+
include Redacted
|
|
22
|
+
|
|
23
|
+
attr_reader :method, :url, :headers, :body, :idempotent
|
|
24
|
+
|
|
25
|
+
# @param idempotent [Boolean, nil] nil decides by HTTP method; false for
|
|
26
|
+
# PUTs with side effects (e.g. re-delivering an inbound message)
|
|
27
|
+
def initialize(method:, url:, headers:, body:, idempotent: nil)
|
|
28
|
+
@method = method
|
|
29
|
+
@url = url
|
|
30
|
+
@headers = headers
|
|
31
|
+
@body = body
|
|
32
|
+
@idempotent = idempotent
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def idempotent?
|
|
37
|
+
return idempotent unless idempotent.nil?
|
|
38
|
+
|
|
39
|
+
%i[get put delete head options].include?(method)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Sensitive header values are replaced with [FILTERED].
|
|
43
|
+
def inspect
|
|
44
|
+
safe = headers.to_h { |name, value| [name, SENSITIVE_HEADERS.include?(name.downcase) ? "[FILTERED]" : value] }
|
|
45
|
+
"#<#{self.class.name} method=#{method.inspect} url=#{url.inspect} headers=#{safe.inspect} " \
|
|
46
|
+
"body=#{body ? "#{body.bytesize} bytes" : 'nil'}>"
|
|
47
|
+
end
|
|
48
|
+
alias to_s inspect
|
|
49
|
+
|
|
50
|
+
# `pp` shows the same redacted text as #inspect.
|
|
51
|
+
def pretty_print(printer)
|
|
52
|
+
printer.text(inspect)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Immutable HTTP response returned by a transport.
|
|
57
|
+
# `headers` keys are lower-case Strings.
|
|
58
|
+
class Response
|
|
59
|
+
# @return [Integer] HTTP status
|
|
60
|
+
attr_reader :status
|
|
61
|
+
# @return [Hash{String => String}] lower-case header names
|
|
62
|
+
attr_reader :headers
|
|
63
|
+
# @return [String] raw body
|
|
64
|
+
attr_reader :body
|
|
65
|
+
|
|
66
|
+
def initialize(status:, headers:, body:)
|
|
67
|
+
@status = status
|
|
68
|
+
@headers = headers
|
|
69
|
+
@body = body
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Same status, headers and body.
|
|
74
|
+
def ==(other)
|
|
75
|
+
other.instance_of?(self.class) && [other.status, other.headers, other.body] == [status, headers, body]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Default transport built on Net::HTTP from the standard library.
|
|
80
|
+
#
|
|
81
|
+
# Security properties:
|
|
82
|
+
# * TLS certificate and hostname verification is always on (VERIFY_PEER);
|
|
83
|
+
# * TLS 1.2 is the minimum protocol version;
|
|
84
|
+
# * redirects are never followed (credentials cannot leak to another host);
|
|
85
|
+
# * a fresh connection per request — the transport is thread-safe.
|
|
86
|
+
#
|
|
87
|
+
# Whether a failed request may have reached Haskimail is decided by the
|
|
88
|
+
# phase it failed in, not by the error class: anything raised while
|
|
89
|
+
# connecting (DNS, TCP, proxy CONNECT, TLS handshake) means "not sent";
|
|
90
|
+
# anything raised after the connection is up means "maybe sent".
|
|
91
|
+
#
|
|
92
|
+
# Any object with `#call(request, open_timeout:, read_timeout:, write_timeout:)`
|
|
93
|
+
# returning {Response} can replace it via `transport:` client option.
|
|
94
|
+
class NetHttpTransport
|
|
95
|
+
include Redacted
|
|
96
|
+
|
|
97
|
+
# Net::HTTPBadResponse and Net::HTTPHeaderSyntaxError are plain
|
|
98
|
+
# StandardErrors, not Net::ProtocolErrors, so they are listed explicitly.
|
|
99
|
+
CONNECT_ERRORS = [
|
|
100
|
+
SocketError, IOError, Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, SystemCallError
|
|
101
|
+
].freeze
|
|
102
|
+
|
|
103
|
+
EXCHANGE_ERRORS = (CONNECT_ERRORS + [Zlib::Error]).freeze
|
|
104
|
+
|
|
105
|
+
# Protection against decompression bombs and runaway responses.
|
|
106
|
+
MAX_RESPONSE_SIZE = 64 * 1024 * 1024
|
|
107
|
+
|
|
108
|
+
class ResponseTooLarge < StandardError; end
|
|
109
|
+
|
|
110
|
+
METHODS = {
|
|
111
|
+
get: Net::HTTP::Get,
|
|
112
|
+
post: Net::HTTP::Post,
|
|
113
|
+
put: Net::HTTP::Put,
|
|
114
|
+
patch: Net::HTTP::Patch,
|
|
115
|
+
delete: Net::HTTP::Delete
|
|
116
|
+
}.freeze
|
|
117
|
+
private_constant :CONNECT_ERRORS, :EXCHANGE_ERRORS, :METHODS, :ResponseTooLarge
|
|
118
|
+
|
|
119
|
+
# Environment variables checked for a proxy, in this order. http_proxy
|
|
120
|
+
# is kept as a fallback because Net::HTTP itself uses it for HTTPS too.
|
|
121
|
+
# Upper-case HTTP_PROXY is ignored on purpose: CGI servers set it from
|
|
122
|
+
# the request header "Proxy:" ("httpoxy").
|
|
123
|
+
PROXY_ENV = %w[https_proxy HTTPS_PROXY http_proxy].freeze
|
|
124
|
+
|
|
125
|
+
# @param proxy [URI::Generic, false, nil] explicit proxy; false connects
|
|
126
|
+
# directly; nil reads {PROXY_ENV} (honouring no_proxy)
|
|
127
|
+
# @param ca_file [String, nil] PEM bundle trusted IN ADDITION to the system CAs
|
|
128
|
+
# @param env [Hash] environment (injectable for tests)
|
|
129
|
+
# @raise [ConfigurationError] for an invalid or https:// proxy in the environment
|
|
130
|
+
def initialize(proxy: nil, ca_file: nil, env: ENV)
|
|
131
|
+
from_env = proxy.nil?
|
|
132
|
+
@proxy = from_env ? self.class.env_proxy(env) : (proxy || nil)
|
|
133
|
+
@no_proxy = self.class.normalize_no_proxy(env["no_proxy"] || env["NO_PROXY"]) if from_env
|
|
134
|
+
@cert_store = ca_file && self.class.cert_store(ca_file)
|
|
135
|
+
freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @api private
|
|
139
|
+
# no_proxy in URI#find_proxy form. Also accepts the conventions of
|
|
140
|
+
# curl and most other tools: "*" (no proxy at all) and "*.example.com".
|
|
141
|
+
# @return [String, false] false when every host bypasses the proxy
|
|
142
|
+
def self.normalize_no_proxy(value)
|
|
143
|
+
entries = value.to_s.split(/[\s,]+/).reject(&:empty?)
|
|
144
|
+
return false if entries.include?("*")
|
|
145
|
+
|
|
146
|
+
entries.map { |entry| entry.sub(/\A\*\./, ".") }.join(",").freeze
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @api private
|
|
150
|
+
# System CAs plus the given bundle. (Setting Net::HTTP#ca_file instead
|
|
151
|
+
# would replace the system CAs: a corporate-proxy CA configured
|
|
152
|
+
# globally would then break direct connections to Haskimail.)
|
|
153
|
+
# @return [OpenSSL::X509::Store]
|
|
154
|
+
# @raise [ConfigurationError] when the file is not a readable PEM bundle
|
|
155
|
+
def self.cert_store(ca_file)
|
|
156
|
+
store = OpenSSL::X509::Store.new
|
|
157
|
+
store.set_default_paths
|
|
158
|
+
store.add_file(ca_file.to_s)
|
|
159
|
+
store
|
|
160
|
+
rescue OpenSSL::X509::StoreError, SystemCallError => e
|
|
161
|
+
raise ConfigurationError, "ca_file #{ca_file.inspect} cannot be loaded: #{e.message}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @api private
|
|
165
|
+
# @return [URI::HTTP, nil] validated proxy from the environment
|
|
166
|
+
def self.env_proxy(env)
|
|
167
|
+
name = PROXY_ENV.find { |key| !Util.blank?(env[key]) }
|
|
168
|
+
name && Configuration.parse_proxy(env[name], source: name)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @param request [Request]
|
|
172
|
+
# @return [Response]
|
|
173
|
+
def call(request, open_timeout:, read_timeout:, write_timeout:)
|
|
174
|
+
uri = URI.parse(request.url)
|
|
175
|
+
net_request = build_request(request, uri)
|
|
176
|
+
http = build_http(uri, open_timeout, read_timeout, write_timeout)
|
|
177
|
+
connect(http, uri)
|
|
178
|
+
begin
|
|
179
|
+
exchange(http, net_request, uri)
|
|
180
|
+
ensure
|
|
181
|
+
http.finish if http.started?
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Shows whether a proxy is used, never its credentials.
|
|
186
|
+
def inspect
|
|
187
|
+
"#<#{self.class.name} proxy=#{@proxy ? '[set]' : 'none'}>"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
# DNS, TCP, proxy CONNECT and TLS handshake: nothing has been sent yet.
|
|
193
|
+
def connect(http, uri)
|
|
194
|
+
http.start
|
|
195
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
196
|
+
# Read/write timeouts here come from a proxy that never answers CONNECT.
|
|
197
|
+
raise TimeoutError.new("Connection to #{uri.host} timed out", original_error: e, request_sent: false)
|
|
198
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
199
|
+
# Never retried: a certificate problem is not transient and may signal an attack.
|
|
200
|
+
raise TlsError.new("TLS error talking to #{uri.host}: #{e.message}", original_error: e, request_sent: false)
|
|
201
|
+
rescue *CONNECT_ERRORS => e
|
|
202
|
+
raise ConnectionError.new("Could not connect to #{uri.host}: #{e.class}: #{e.message}",
|
|
203
|
+
original_error: e, request_sent: false)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Sends the request and reads the body in chunks, so an oversized
|
|
207
|
+
# response is cut off early. Any failure here may come after Haskimail
|
|
208
|
+
# received the request.
|
|
209
|
+
def exchange(http, net_request, uri)
|
|
210
|
+
body = +""
|
|
211
|
+
net_response = http.request(net_request) do |response|
|
|
212
|
+
response.read_body do |chunk|
|
|
213
|
+
body << chunk
|
|
214
|
+
raise ResponseTooLarge if body.bytesize > MAX_RESPONSE_SIZE
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
Response.new(status: net_response.code.to_i, headers: net_response.each_header.to_h,
|
|
218
|
+
body: body.force_encoding(Encoding::UTF_8))
|
|
219
|
+
rescue ResponseTooLarge => e
|
|
220
|
+
raise ConnectionError.new("Response from #{uri.host} exceeds #{MAX_RESPONSE_SIZE} bytes", original_error: e)
|
|
221
|
+
rescue Net::ReadTimeout, Net::WriteTimeout => e
|
|
222
|
+
raise TimeoutError.new("Request to #{uri.host} timed out", original_error: e)
|
|
223
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
224
|
+
raise TlsError.new("TLS error talking to #{uri.host}: #{e.message}", original_error: e)
|
|
225
|
+
rescue *EXCHANGE_ERRORS => e
|
|
226
|
+
raise ConnectionError.new("Connection to #{uri.host} failed: #{e.class}: #{e.message}", original_error: e)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_http(uri, open_timeout, read_timeout, write_timeout)
|
|
230
|
+
# Net::HTTP is never left to read the environment itself: it would
|
|
231
|
+
# use http_proxy unchecked and ignore https_proxy.
|
|
232
|
+
proxy = @proxy if @proxy && use_proxy?(uri)
|
|
233
|
+
http = if proxy
|
|
234
|
+
Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, decode(proxy.user), decode(proxy.password))
|
|
235
|
+
else
|
|
236
|
+
Net::HTTP.new(uri.host, uri.port, nil)
|
|
237
|
+
end
|
|
238
|
+
http.use_ssl = true
|
|
239
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
240
|
+
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
241
|
+
http.cert_store = @cert_store if @cert_store
|
|
242
|
+
http.open_timeout = open_timeout
|
|
243
|
+
http.read_timeout = read_timeout
|
|
244
|
+
http.write_timeout = write_timeout
|
|
245
|
+
http.max_retries = 0 # retries are handled by the client with proper rules
|
|
246
|
+
http
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def build_request(request, uri)
|
|
250
|
+
klass = METHODS.fetch(request.method) do
|
|
251
|
+
raise ValidationError, "Unsupported HTTP method #{request.method.inspect}"
|
|
252
|
+
end
|
|
253
|
+
net_request = klass.new(uri.request_uri)
|
|
254
|
+
request.headers.each { |name, value| net_request[name] = value }
|
|
255
|
+
net_request.body = request.body if request.body
|
|
256
|
+
net_request
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Same rules as URI#find_proxy: an environment proxy is skipped for
|
|
260
|
+
# loopback addresses and for hosts matched by no_proxy (names, domain
|
|
261
|
+
# suffixes, IP ranges in CIDR form). An explicit proxy is always used.
|
|
262
|
+
def use_proxy?(uri)
|
|
263
|
+
no_proxy = @no_proxy
|
|
264
|
+
return true if no_proxy.nil?
|
|
265
|
+
return false if no_proxy == false
|
|
266
|
+
|
|
267
|
+
address = resolve(uri.hostname.to_s)
|
|
268
|
+
return false if address&.match?(/\A127\.|\A::1\z/)
|
|
269
|
+
|
|
270
|
+
# An unresolved name ("") is matched by name only, exactly like nil.
|
|
271
|
+
URI::Generic.use_proxy?(uri.hostname.to_s, address.to_s, uri.port || 443, no_proxy)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def resolve(host)
|
|
275
|
+
IPSocket.getaddress(host)
|
|
276
|
+
rescue SocketError
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Percent-decoding of URL user info ("+" stays "+", unlike form decoding).
|
|
281
|
+
def decode(value)
|
|
282
|
+
return nil if value.nil?
|
|
283
|
+
|
|
284
|
+
value.b.gsub(/%\h\h/n) { |escape| escape.delete_prefix("%").hex.chr }.force_encoding(Encoding::UTF_8)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../util"
|
|
5
|
+
|
|
6
|
+
module Haskimail
|
|
7
|
+
module HTTP
|
|
8
|
+
# @api private
|
|
9
|
+
# Turns an HTTP response into data or into the matching {ApiError}.
|
|
10
|
+
module ResponseParser
|
|
11
|
+
# Error class raised for each HTTP status (others: see #error_class).
|
|
12
|
+
ERROR_CLASSES = {
|
|
13
|
+
401 => AuthenticationError,
|
|
14
|
+
403 => ForbiddenError,
|
|
15
|
+
404 => NotFoundError,
|
|
16
|
+
413 => PayloadTooLargeError,
|
|
17
|
+
422 => UnprocessableEntityError,
|
|
18
|
+
429 => RateLimitError,
|
|
19
|
+
503 => ServiceUnavailableError
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @return [Hash, Array, nil] parsed JSON; nil for an empty or non-JSON body
|
|
25
|
+
def parse(response)
|
|
26
|
+
body = response.body.to_s
|
|
27
|
+
return nil if blank_body?(body)
|
|
28
|
+
|
|
29
|
+
Util.parse_json(body)
|
|
30
|
+
rescue JSON::ParserError # includes JSON::NestingError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True for an empty or whitespace-only body.
|
|
35
|
+
def blank_body?(body)
|
|
36
|
+
body.to_s.b.strip.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ErrorCode as Integer; the API may also send it as a numeric String.
|
|
40
|
+
# @return [Integer, nil] nil when absent or not an integral number
|
|
41
|
+
def error_code(data)
|
|
42
|
+
value = data.is_a?(Hash) ? data["ErrorCode"] : nil
|
|
43
|
+
case value
|
|
44
|
+
when Integer then value
|
|
45
|
+
when Float then value.to_i if value.finite? && value == value.floor
|
|
46
|
+
when String then Integer(value.strip, 10, exception: false)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True when ErrorCode is present but is not a number: the outcome of
|
|
51
|
+
# the request cannot be determined.
|
|
52
|
+
def malformed_error_code?(data)
|
|
53
|
+
data.is_a?(Hash) && !data["ErrorCode"].nil? && error_code(data).nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A 2xx carrying a non-zero ErrorCode is still a failure (single objects
|
|
57
|
+
# only: batch arrays report per-item errors and never raise).
|
|
58
|
+
def success?(response, data)
|
|
59
|
+
return false unless (200..299).cover?(response.status)
|
|
60
|
+
return true unless data.is_a?(Hash) && !data["ErrorCode"].nil?
|
|
61
|
+
|
|
62
|
+
error_code(data)&.zero? || false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [ApiError]
|
|
66
|
+
def api_error(response, data)
|
|
67
|
+
code = error_code(data)
|
|
68
|
+
message = data.is_a?(Hash) ? data["Message"] : nil
|
|
69
|
+
status = response.status
|
|
70
|
+
|
|
71
|
+
text = "HTTP #{status}"
|
|
72
|
+
text += " [ErrorCode #{code}]" if code
|
|
73
|
+
text += ": #{message}" unless message.to_s.empty?
|
|
74
|
+
text += ": #{response.body.to_s[0, 200]}" if data.nil? && !response.body.to_s.empty?
|
|
75
|
+
|
|
76
|
+
error_class(status).new(text, http_status: status, error_code: code, api_message: message,
|
|
77
|
+
response_body: response.body, response_headers: response.headers)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A 2xx whose body cannot be interpreted: not JSON (e.g. a proxy page)
|
|
81
|
+
# or an ErrorCode that is not a number.
|
|
82
|
+
# @return [UnexpectedResponseError]
|
|
83
|
+
def unexpected_body_error(request, response, reason = "a non-JSON body")
|
|
84
|
+
text = "HTTP #{response.status} with #{reason}: #{response.body.to_s[0, 200]}"
|
|
85
|
+
unless request.idempotent?
|
|
86
|
+
text += " (the request may have been executed — for a send, check the message log before re-sending)"
|
|
87
|
+
end
|
|
88
|
+
UnexpectedResponseError.new(text, http_status: response.status, response_body: response.body,
|
|
89
|
+
response_headers: response.headers)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Class] the ApiError subclass for an HTTP status
|
|
93
|
+
def error_class(status)
|
|
94
|
+
ERROR_CLASSES.fetch(status) do
|
|
95
|
+
case status
|
|
96
|
+
when 500.. then ServerError
|
|
97
|
+
when 200..299 then UnprocessableEntityError
|
|
98
|
+
else ApiError
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module Haskimail
|
|
8
|
+
# HTTP layer: requests, transport, response parsing and retry rules.
|
|
9
|
+
module HTTP
|
|
10
|
+
# Decides whether a failed attempt may be repeated and how long to wait.
|
|
11
|
+
#
|
|
12
|
+
# The rule of thumb: never repeat a request the server may already have
|
|
13
|
+
# executed — a duplicate email is worse than an error.
|
|
14
|
+
class RetryPolicy
|
|
15
|
+
# Statuses retried for idempotent requests.
|
|
16
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
17
|
+
# "Refused before processing": the documentation says to repeat the
|
|
18
|
+
# request later, so even a POST is safe. 500/502/504 are excluded for
|
|
19
|
+
# POST — the email may already be accepted.
|
|
20
|
+
REFUSED_STATUSES = [429, 503].freeze
|
|
21
|
+
# ErrorCode 36 — "Временная ошибка отправки. Повторите запрос позже":
|
|
22
|
+
# the email was not accepted.
|
|
23
|
+
RETRYABLE_ERROR_CODES = [ErrorCodes::TEMPORARY_SENDING_ERROR].freeze
|
|
24
|
+
# ErrorCode 100 — "Внутренняя ошибка. Повторите запрос позже": repeated
|
|
25
|
+
# only for idempotent requests (a send may have been processed).
|
|
26
|
+
IDEMPOTENT_RETRYABLE_ERROR_CODES = [ErrorCodes::INTERNAL_ERROR].freeze
|
|
27
|
+
# Upper bound for Retry-After, seconds.
|
|
28
|
+
MAX_RETRY_AFTER = 30
|
|
29
|
+
# First backoff step, seconds (doubles each attempt).
|
|
30
|
+
BASE_DELAY = 0.5
|
|
31
|
+
# Upper bound of the backoff, seconds.
|
|
32
|
+
MAX_DELAY = 8.0
|
|
33
|
+
|
|
34
|
+
attr_reader :max_retries
|
|
35
|
+
|
|
36
|
+
# @param max_retries [Integer]
|
|
37
|
+
# @param random [#rand] source of jitter (injectable for tests)
|
|
38
|
+
# @param clock [#call] current Time, for Retry-After dates (injectable for tests)
|
|
39
|
+
def initialize(max_retries:, random: Random, clock: -> { Time.now })
|
|
40
|
+
@max_retries = max_retries
|
|
41
|
+
@random = random
|
|
42
|
+
@clock = clock
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param error_code [Integer, nil] API ErrorCode of the response body
|
|
47
|
+
def retry_response?(request, response, error_code, attempt)
|
|
48
|
+
return false if attempt >= max_retries
|
|
49
|
+
|
|
50
|
+
if RETRYABLE_STATUSES.include?(response.status)
|
|
51
|
+
return request.idempotent? || REFUSED_STATUSES.include?(response.status)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return false unless response.status == 422
|
|
55
|
+
|
|
56
|
+
RETRYABLE_ERROR_CODES.include?(error_code) ||
|
|
57
|
+
(request.idempotent? && IDEMPOTENT_RETRYABLE_ERROR_CODES.include?(error_code))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# TLS failures are never repeated; other network errors are repeated for
|
|
61
|
+
# idempotent requests, or when the request certainly never left.
|
|
62
|
+
def retry_error?(request, error, attempt)
|
|
63
|
+
return false if attempt >= max_retries || error.is_a?(TlsError)
|
|
64
|
+
|
|
65
|
+
request.idempotent? || !error.request_sent?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Seconds to wait before attempt number `attempt` (1-based retry count):
|
|
69
|
+
# Retry-After when the server sent one, otherwise exponential backoff
|
|
70
|
+
# with "equal jitter".
|
|
71
|
+
def delay(attempt, response = nil)
|
|
72
|
+
retry_after(response) || begin
|
|
73
|
+
base = [BASE_DELAY * (2**(attempt - 1)), MAX_DELAY].min
|
|
74
|
+
(base / 2) + (@random.rand * base / 2)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Retry-After as delta-seconds or an HTTP date, capped at MAX_RETRY_AFTER.
|
|
81
|
+
def retry_after(response)
|
|
82
|
+
value = response&.headers&.fetch("retry-after", nil).to_s.b.strip
|
|
83
|
+
return nil if value.empty?
|
|
84
|
+
|
|
85
|
+
seconds = value.match?(/\A\d+\z/) ? value.to_i : (Time.httpdate(value) - @clock.call).ceil
|
|
86
|
+
seconds.clamp(0, MAX_RETRY_AFTER).to_f
|
|
87
|
+
rescue ArgumentError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Haskimail
|
|
4
|
+
# @api private
|
|
5
|
+
# For objects that hold credentials (API token, proxy password): every
|
|
6
|
+
# generic serializer gets the redacted #inspect text instead of the
|
|
7
|
+
# instance variables. Covers YAML (Psych), Marshal and JSON, including
|
|
8
|
+
# ActiveSupport's `as_json`, which would otherwise dump instance variables.
|
|
9
|
+
module Redacted
|
|
10
|
+
# YAML shows only the redacted description.
|
|
11
|
+
def encode_with(coder)
|
|
12
|
+
coder.scalar = inspect
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Never written to caches or dumps.
|
|
16
|
+
def marshal_dump
|
|
17
|
+
raise TypeError, "#{self.class.name} holds credentials and cannot be marshalled"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# JSON shows only the redacted description.
|
|
21
|
+
def as_json(*)
|
|
22
|
+
inspect
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# JSON shows only the redacted description. (json's RBS types the state
|
|
26
|
+
# argument of String#to_json too narrowly for a splat.)
|
|
27
|
+
def to_json(*args)
|
|
28
|
+
inspect.to_json(*args) # steep:ignore
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Haskimail
|
|
6
|
+
module Resources
|
|
7
|
+
# Servers (account token): https://haskimail.ru/developer/api/servers-api
|
|
8
|
+
class Servers < Base
|
|
9
|
+
# Documented colours (case-insensitive). The API silently stores
|
|
10
|
+
# "purple" for anything else; the SDK raises instead, so a typo is
|
|
11
|
+
# caught rather than turned into the wrong colour.
|
|
12
|
+
COLORS = %w[purple blue turquoise green red orange yellow grey].freeze
|
|
13
|
+
# Values of TrackLinks besides true/false.
|
|
14
|
+
TRACK_LINKS = %w[None HtmlAndText HtmlOnly TextOnly].freeze
|
|
15
|
+
# Live delivers mail, Sandbox never does (set at creation only).
|
|
16
|
+
DELIVERY_TYPES = %w[Live Sandbox].freeze
|
|
17
|
+
|
|
18
|
+
# GET /servers
|
|
19
|
+
# @return [ApiObject] TotalCount, Servers[]
|
|
20
|
+
def list(count: nil, offset: nil)
|
|
21
|
+
check_paging!(count, offset)
|
|
22
|
+
object(api_get("/servers", query({ count: count, offset: offset }, PAGING)))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The API returns 50 servers per page by default.
|
|
26
|
+
# @return [Paginator]
|
|
27
|
+
def each(page_size: 50, &block)
|
|
28
|
+
paginator = paginate("Servers", page_size: page_size) do |count, offset|
|
|
29
|
+
api_get("/servers", { "count" => count, "offset" => offset })
|
|
30
|
+
end
|
|
31
|
+
run(paginator, block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# GET /servers/{serverid}
|
|
35
|
+
def get(server_id)
|
|
36
|
+
object(api_get(path("/servers/%s", server_id)))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# POST /servers
|
|
40
|
+
#
|
|
41
|
+
# account.servers.create(name: "Интернет-магазин", color: "purple",
|
|
42
|
+
# track_opens: true, track_links: "HtmlAndText")
|
|
43
|
+
#
|
|
44
|
+
# `delivery_type: "Sandbox"` creates a server that never delivers mail;
|
|
45
|
+
# the type cannot be changed later.
|
|
46
|
+
def create(params = nil, **fields)
|
|
47
|
+
payload = validate!(Util.to_api(merge_params(params, fields)))
|
|
48
|
+
raise ValidationError, "name is required" if Util.blank?(payload["Name"])
|
|
49
|
+
raise ValidationError, "color is required" if Util.blank?(payload["Color"])
|
|
50
|
+
|
|
51
|
+
object(api_post("/servers", payload))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# PUT /servers/{serverid} — pass only the fields to change.
|
|
55
|
+
def update(server_id, params = nil, **fields)
|
|
56
|
+
payload = validate!(Util.to_api(merge_params(params, fields)))
|
|
57
|
+
raise ValidationError, "Nothing to update" if payload.empty?
|
|
58
|
+
if payload.key?("DeliveryType")
|
|
59
|
+
raise ValidationError, "delivery_type cannot be changed after the server is created"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
object(api_put(path("/servers/%s", server_id), payload))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# DELETE /servers/{serverid} — also deletes its inbound rules.
|
|
66
|
+
def delete(server_id)
|
|
67
|
+
object(api_delete(path("/servers/%s", server_id)))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# POST /servers/{serverid}/tokens/new — issues an additional server API
|
|
71
|
+
# token. The plain-text value is returned only once, in this response —
|
|
72
|
+
# treat it as a secret.
|
|
73
|
+
#
|
|
74
|
+
# account.servers.issue_token(11834, name: "Интеграция 1С")
|
|
75
|
+
#
|
|
76
|
+
# @param server_id [Integer]
|
|
77
|
+
# @param name [String, nil] optional token name
|
|
78
|
+
# @return [ApiObject] Token, ServerId
|
|
79
|
+
def issue_token(server_id, name: nil)
|
|
80
|
+
body = name.nil? ? nil : { "Name" => Util.strip_text(name, "token name") }
|
|
81
|
+
object(api_post(path("/servers/%s/tokens/new", server_id), body, idempotent: false))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate!(payload)
|
|
87
|
+
check_value!(payload, "Color", COLORS) { |value| Util.strip_text(value, "color").downcase }
|
|
88
|
+
check_value!(payload, "DeliveryType", DELIVERY_TYPES)
|
|
89
|
+
if payload.key?("TrackLinks") && ![true, false].include?(payload["TrackLinks"])
|
|
90
|
+
check_value!(payload, "TrackLinks", TRACK_LINKS)
|
|
91
|
+
end
|
|
92
|
+
threshold = payload["InboundSpamThreshold"]
|
|
93
|
+
if threshold && !(threshold.is_a?(Integer) && threshold.between?(0, 30) && (threshold % 5).zero?)
|
|
94
|
+
raise ValidationError, "inbound_spam_threshold must be 0..30 in steps of 5"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
payload
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def check_value!(payload, key, allowed)
|
|
101
|
+
return unless payload.key?(key)
|
|
102
|
+
|
|
103
|
+
value = block_given? ? yield(payload[key]) : payload[key].to_s
|
|
104
|
+
return if allowed.include?(value)
|
|
105
|
+
|
|
106
|
+
raise ValidationError, "#{key} must be one of: #{allowed.join(', ')}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Sending domains (account token): https://haskimail.ru/developer/api/domains-api
|
|
111
|
+
class Domains < Base
|
|
112
|
+
# GET /domains
|
|
113
|
+
# @return [ApiObject] TotalCount, Domains[]
|
|
114
|
+
def list(count: nil, offset: nil)
|
|
115
|
+
check_paging!(count, offset)
|
|
116
|
+
object(api_get("/domains", query({ count: count, offset: offset }, PAGING)))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Paginator]
|
|
120
|
+
def each(page_size: 500, &block)
|
|
121
|
+
paginator = paginate("Domains", page_size: page_size) do |count, offset|
|
|
122
|
+
api_get("/domains", { "count" => count, "offset" => offset })
|
|
123
|
+
end
|
|
124
|
+
run(paginator, block)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# GET /domains/{domainid} — includes the SPF/DKIM records to publish.
|
|
128
|
+
def get(domain_id)
|
|
129
|
+
object(api_get(path("/domains/%s", domain_id)))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# POST /domains
|
|
133
|
+
# @param name [String] e.g. "example.com"
|
|
134
|
+
def create(name)
|
|
135
|
+
domain = Util.strip_text(name, "Domain name")
|
|
136
|
+
raise ValidationError, "Domain name must not be blank" if domain.empty?
|
|
137
|
+
|
|
138
|
+
object(api_post("/domains", { "Name" => domain }))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# DELETE /domains/{domainid} — also removes confirmed addresses on it.
|
|
142
|
+
def delete(domain_id)
|
|
143
|
+
object(api_delete(path("/domains/%s", domain_id)))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# PUT /domains/{domainid}/verifyDkim — re-checks DNS.
|
|
147
|
+
def verify_dkim(domain_id)
|
|
148
|
+
object(api_put(path("/domains/%s/verifyDkim", domain_id)))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# PUT /domains/{domainid}/verifyspf — re-checks DNS.
|
|
152
|
+
def verify_spf(domain_id)
|
|
153
|
+
object(api_put(path("/domains/%s/verifyspf", domain_id)))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|