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,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