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,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_client"
|
|
4
|
+
require_relative "resources/emails"
|
|
5
|
+
require_relative "resources/templates"
|
|
6
|
+
require_relative "resources/messages"
|
|
7
|
+
require_relative "resources/server_resources"
|
|
8
|
+
require_relative "resources/account_resources"
|
|
9
|
+
|
|
10
|
+
module Haskimail
|
|
11
|
+
# Token that validates send requests without sending anything.
|
|
12
|
+
TEST_TOKEN = "HASKIMAIL_API_TEST"
|
|
13
|
+
|
|
14
|
+
# Client authenticated with a server token (`X-Haskimail-Server-Token`):
|
|
15
|
+
# everything that belongs to one server, including sending email.
|
|
16
|
+
#
|
|
17
|
+
# client = Haskimail::ServerClient.new(ENV.fetch("HASKIMAIL_SERVER_TOKEN"))
|
|
18
|
+
# client.emails.deliver(from: "shop@example.com", to: "ivan@example.com",
|
|
19
|
+
# subject: "Привет", text_body: "Привет!")
|
|
20
|
+
#
|
|
21
|
+
# Pass {Haskimail::TEST_TOKEN} to validate send requests without sending.
|
|
22
|
+
# Instances are immutable and safe to share between threads.
|
|
23
|
+
class ServerClient < BaseClient
|
|
24
|
+
# Header that carries the server token.
|
|
25
|
+
TOKEN_HEADER = "X-Haskimail-Server-Token"
|
|
26
|
+
|
|
27
|
+
# @return [Resources::Emails]
|
|
28
|
+
attr_reader :emails
|
|
29
|
+
# @return [Resources::Templates]
|
|
30
|
+
attr_reader :templates
|
|
31
|
+
# @return [Resources::Messages]
|
|
32
|
+
attr_reader :messages
|
|
33
|
+
# @return [Resources::Opens]
|
|
34
|
+
attr_reader :opens
|
|
35
|
+
# @return [Resources::Clicks]
|
|
36
|
+
attr_reader :clicks
|
|
37
|
+
# @return [Resources::InboundMessages]
|
|
38
|
+
attr_reader :inbound_messages
|
|
39
|
+
# @return [Resources::Bounces]
|
|
40
|
+
attr_reader :bounces
|
|
41
|
+
# @return [Resources::MessageStreams]
|
|
42
|
+
attr_reader :message_streams
|
|
43
|
+
# @return [Resources::Suppressions]
|
|
44
|
+
attr_reader :suppressions
|
|
45
|
+
# @return [Resources::Webhooks]
|
|
46
|
+
attr_reader :webhooks
|
|
47
|
+
# @return [Resources::InboundRules]
|
|
48
|
+
attr_reader :inbound_rules
|
|
49
|
+
# @return [Resources::Stats]
|
|
50
|
+
attr_reader :stats
|
|
51
|
+
|
|
52
|
+
# @param token [String, nil] server token; defaults to ENV["HASKIMAIL_SERVER_TOKEN"]
|
|
53
|
+
def initialize(token = ENV.fetch("HASKIMAIL_SERVER_TOKEN", nil), **options)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
protected
|
|
58
|
+
|
|
59
|
+
# Name of the authentication header.
|
|
60
|
+
def token_header
|
|
61
|
+
TOKEN_HEADER
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Creates the resource objects available with this token.
|
|
65
|
+
def build_resources
|
|
66
|
+
@emails = Resources::Emails.new(self)
|
|
67
|
+
@templates = Resources::Templates.new(self)
|
|
68
|
+
@messages = Resources::Messages.new(self)
|
|
69
|
+
@opens = Resources::Opens.new(self)
|
|
70
|
+
@clicks = Resources::Clicks.new(self)
|
|
71
|
+
@inbound_messages = Resources::InboundMessages.new(self)
|
|
72
|
+
@bounces = Resources::Bounces.new(self)
|
|
73
|
+
@message_streams = Resources::MessageStreams.new(self)
|
|
74
|
+
@suppressions = Resources::Suppressions.new(self)
|
|
75
|
+
@webhooks = Resources::Webhooks.new(self)
|
|
76
|
+
@inbound_rules = Resources::InboundRules.new(self)
|
|
77
|
+
@stats = Resources::Stats.new(self)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Client authenticated with the account token (`X-Haskimail-Account-Token`).
|
|
82
|
+
#
|
|
83
|
+
# Manages servers and domains, moves templates between servers and — as the
|
|
84
|
+
# documentation allows for the account key — reads and manages data of all
|
|
85
|
+
# servers of the account at once: templates, messages, opens, clicks,
|
|
86
|
+
# bounces, message streams, webhooks and statistics.
|
|
87
|
+
#
|
|
88
|
+
# Sending email, inbound messages, inbound rules and suppression lists need a
|
|
89
|
+
# server token: use {ServerClient} for them.
|
|
90
|
+
#
|
|
91
|
+
# account = Haskimail::AccountClient.new(ENV.fetch("HASKIMAIL_ACCOUNT_TOKEN"))
|
|
92
|
+
# account.servers.each { |server| puts server.name }
|
|
93
|
+
class AccountClient < BaseClient
|
|
94
|
+
# Header that carries the account token.
|
|
95
|
+
TOKEN_HEADER = "X-Haskimail-Account-Token"
|
|
96
|
+
|
|
97
|
+
# @return [Resources::Servers]
|
|
98
|
+
attr_reader :servers
|
|
99
|
+
# @return [Resources::Domains]
|
|
100
|
+
attr_reader :domains
|
|
101
|
+
# @return [Resources::AccountTemplates]
|
|
102
|
+
attr_reader :templates
|
|
103
|
+
# @return [Resources::Messages]
|
|
104
|
+
attr_reader :messages
|
|
105
|
+
# @return [Resources::Opens]
|
|
106
|
+
attr_reader :opens
|
|
107
|
+
# @return [Resources::Clicks]
|
|
108
|
+
attr_reader :clicks
|
|
109
|
+
# @return [Resources::Bounces]
|
|
110
|
+
attr_reader :bounces
|
|
111
|
+
# With the account token `create` needs `server_id:`.
|
|
112
|
+
# @return [Resources::MessageStreams]
|
|
113
|
+
attr_reader :message_streams
|
|
114
|
+
# With the account token pass `message_stream:` to `create` explicitly.
|
|
115
|
+
# @return [Resources::Webhooks]
|
|
116
|
+
attr_reader :webhooks
|
|
117
|
+
# @return [Resources::Stats]
|
|
118
|
+
attr_reader :stats
|
|
119
|
+
|
|
120
|
+
# @param token [String, nil] account token; defaults to ENV["HASKIMAIL_ACCOUNT_TOKEN"]
|
|
121
|
+
def initialize(token = ENV.fetch("HASKIMAIL_ACCOUNT_TOKEN", nil), **options)
|
|
122
|
+
super
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
protected
|
|
126
|
+
|
|
127
|
+
# Name of the authentication header.
|
|
128
|
+
def token_header
|
|
129
|
+
TOKEN_HEADER
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Creates the resource objects available with this token.
|
|
133
|
+
def build_resources
|
|
134
|
+
@servers = Resources::Servers.new(self)
|
|
135
|
+
@domains = Resources::Domains.new(self)
|
|
136
|
+
@templates = Resources::AccountTemplates.new(self)
|
|
137
|
+
@messages = Resources::Messages.new(self)
|
|
138
|
+
@opens = Resources::Opens.new(self)
|
|
139
|
+
@clicks = Resources::Clicks.new(self)
|
|
140
|
+
@bounces = Resources::Bounces.new(self)
|
|
141
|
+
@message_streams = Resources::MessageStreams.new(self)
|
|
142
|
+
@webhooks = Resources::Webhooks.new(self)
|
|
143
|
+
@stats = Resources::Stats.new(self)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
# Ruby SDK for the Haskimail transactional email API.
|
|
9
|
+
module Haskimail
|
|
10
|
+
# Client settings. A global instance lives in {Haskimail.configuration};
|
|
11
|
+
# every client takes a frozen snapshot of it at construction time, so
|
|
12
|
+
# changing global settings later never affects clients that already exist.
|
|
13
|
+
#
|
|
14
|
+
# Haskimail.configure do |config|
|
|
15
|
+
# config.read_timeout = 15
|
|
16
|
+
# config.max_retries = 3
|
|
17
|
+
# config.logger = Rails.logger
|
|
18
|
+
# end
|
|
19
|
+
class Configuration
|
|
20
|
+
# Production API endpoint.
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.haskimail.ru"
|
|
22
|
+
|
|
23
|
+
# Every setting, in the order shown by #inspect.
|
|
24
|
+
ATTRIBUTES = %i[
|
|
25
|
+
base_url open_timeout read_timeout write_timeout max_retries
|
|
26
|
+
logger proxy ca_file user_agent
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# @!attribute [r] base_url
|
|
30
|
+
# @return [String] API endpoint (HTTPS)
|
|
31
|
+
# @!attribute [r] open_timeout
|
|
32
|
+
# @return [Numeric] seconds to establish the connection
|
|
33
|
+
# @!attribute [r] read_timeout
|
|
34
|
+
# @return [Numeric] seconds to wait for each read
|
|
35
|
+
# @!attribute [r] write_timeout
|
|
36
|
+
# @return [Numeric] seconds to wait for each write
|
|
37
|
+
# @!attribute [r] max_retries
|
|
38
|
+
# @return [Integer] automatic retries (0..10)
|
|
39
|
+
# @!attribute [r] logger
|
|
40
|
+
# @return [Logger, nil] request log without tokens or addresses
|
|
41
|
+
# @!attribute [r] proxy
|
|
42
|
+
# @return [URI::HTTP, false, nil] explicit proxy; false = direct; nil = from the environment
|
|
43
|
+
# @!attribute [r] ca_file
|
|
44
|
+
# @return [String, nil] extra trusted CA bundle
|
|
45
|
+
# @!attribute [r] user_agent
|
|
46
|
+
# @return [String, nil] suffix of the User-Agent header
|
|
47
|
+
attr_reader :base_url, :open_timeout, :read_timeout, :write_timeout, :max_retries,
|
|
48
|
+
:logger, :proxy, :ca_file, :user_agent
|
|
49
|
+
|
|
50
|
+
def initialize(**options)
|
|
51
|
+
@base_url = DEFAULT_BASE_URL
|
|
52
|
+
@open_timeout = 5
|
|
53
|
+
@read_timeout = 30
|
|
54
|
+
@write_timeout = 30
|
|
55
|
+
@max_retries = 2
|
|
56
|
+
@logger = nil
|
|
57
|
+
@proxy = nil
|
|
58
|
+
@ca_file = nil
|
|
59
|
+
@user_agent = nil
|
|
60
|
+
update(**options)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Bulk assignment with validation.
|
|
64
|
+
# @return [self]
|
|
65
|
+
def update(**options)
|
|
66
|
+
unknown = options.keys - ATTRIBUTES
|
|
67
|
+
raise ConfigurationError, "Unknown option(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
68
|
+
|
|
69
|
+
# All-or-nothing: validate on a copy, then apply.
|
|
70
|
+
staged = dup
|
|
71
|
+
options.each { |key, value| staged.public_send(:"#{key}=", value) }
|
|
72
|
+
ATTRIBUTES.each { |key| instance_variable_set(:"@#{key}", staged.public_send(key)) }
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# HTTPS URL without credentials, query or fragment.
|
|
77
|
+
def base_url=(value)
|
|
78
|
+
@base_url = self.class.normalize_base_url(value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Seconds to establish the connection.
|
|
82
|
+
def open_timeout=(value)
|
|
83
|
+
@open_timeout = positive_number!(:open_timeout, value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Seconds to wait for each read of the response.
|
|
87
|
+
def read_timeout=(value)
|
|
88
|
+
@read_timeout = positive_number!(:read_timeout, value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Seconds to wait for each write of the request.
|
|
92
|
+
def write_timeout=(value)
|
|
93
|
+
@write_timeout = positive_number!(:write_timeout, value)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Automatic retries after a failed attempt (0..10), see HTTP::RetryPolicy.
|
|
97
|
+
def max_retries=(value)
|
|
98
|
+
unless value.is_a?(Integer) && value.between?(0, 10)
|
|
99
|
+
raise ConfigurationError, "max_retries must be an Integer between 0 and 10"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@max_retries = value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Any object responding to #debug/#info/#warn (Logger, Rails.logger...).
|
|
106
|
+
def logger=(value)
|
|
107
|
+
unless value.nil? || %i[debug info warn].all? { |m| value.respond_to?(m) }
|
|
108
|
+
raise ConfigurationError, "logger must respond to #debug, #info and #warn"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@logger = value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Explicit HTTP proxy, e.g. "http://user:pass@proxy.local:3128". Traffic to
|
|
115
|
+
# Haskimail is still end-to-end TLS through the proxy (CONNECT tunnel).
|
|
116
|
+
# When nil, the `https_proxy`, `HTTPS_PROXY` or `http_proxy` environment
|
|
117
|
+
# variable is used (with `no_proxy`), validated the same way; `false`
|
|
118
|
+
# connects directly and ignores the environment.
|
|
119
|
+
def proxy=(value)
|
|
120
|
+
@proxy = value.nil? || value == false ? value : self.class.parse_proxy(value)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Path to a PEM bundle of CAs trusted in addition to the system CAs
|
|
124
|
+
# (e.g. a corporate TLS proxy). Verification itself can never be disabled.
|
|
125
|
+
def ca_file=(value)
|
|
126
|
+
raise ConfigurationError, "ca_file #{value.inspect} does not exist" if value && !File.file?(value.to_s)
|
|
127
|
+
|
|
128
|
+
@ca_file = value&.to_s&.freeze
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Suffix appended to the SDK User-Agent, e.g. "my-shop/2.1".
|
|
132
|
+
# Visible ASCII and spaces only (it becomes an HTTP header value).
|
|
133
|
+
def user_agent=(value)
|
|
134
|
+
if !value.nil? && !value.to_s.b.match?(/\A[\x20-\x7e]*\z/n)
|
|
135
|
+
raise ConfigurationError, "user_agent may contain only printable ASCII characters"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@user_agent = value.nil? ? nil : value.to_s.dup.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Proxy password is masked; use #proxy for the real value.
|
|
142
|
+
def to_h
|
|
143
|
+
ATTRIBUTES.to_h { |key| [key, key == :proxy ? redacted_proxy : public_send(key)] }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Like #to_h: the proxy password is masked.
|
|
147
|
+
def inspect
|
|
148
|
+
"#<#{self.class.name} #{to_h.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')}>"
|
|
149
|
+
end
|
|
150
|
+
alias to_s inspect
|
|
151
|
+
|
|
152
|
+
# The proxy password must not end up in caches or dumps.
|
|
153
|
+
def marshal_dump
|
|
154
|
+
raise TypeError, "#{self.class.name} may hold credentials and cannot be marshalled"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# YAML (Psych) gets the redacted view, like #inspect.
|
|
158
|
+
def encode_with(coder)
|
|
159
|
+
coder.map = to_h.transform_keys(&:to_s).transform_values { |value| value.is_a?(String) ? value : value.inspect }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @api private
|
|
163
|
+
# Validates a proxy URL. Net::HTTP talks to proxies in plain text, so an
|
|
164
|
+
# https:// proxy would silently send its credentials unencrypted: refused.
|
|
165
|
+
# @return [URI::HTTP] frozen
|
|
166
|
+
def self.parse_proxy(value, source: "proxy")
|
|
167
|
+
text = value.to_s.strip
|
|
168
|
+
text = "http://#{text}" unless text.include?("://") # "proxy.local:3128", as in many environments
|
|
169
|
+
uri = URI.parse(text)
|
|
170
|
+
unless uri.scheme == "http" && !uri.host.to_s.empty?
|
|
171
|
+
raise ConfigurationError, "#{source} must be an http:// URL (TLS to the proxy is not supported by Net::HTTP)"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
uri.freeze
|
|
175
|
+
rescue URI::InvalidURIError
|
|
176
|
+
raise ConfigurationError, "#{source} is not a valid URL"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @api private
|
|
180
|
+
# @return [String] frozen base URL without a trailing slash
|
|
181
|
+
def self.normalize_base_url(value)
|
|
182
|
+
uri = URI.parse(value.to_s)
|
|
183
|
+
raise ConfigurationError, "base_url must use https" unless uri.scheme == "https"
|
|
184
|
+
raise ConfigurationError, "base_url must contain a host" if uri.host.nil? || uri.host.empty?
|
|
185
|
+
raise ConfigurationError, "base_url must not contain credentials" if uri.userinfo
|
|
186
|
+
raise ConfigurationError, "base_url must not contain a query or fragment" if uri.query || uri.fragment
|
|
187
|
+
|
|
188
|
+
uri.to_s.chomp("/").freeze
|
|
189
|
+
rescue URI::InvalidURIError
|
|
190
|
+
raise ConfigurationError, "base_url is not a valid URL"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def redacted_proxy
|
|
196
|
+
proxy = @proxy
|
|
197
|
+
return proxy unless proxy
|
|
198
|
+
return proxy.to_s unless proxy.userinfo
|
|
199
|
+
|
|
200
|
+
# A lone user name may itself be a token, so it is masked too.
|
|
201
|
+
user = proxy.password ? "#{proxy.user}:[FILTERED]" : "[FILTERED]"
|
|
202
|
+
"#{proxy.scheme}://#{user}@#{proxy.host}:#{proxy.port}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def positive_number!(name, value)
|
|
206
|
+
return value if value.is_a?(Numeric) && value.positive?
|
|
207
|
+
|
|
208
|
+
raise ConfigurationError, "#{name} must be a positive number of seconds"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Global defaults live here (not in haskimail.rb) so that every file of the
|
|
213
|
+
# gem can be required on its own.
|
|
214
|
+
@configuration = Configuration.new.freeze
|
|
215
|
+
# Re-entrant: reading the configuration inside a configure block is fine.
|
|
216
|
+
@config_mutex = Monitor.new
|
|
217
|
+
|
|
218
|
+
class << self
|
|
219
|
+
# Global defaults (frozen), snapshotted by every client when it is created.
|
|
220
|
+
# Change them with {configure}.
|
|
221
|
+
# @return [Configuration]
|
|
222
|
+
def configuration
|
|
223
|
+
@config_mutex.synchronize { @configuration }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Changes global defaults atomically: if the block raises, nothing changes.
|
|
227
|
+
#
|
|
228
|
+
# Haskimail.configure { |config| config.max_retries = 3 }
|
|
229
|
+
#
|
|
230
|
+
# @yieldparam config [Configuration]
|
|
231
|
+
def configure
|
|
232
|
+
raise ConfigurationError, "Haskimail.configure needs a block" unless block_given?
|
|
233
|
+
|
|
234
|
+
@config_mutex.synchronize do
|
|
235
|
+
draft = @configuration.dup
|
|
236
|
+
yield draft
|
|
237
|
+
@configuration = draft.freeze
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Restores defaults (handy in tests).
|
|
242
|
+
def reset_configuration!
|
|
243
|
+
@config_mutex.synchronize { @configuration = Configuration.new.freeze }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Haskimail
|
|
4
|
+
# Base class for every error raised by this library.
|
|
5
|
+
# `rescue Haskimail::Error` catches every failure of an API call, of input
|
|
6
|
+
# validation and of webhook parsing. Only calling a Ruby method with the
|
|
7
|
+
# wrong arguments (e.g. a missing keyword) raises Ruby's own ArgumentError.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Invalid client configuration (missing token, non-HTTPS base URL, bad timeout...).
|
|
11
|
+
class ConfigurationError < Error; end
|
|
12
|
+
|
|
13
|
+
# The request was rejected on the client side before anything was sent.
|
|
14
|
+
class ValidationError < Error; end
|
|
15
|
+
|
|
16
|
+
# Webhook payload signature does not match.
|
|
17
|
+
class SignatureVerificationError < Error; end
|
|
18
|
+
|
|
19
|
+
# Webhook body is larger than the configured limit. A subclass of
|
|
20
|
+
# ValidationError, so answer it with a 413 rather than a 403: for inbound
|
|
21
|
+
# email a 403 stops Haskimail's retries and the message is lost.
|
|
22
|
+
class WebhookPayloadTooLargeError < ValidationError; end
|
|
23
|
+
|
|
24
|
+
# Network-level failure: DNS, refused connection, TLS, timeout.
|
|
25
|
+
class ConnectionError < Error
|
|
26
|
+
# @return [Exception, nil] the low-level exception
|
|
27
|
+
attr_reader :original_error
|
|
28
|
+
|
|
29
|
+
def initialize(message = nil, original_error: nil, request_sent: true)
|
|
30
|
+
super(message)
|
|
31
|
+
@original_error = original_error
|
|
32
|
+
@request_sent = request_sent
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# `false` means the request certainly never reached Haskimail, so even a
|
|
36
|
+
# non-idempotent call (sending an email) is safe to repeat.
|
|
37
|
+
def request_sent?
|
|
38
|
+
@request_sent
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Connect, read or write timeout.
|
|
43
|
+
class TimeoutError < ConnectionError; end
|
|
44
|
+
|
|
45
|
+
# TLS handshake or certificate verification failure. Never retried.
|
|
46
|
+
class TlsError < ConnectionError; end
|
|
47
|
+
|
|
48
|
+
# Haskimail answered with an error. Carries the HTTP status and the API
|
|
49
|
+
# `ErrorCode` (see {Haskimail::ErrorCodes}).
|
|
50
|
+
class ApiError < Error
|
|
51
|
+
attr_reader :http_status, :error_code, :api_message, :response_body, :response_headers
|
|
52
|
+
|
|
53
|
+
def initialize(message = nil, http_status: nil, error_code: nil, api_message: nil,
|
|
54
|
+
response_body: nil, response_headers: {})
|
|
55
|
+
super(message)
|
|
56
|
+
@http_status = http_status
|
|
57
|
+
@error_code = error_code
|
|
58
|
+
@api_message = api_message
|
|
59
|
+
@response_body = response_body
|
|
60
|
+
@response_headers = response_headers
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Description of {#error_code} from the documentation, if known.
|
|
64
|
+
def error_code_description
|
|
65
|
+
ErrorCodes.describe(error_code)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# HTTP 401 — the token is missing, wrong, or of the wrong kind
|
|
70
|
+
# (e.g. a server token used for account-level methods; ErrorCode 10).
|
|
71
|
+
class AuthenticationError < ApiError; end
|
|
72
|
+
|
|
73
|
+
# HTTP 403 — the IP address is not in the account allow-list.
|
|
74
|
+
class ForbiddenError < ApiError; end
|
|
75
|
+
|
|
76
|
+
# HTTP 404 — unknown API method or object.
|
|
77
|
+
class NotFoundError < ApiError; end
|
|
78
|
+
|
|
79
|
+
# HTTP 413 — the batch is larger than 50 MB.
|
|
80
|
+
class PayloadTooLargeError < ApiError; end
|
|
81
|
+
|
|
82
|
+
# HTTP 422 — validation failed; inspect {#error_code}.
|
|
83
|
+
class UnprocessableEntityError < ApiError; end
|
|
84
|
+
|
|
85
|
+
# HTTP 429 — not described in the docs, handled defensively.
|
|
86
|
+
class RateLimitError < ApiError; end
|
|
87
|
+
|
|
88
|
+
# HTTP 5xx — server-side failure. GET/PUT/DELETE are retried automatically;
|
|
89
|
+
# a POST that got 500/502/504 MAY already have been executed (an email may
|
|
90
|
+
# have been sent), so check the message log before repeating it.
|
|
91
|
+
class ServerError < ApiError; end
|
|
92
|
+
|
|
93
|
+
# HTTP 503 — temporary unavailability; the request was not executed.
|
|
94
|
+
class ServiceUnavailableError < ServerError; end
|
|
95
|
+
|
|
96
|
+
# A successful status with a body the SDK cannot interpret (not JSON, or
|
|
97
|
+
# not the documented shape). For send calls the email MAY have been
|
|
98
|
+
# accepted — check the message log before re-sending.
|
|
99
|
+
class UnexpectedResponseError < ApiError; end
|
|
100
|
+
|
|
101
|
+
# Numeric `ErrorCode` values documented at
|
|
102
|
+
# https://haskimail.ru/developer/api/overview#error-codes
|
|
103
|
+
module ErrorCodes
|
|
104
|
+
# Неверный API-ключ.
|
|
105
|
+
INVALID_API_TOKEN = 10
|
|
106
|
+
# Объект не найден или неполный запрос.
|
|
107
|
+
NOT_FOUND_OR_INCOMPLETE_REQUEST = 12
|
|
108
|
+
# Внутренняя ошибка, повторите запрос позже.
|
|
109
|
+
INTERNAL_ERROR = 100
|
|
110
|
+
# Неизвестный метод API.
|
|
111
|
+
UNKNOWN_API_METHOD = 404
|
|
112
|
+
|
|
113
|
+
# Не все обязательные параметры.
|
|
114
|
+
MISSING_REQUIRED_FIELDS = 3
|
|
115
|
+
# Аккаунт не может отправлять.
|
|
116
|
+
ACCOUNT_CANNOT_SEND = 4
|
|
117
|
+
# Некорректный email-адрес.
|
|
118
|
+
INVALID_EMAIL_ADDRESS = 6
|
|
119
|
+
# Действие запрещено.
|
|
120
|
+
ACTION_NOT_ALLOWED = 15
|
|
121
|
+
# Слишком большое вложение.
|
|
122
|
+
ATTACHMENT_TOO_LARGE = 26
|
|
123
|
+
# Недопустимый тип вложения.
|
|
124
|
+
INVALID_ATTACHMENT_TYPE = 33
|
|
125
|
+
# Отправка недоступна.
|
|
126
|
+
SENDING_UNAVAILABLE = 35
|
|
127
|
+
# Временная ошибка отправки, повторите запрос позже.
|
|
128
|
+
TEMPORARY_SENDING_ERROR = 36
|
|
129
|
+
# Некорректные заголовки письма.
|
|
130
|
+
INVALID_HEADERS = 40
|
|
131
|
+
# Адрес отправителя не подтвержден.
|
|
132
|
+
SENDER_SIGNATURE_NOT_CONFIRMED = 52
|
|
133
|
+
# Неверное значение поля или неверный пакет писем.
|
|
134
|
+
INVALID_FIELD_VALUE_OR_BATCH = 300
|
|
135
|
+
# Отправитель не подтвержден (аккаунт на модерации).
|
|
136
|
+
SENDER_NOT_CONFIRMED = 400
|
|
137
|
+
# Неверное поле запроса (тестовый режим).
|
|
138
|
+
INVALID_REQUEST_FIELD = 403
|
|
139
|
+
# Запрещенный тип вложения (тестовый режим).
|
|
140
|
+
FORBIDDEN_ATTACHMENT_TYPE = 411
|
|
141
|
+
# Получатель не подтвержден (аккаунт на модерации).
|
|
142
|
+
RECIPIENT_NOT_CONFIRMED = 412
|
|
143
|
+
# Шаблон не найден.
|
|
144
|
+
TEMPLATE_NOT_FOUND = 1101
|
|
145
|
+
# Канал не найден.
|
|
146
|
+
MESSAGE_STREAM_NOT_FOUND = 1226
|
|
147
|
+
# Через входящий канал нельзя отправлять письма.
|
|
148
|
+
CANNOT_SEND_VIA_INBOUND_STREAM = 1236
|
|
149
|
+
|
|
150
|
+
# Неверные параметры запроса сообщений.
|
|
151
|
+
INVALID_MESSAGES_QUERY = 700
|
|
152
|
+
# Сообщение не найдено.
|
|
153
|
+
MESSAGE_NOT_FOUND = 701
|
|
154
|
+
# Пропустить можно только заблокированное входящее письмо.
|
|
155
|
+
BYPASS_ONLY_BLOCKED = 702
|
|
156
|
+
# Повторить можно только входящее письмо с ошибкой.
|
|
157
|
+
RETRY_ONLY_FAILED = 703
|
|
158
|
+
# Неверные параметры запроса правил.
|
|
159
|
+
INVALID_RULE_REQUEST = 800
|
|
160
|
+
# Такое правило уже есть.
|
|
161
|
+
RULE_ALREADY_EXISTS = 810
|
|
162
|
+
# Правило не найдено.
|
|
163
|
+
RULE_NOT_FOUND = 812
|
|
164
|
+
# Неверное правило.
|
|
165
|
+
INVALID_RULE = 813
|
|
166
|
+
|
|
167
|
+
# Не найден домен или сервер.
|
|
168
|
+
DOMAIN_OR_SERVER_NOT_FOUND = 510
|
|
169
|
+
# Домен уже добавлен.
|
|
170
|
+
DOMAIN_ALREADY_EXISTS = 512
|
|
171
|
+
# Неверный формат домена.
|
|
172
|
+
INVALID_DOMAIN = 516
|
|
173
|
+
# Нечего проверять или неверная модель.
|
|
174
|
+
TEMPLATE_VALIDATION_NOTHING_TO_CHECK = 1120
|
|
175
|
+
|
|
176
|
+
# Некорректный входящий домен.
|
|
177
|
+
INVALID_INBOUND_DOMAIN = 600
|
|
178
|
+
# Входящий домен уже используется.
|
|
179
|
+
INBOUND_DOMAIN_IN_USE = 602
|
|
180
|
+
# Неверный InboundHookUrl.
|
|
181
|
+
INVALID_INBOUND_HOOK_URL = 606
|
|
182
|
+
# У входящего домена нет MX-записи inbound.haskimail.ru.
|
|
183
|
+
INBOUND_DOMAIN_MISSING_MX = 610
|
|
184
|
+
# Неверный InboundSpamThreshold.
|
|
185
|
+
INVALID_INBOUND_SPAM_THRESHOLD = 611
|
|
186
|
+
|
|
187
|
+
# Неверный MessageStreamType.
|
|
188
|
+
INVALID_MESSAGE_STREAM_TYPE = 1221
|
|
189
|
+
# Входящий канал создать нельзя.
|
|
190
|
+
CANNOT_CREATE_INBOUND_STREAM = 1228
|
|
191
|
+
# Входящий канал нельзя архивировать.
|
|
192
|
+
CANNOT_ARCHIVE_INBOUND_STREAM = 1229
|
|
193
|
+
|
|
194
|
+
# Russian description of every documented code.
|
|
195
|
+
DESCRIPTIONS = {
|
|
196
|
+
INVALID_API_TOKEN => "Неверный API-ключ",
|
|
197
|
+
NOT_FOUND_OR_INCOMPLETE_REQUEST => "Объект не найден или неполный запрос",
|
|
198
|
+
INTERNAL_ERROR => "Внутренняя ошибка, повторите запрос позже",
|
|
199
|
+
UNKNOWN_API_METHOD => "Неизвестный метод API",
|
|
200
|
+
MISSING_REQUIRED_FIELDS => "Не все обязательные параметры",
|
|
201
|
+
ACCOUNT_CANNOT_SEND => "Аккаунт не может отправлять",
|
|
202
|
+
INVALID_EMAIL_ADDRESS => "Некорректный email-адрес",
|
|
203
|
+
ACTION_NOT_ALLOWED => "Действие запрещено",
|
|
204
|
+
ATTACHMENT_TOO_LARGE => "Слишком большое вложение",
|
|
205
|
+
INVALID_ATTACHMENT_TYPE => "Недопустимый тип вложения",
|
|
206
|
+
SENDING_UNAVAILABLE => "Отправка недоступна",
|
|
207
|
+
TEMPORARY_SENDING_ERROR => "Временная ошибка отправки, повторите запрос позже",
|
|
208
|
+
INVALID_HEADERS => "Некорректные заголовки письма",
|
|
209
|
+
SENDER_SIGNATURE_NOT_CONFIRMED => "Адрес отправителя не подтвержден",
|
|
210
|
+
INVALID_FIELD_VALUE_OR_BATCH => "Неверное значение поля или неверный пакет писем",
|
|
211
|
+
SENDER_NOT_CONFIRMED => "Отправитель не подтвержден (аккаунт на модерации)",
|
|
212
|
+
INVALID_REQUEST_FIELD => "Неверное поле запроса (тестовый режим)",
|
|
213
|
+
FORBIDDEN_ATTACHMENT_TYPE => "Запрещенный тип вложения (тестовый режим)",
|
|
214
|
+
RECIPIENT_NOT_CONFIRMED => "Получатель не подтвержден (аккаунт на модерации)",
|
|
215
|
+
TEMPLATE_NOT_FOUND => "Шаблон не найден",
|
|
216
|
+
MESSAGE_STREAM_NOT_FOUND => "Канал не найден",
|
|
217
|
+
CANNOT_SEND_VIA_INBOUND_STREAM => "Через входящий канал нельзя отправлять письма",
|
|
218
|
+
INVALID_MESSAGES_QUERY => "Неверные параметры запроса сообщений",
|
|
219
|
+
MESSAGE_NOT_FOUND => "Сообщение не найдено",
|
|
220
|
+
BYPASS_ONLY_BLOCKED => "Пропустить можно только заблокированное входящее письмо",
|
|
221
|
+
RETRY_ONLY_FAILED => "Повторить можно только входящее письмо с ошибкой",
|
|
222
|
+
INVALID_RULE_REQUEST => "Неверные параметры запроса правил",
|
|
223
|
+
RULE_ALREADY_EXISTS => "Такое правило уже есть",
|
|
224
|
+
RULE_NOT_FOUND => "Правило не найдено",
|
|
225
|
+
INVALID_RULE => "Неверное правило",
|
|
226
|
+
DOMAIN_OR_SERVER_NOT_FOUND => "Не найден домен или сервер",
|
|
227
|
+
DOMAIN_ALREADY_EXISTS => "Домен уже добавлен",
|
|
228
|
+
INVALID_DOMAIN => "Неверный формат домена",
|
|
229
|
+
TEMPLATE_VALIDATION_NOTHING_TO_CHECK => "Нечего проверять или неверная модель",
|
|
230
|
+
INVALID_INBOUND_DOMAIN => "Некорректный входящий домен",
|
|
231
|
+
INBOUND_DOMAIN_IN_USE => "Входящий домен уже используется",
|
|
232
|
+
INVALID_INBOUND_HOOK_URL => "Неверный InboundHookUrl",
|
|
233
|
+
INBOUND_DOMAIN_MISSING_MX => "У входящего домена нет MX-записи inbound.haskimail.ru",
|
|
234
|
+
INVALID_INBOUND_SPAM_THRESHOLD => "Неверный InboundSpamThreshold",
|
|
235
|
+
INVALID_MESSAGE_STREAM_TYPE => "Неверный MessageStreamType",
|
|
236
|
+
CANNOT_CREATE_INBOUND_STREAM => "Входящий канал создать нельзя",
|
|
237
|
+
CANNOT_ARCHIVE_INBOUND_STREAM => "Входящий канал нельзя архивировать"
|
|
238
|
+
}.freeze
|
|
239
|
+
|
|
240
|
+
# @param code [Integer, nil]
|
|
241
|
+
# @return [String, nil]
|
|
242
|
+
def self.describe(code)
|
|
243
|
+
DESCRIPTIONS[code]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|