email-validation-sdk 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 +26 -0
- data/LICENSE +21 -0
- data/README.md +327 -0
- data/lib/email_validation_sdk/bulk.rb +218 -0
- data/lib/email_validation_sdk/client.rb +292 -0
- data/lib/email_validation_sdk/constants.rb +131 -0
- data/lib/email_validation_sdk/errors.rb +92 -0
- data/lib/email_validation_sdk/models.rb +219 -0
- data/lib/email_validation_sdk/transport.rb +208 -0
- data/lib/email_validation_sdk/version.rb +6 -0
- data/lib/email_validation_sdk/webhooks.rb +128 -0
- data/lib/email_validation_sdk.rb +22 -0
- metadata +56 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module EmailValidation
|
|
8
|
+
# Validated client settings. #inspect never shows the API key.
|
|
9
|
+
class Config
|
|
10
|
+
attr_reader :base_url, :timeout, :max_retries, :max_retry_wait
|
|
11
|
+
|
|
12
|
+
# No ASCII or Unicode whitespace (NBSP, U+2003, U+2028, BOM …).
|
|
13
|
+
KEY_PATTERN = /\A[^[:space:]\u{FEFF}]+\z/
|
|
14
|
+
private_constant :KEY_PATTERN
|
|
15
|
+
|
|
16
|
+
def initialize(api_key:, base_url: nil, timeout: 30, max_retries: 2, max_retry_wait: 10)
|
|
17
|
+
raise ArgumentError, "api_key is required (a non-empty key without whitespace)" unless valid_key?(api_key)
|
|
18
|
+
|
|
19
|
+
url = (base_url || DEFAULT_BASE_URL).to_s
|
|
20
|
+
raise ArgumentError, "base_url must start with http:// or https://" unless url.start_with?("https://", "http://")
|
|
21
|
+
raise ArgumentError, "timeout must be a number of seconds greater than 0" unless
|
|
22
|
+
timeout.is_a?(Numeric) && timeout.positive?
|
|
23
|
+
|
|
24
|
+
@api_key = api_key.dup.freeze
|
|
25
|
+
@base_url = url.sub(%r{/+\z}, "").freeze
|
|
26
|
+
@timeout = timeout
|
|
27
|
+
@max_retries = [Integer(max_retries), 0].max
|
|
28
|
+
@max_retry_wait = [max_retry_wait.to_f, 0.0].max
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @api private
|
|
33
|
+
def authorization
|
|
34
|
+
"Bearer #{@api_key}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inspect
|
|
38
|
+
"#<#{self.class.name} base_url=#{base_url.inspect} timeout=#{timeout} max_retries=#{max_retries} " \
|
|
39
|
+
"max_retry_wait=#{max_retry_wait} api_key=[hidden]>"
|
|
40
|
+
end
|
|
41
|
+
alias to_s inspect
|
|
42
|
+
|
|
43
|
+
def pretty_print(pp)
|
|
44
|
+
pp.text(inspect)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# A key in another encoding (or with invalid bytes) is refused with the same ArgumentError, never an
|
|
50
|
+
# Encoding error.
|
|
51
|
+
def valid_key?(api_key)
|
|
52
|
+
return false unless api_key.is_a?(String)
|
|
53
|
+
|
|
54
|
+
key = api_key.encoding == Encoding::UTF_8 ? api_key : api_key.dup.force_encoding(Encoding::UTF_8)
|
|
55
|
+
key.valid_encoding? && KEY_PATTERN.match?(key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Client for the email-validation.org REST API.
|
|
60
|
+
#
|
|
61
|
+
# client = EmailValidation::Client.new(api_key: ENV.fetch("EV_API_KEY"))
|
|
62
|
+
# result = client.validate("accepted@sandbox.email-validation.org")
|
|
63
|
+
# result.status # => "valid"
|
|
64
|
+
#
|
|
65
|
+
# Retries: answers 429 and 503 are never charged, so they are retried for every method with backoff (honouring
|
|
66
|
+
# +Retry-After+) up to +max_retries+ times, unless the wait would be longer than +max_retry_wait+ seconds (and never
|
|
67
|
+
# for +key_daily_cap+). Network failures and timeouts are retried only for GET: a check that reached the service may
|
|
68
|
+
# already have been charged.
|
|
69
|
+
#
|
|
70
|
+
# The client is safe to share between threads; build it once and reuse it.
|
|
71
|
+
class Client
|
|
72
|
+
USER_AGENT = "email-validation-ruby/#{VERSION} (ruby #{RUBY_VERSION})".freeze
|
|
73
|
+
JSON_TYPE = "application/json"
|
|
74
|
+
RETRY_STATUSES = [429, 503].freeze
|
|
75
|
+
NO_RETRY_CODES = ["key_daily_cap"].freeze
|
|
76
|
+
private_constant :JSON_TYPE, :RETRY_STATUSES, :NO_RETRY_CODES
|
|
77
|
+
|
|
78
|
+
# Settings (base URL, timeout, retries); the key is never shown.
|
|
79
|
+
attr_reader :config
|
|
80
|
+
|
|
81
|
+
# Bulk jobs: +client.bulk.create+, +get+, +start+, +cancel+, +results+, +wait+.
|
|
82
|
+
attr_reader :bulk
|
|
83
|
+
|
|
84
|
+
# Options:
|
|
85
|
+
# - +api_key+ (required): your key (+ev_live_…+). Keep it on the server.
|
|
86
|
+
# - +base_url+: default +https://api.email-validation.org+.
|
|
87
|
+
# - +timeout+: seconds per attempt (connect and the whole response), default 30. A check may take up to 15 s on
|
|
88
|
+
# our side, so keep it a few seconds above the +timeout+ you pass to #validate.
|
|
89
|
+
# - +max_retries+: retries after the first attempt, default 2 (negative values count as 0).
|
|
90
|
+
# - +max_retry_wait+: the longest single wait in seconds the client accepts before giving up, default 10.
|
|
91
|
+
# - +transport+: replaces the HTTP stack (see NetHttpTransport); a transport you pass is never closed by #close.
|
|
92
|
+
# - +sleep+, +clock+: injectable for tests (+sleep.call(seconds)+; +clock.call+ returns the current Time).
|
|
93
|
+
def initialize(api_key:, base_url: nil, timeout: 30, max_retries: 2, max_retry_wait: 10, transport: nil,
|
|
94
|
+
sleep: nil, clock: nil)
|
|
95
|
+
@config = Config.new(api_key: api_key, base_url: base_url, timeout: timeout, max_retries: max_retries,
|
|
96
|
+
max_retry_wait: max_retry_wait)
|
|
97
|
+
@owns_transport = transport.nil?
|
|
98
|
+
@transport = transport || NetHttpTransport.new
|
|
99
|
+
@sleep = sleep || ->(seconds) { Kernel.sleep(seconds) }
|
|
100
|
+
@clock = clock || -> { Time.now }
|
|
101
|
+
@bulk = Bulk.new(self)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Checks one address and returns a ValidationResult. Costs 1 credit unless the result is +unknown+ or the answer
|
|
105
|
+
# is local (malformed and sandbox addresses are free).
|
|
106
|
+
#
|
|
107
|
+
# - +ip_address+: the address the signup came from (optional).
|
|
108
|
+
# - +timeout+: seconds the service may spend on the check (5–15, server default 15). This is not the client's
|
|
109
|
+
# per-attempt timeout, which should stay a few seconds above it.
|
|
110
|
+
# - +fresh+: +true+ checks the address again even when it was checked in the last 12 months (by default the
|
|
111
|
+
# earlier definite result may be returned; +processed_at+ is when the answer was produced).
|
|
112
|
+
#
|
|
113
|
+
# A validation is never retried after a network failure or timeout (it may already have been charged).
|
|
114
|
+
def validate(email, ip_address: nil, timeout: nil, fresh: nil)
|
|
115
|
+
raise ArgumentError, "email must be a String" unless email.is_a?(String)
|
|
116
|
+
|
|
117
|
+
body = { "email" => email, "ip_address" => ip_address, "timeout" => timeout, "fresh" => fresh }.compact
|
|
118
|
+
request_model(ValidationResult, "POST", "/v1/validate", json: body)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Balance, debt, pending credits and credit lots (soonest-expiring first).
|
|
122
|
+
def credits
|
|
123
|
+
request_model(Credits, "GET", "/v1/credits")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Credit usage by day (default) or by API key (+group_by: "key"+). Dates are +"YYYY-MM-DD"+ strings (UTC) or
|
|
127
|
+
# Date objects.
|
|
128
|
+
def usage(from: nil, to: nil, group_by: nil)
|
|
129
|
+
query = { "from" => from, "to" => to, "group_by" => group_by }
|
|
130
|
+
request_model(UsageResponse, "GET", "/v1/usage", query: query)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The public service status (overall and per component).
|
|
134
|
+
def status
|
|
135
|
+
request_model(ServiceStatus, "GET", "/v1/status")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Releases the HTTP connections of the built-in transport. A transport you passed in is left open.
|
|
139
|
+
def close
|
|
140
|
+
@transport.close if @owns_transport && @transport.respond_to?(:close)
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def inspect
|
|
145
|
+
"#<#{self.class.name} base_url=#{config.base_url.inspect} timeout=#{config.timeout} " \
|
|
146
|
+
"max_retries=#{config.max_retries} api_key=[hidden]>"
|
|
147
|
+
end
|
|
148
|
+
alias to_s inspect
|
|
149
|
+
|
|
150
|
+
def pretty_print(pp)
|
|
151
|
+
pp.text(inspect)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @api private
|
|
155
|
+
def request_model(model, method, path, **options)
|
|
156
|
+
model.from_h(decode_json(perform(method, path, **options)))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @api private
|
|
160
|
+
def sleep_for(seconds)
|
|
161
|
+
@sleep.call(seconds)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @api private
|
|
165
|
+
# Sends one call with the retry policy and returns the 2xx HttpResponse. +stream+, when given, is an object with
|
|
166
|
+
# +on_head(status, headers)+ (passed to the transport as its block) and +started?+ (true once a chunk reached the
|
|
167
|
+
# caller, after which nothing is retried).
|
|
168
|
+
def perform(method, path, query: nil, json: nil, body: nil, content_type: nil, accept: JSON_TYPE, headers: nil,
|
|
169
|
+
stream: nil)
|
|
170
|
+
request = build_request(method, path, query, json, body, content_type, accept, headers)
|
|
171
|
+
attempt = 0
|
|
172
|
+
loop do
|
|
173
|
+
begin
|
|
174
|
+
response = stream ? @transport.call(request) { |s, h| stream.on_head(s, h) } : @transport.call(request)
|
|
175
|
+
rescue TransportError => e
|
|
176
|
+
if method == "GET" && attempt < config.max_retries && !stream&.started?
|
|
177
|
+
sleep_for(backoff(attempt))
|
|
178
|
+
attempt += 1
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
raise connection_error(e)
|
|
182
|
+
end
|
|
183
|
+
return response if (200..299).cover?(response.status)
|
|
184
|
+
|
|
185
|
+
error = error_from(response)
|
|
186
|
+
wait = retry_wait(error, attempt)
|
|
187
|
+
raise error if wait.nil?
|
|
188
|
+
|
|
189
|
+
sleep_for(wait)
|
|
190
|
+
attempt += 1
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @api private
|
|
195
|
+
def parse_retry_after(value)
|
|
196
|
+
return nil if value.nil?
|
|
197
|
+
|
|
198
|
+
value = value.strip
|
|
199
|
+
return Integer(value, 10) if value.match?(/\A\d+\z/)
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
at = Time.httpdate(value)
|
|
203
|
+
rescue ArgumentError
|
|
204
|
+
return nil
|
|
205
|
+
end
|
|
206
|
+
[(at - @clock.call).ceil, 0].max
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def build_request(method, path, query, json, body, content_type, accept, extra_headers)
|
|
212
|
+
url = config.base_url + path
|
|
213
|
+
encoded = encode_query(query)
|
|
214
|
+
url = "#{url}?#{encoded}" unless encoded.empty?
|
|
215
|
+
headers = { "authorization" => config.authorization, "accept" => accept, "user-agent" => USER_AGENT }
|
|
216
|
+
if json
|
|
217
|
+
body = JSON.generate(json).b
|
|
218
|
+
headers["content-type"] = JSON_TYPE
|
|
219
|
+
elsif body
|
|
220
|
+
body = body.b
|
|
221
|
+
headers["content-type"] = content_type if content_type
|
|
222
|
+
end
|
|
223
|
+
extra_headers&.each { |name, value| headers[name.to_s.downcase] = value.to_s }
|
|
224
|
+
HttpRequest.new(method: method, url: url, headers: headers, body: body, timeout: config.timeout)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def encode_query(query)
|
|
228
|
+
return "" if query.nil?
|
|
229
|
+
|
|
230
|
+
pairs = query.filter_map do |name, value|
|
|
231
|
+
next if value.nil?
|
|
232
|
+
|
|
233
|
+
[name.to_s, value.to_s] # true/false become "true"/"false"; integers decimal; Dates ISO 8601
|
|
234
|
+
end
|
|
235
|
+
URI.encode_www_form(pairs)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def decode_json(response)
|
|
239
|
+
parsed = JSON.parse(response.body.dup.force_encoding(Encoding::UTF_8))
|
|
240
|
+
raise JSON::ParserError, "not an object" unless parsed.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
parsed
|
|
243
|
+
rescue JSON::ParserError, EncodingError
|
|
244
|
+
raise Error.new("The API answered with something other than JSON.", status: response.status,
|
|
245
|
+
code: "unexpected_response",
|
|
246
|
+
request_id: response.header("x-request-id"))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def error_from(response)
|
|
250
|
+
request_id = response.header("x-request-id")
|
|
251
|
+
retry_after = parse_retry_after(response.header("retry-after"))
|
|
252
|
+
klass = EmailValidation.error_class_for_status(response.status)
|
|
253
|
+
envelope = begin
|
|
254
|
+
JSON.parse(response.body.dup.force_encoding(Encoding::UTF_8))
|
|
255
|
+
rescue JSON::ParserError, EncodingError
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
err = envelope.is_a?(Hash) ? envelope["error"] : nil
|
|
259
|
+
if err.is_a?(Hash) && ERROR_CODES.include?(err["code"])
|
|
260
|
+
code = err["code"]
|
|
261
|
+
message = err["message"].is_a?(String) ? err["message"] : code
|
|
262
|
+
request_id = err["request_id"] if err["request_id"].is_a?(String)
|
|
263
|
+
else
|
|
264
|
+
code = "unexpected_response"
|
|
265
|
+
message = "Unexpected HTTP #{response.status} from the API."
|
|
266
|
+
end
|
|
267
|
+
klass.new(message, status: response.status, code: code, request_id: request_id, retry_after: retry_after)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def retry_wait(error, attempt)
|
|
271
|
+
return nil unless RETRY_STATUSES.include?(error.status)
|
|
272
|
+
return nil if NO_RETRY_CODES.include?(error.code) || attempt >= config.max_retries
|
|
273
|
+
|
|
274
|
+
wait = error.retry_after.nil? ? backoff(attempt) : error.retry_after
|
|
275
|
+
wait <= config.max_retry_wait ? wait : nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Exponential backoff with jitter: 0.25–0.5 s, 0.5–1 s, 1–2 s … capped at 4–8 s.
|
|
279
|
+
def backoff(attempt)
|
|
280
|
+
base = [8.0, 0.5 * (2**attempt)].min
|
|
281
|
+
(base / 2) + (rand * base / 2)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def connection_error(transport_error)
|
|
285
|
+
if transport_error.timeout?
|
|
286
|
+
ConnectionError.new("The request timed out.", status: 0, code: "timeout")
|
|
287
|
+
else
|
|
288
|
+
ConnectionError.new("The API could not be reached.", status: 0, code: "connection_error")
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailValidation
|
|
4
|
+
# Public vocabulary of the email-validation.org API.
|
|
5
|
+
#
|
|
6
|
+
# Every value is a plain String: results keep the raw string even when a newer API version sends a value this SDK
|
|
7
|
+
# does not list yet (forward compatibility), so compare with the constants below rather than with a closed enum.
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.email-validation.org"
|
|
10
|
+
|
|
11
|
+
# EU data residency: validated and stored in the EU. Pass it as +base_url:+ to use the EU endpoint.
|
|
12
|
+
EU_BASE_URL = "https://api-eu.email-validation.org"
|
|
13
|
+
|
|
14
|
+
# Free test addresses: +<reason>@sandbox.email-validation.org+ answers that reason and costs nothing.
|
|
15
|
+
SANDBOX_DOMAIN = "sandbox.email-validation.org"
|
|
16
|
+
|
|
17
|
+
# Default webhook timestamp tolerance, in seconds.
|
|
18
|
+
DEFAULT_WEBHOOK_TOLERANCE = 300
|
|
19
|
+
|
|
20
|
+
STATUSES = %w[valid invalid catch_all unknown disposable].freeze
|
|
21
|
+
|
|
22
|
+
REASONS_BY_STATUS = {
|
|
23
|
+
"valid" => %w[
|
|
24
|
+
accepted alias normalized low_engagement high_engagement trusted_catch_all ai_agent role_account abuse_linked
|
|
25
|
+
suppressed trap_pattern spamtrap complainer
|
|
26
|
+
].freeze,
|
|
27
|
+
"invalid" => %w[no_mailbox no_inbound_mail invalid_syntax likely_typo no_dns mailbox_full unroutable].freeze,
|
|
28
|
+
"catch_all" => %w[catch_all role_catch_all forwarding_domain].freeze,
|
|
29
|
+
"unknown" => %w[server_unavailable temporary_error greylisted anti_spam_block timeout internal_error].freeze,
|
|
30
|
+
"disposable" => %w[disposable].freeze
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Catalogue order: by status, unknown last.
|
|
34
|
+
REASONS = %w[valid invalid catch_all disposable unknown].flat_map { |s| REASONS_BY_STATUS[s] }.freeze
|
|
35
|
+
|
|
36
|
+
ACTIONS = %w[send send_with_caution remove retry_later].freeze
|
|
37
|
+
|
|
38
|
+
# The fixed status and action of every reason.
|
|
39
|
+
REASON_INFO = {
|
|
40
|
+
"accepted" => %w[valid send], "alias" => %w[valid send], "normalized" => %w[valid send],
|
|
41
|
+
"low_engagement" => %w[valid send_with_caution], "high_engagement" => %w[valid send],
|
|
42
|
+
"trusted_catch_all" => %w[valid send], "ai_agent" => %w[valid send_with_caution],
|
|
43
|
+
"role_account" => %w[valid send_with_caution], "abuse_linked" => %w[valid remove],
|
|
44
|
+
"suppressed" => %w[valid remove], "trap_pattern" => %w[valid remove], "spamtrap" => %w[valid remove],
|
|
45
|
+
"complainer" => %w[valid remove],
|
|
46
|
+
"no_mailbox" => %w[invalid remove], "no_inbound_mail" => %w[invalid remove],
|
|
47
|
+
"invalid_syntax" => %w[invalid remove], "likely_typo" => %w[invalid remove], "no_dns" => %w[invalid remove],
|
|
48
|
+
"mailbox_full" => %w[invalid remove], "unroutable" => %w[invalid remove],
|
|
49
|
+
"catch_all" => %w[catch_all send_with_caution], "role_catch_all" => %w[catch_all send_with_caution],
|
|
50
|
+
"forwarding_domain" => %w[catch_all send_with_caution],
|
|
51
|
+
"disposable" => %w[disposable remove],
|
|
52
|
+
"server_unavailable" => %w[unknown retry_later], "temporary_error" => %w[unknown retry_later],
|
|
53
|
+
"greylisted" => %w[unknown retry_later], "anti_spam_block" => %w[unknown retry_later],
|
|
54
|
+
"timeout" => %w[unknown retry_later], "internal_error" => %w[unknown retry_later]
|
|
55
|
+
}.transform_values { |(status, action)| { status: status, action: action }.freeze }.freeze
|
|
56
|
+
|
|
57
|
+
SMTP_PROVIDERS = %w[
|
|
58
|
+
google microsoft yahoo apple zoho proton fastmail gmx yandex mailru amazon mimecast proofpoint barracuda other
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
BULK_JOB_STATUSES = %w[
|
|
62
|
+
analyzing ready queued running cancelling finalizing completed failed cancelled expired
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
TERMINAL_BULK_JOB_STATUSES = %w[completed failed cancelled expired].freeze
|
|
66
|
+
|
|
67
|
+
# Why a bulk job +failed+ or waits in +ready+ (its +error_code+), mapped to the job status it comes with.
|
|
68
|
+
BULK_JOB_ERROR_CODES = {
|
|
69
|
+
"invalid_csv" => "failed", "too_many_rows" => "failed", "empty_file" => "failed",
|
|
70
|
+
"email_column_not_found" => "failed", "upload_missing" => "failed", "pipeline_mismatch" => "failed",
|
|
71
|
+
"missing_results" => "failed", "internal_error" => "failed",
|
|
72
|
+
"insufficient_credits" => "ready", "upstream_capacity" => "ready", "account_suspended" => "ready",
|
|
73
|
+
"billing_hold" => "ready", "invalid_key" => "ready", "forbidden_scope" => "ready",
|
|
74
|
+
"key_daily_cap" => "ready", "upload_expired" => "ready"
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
# API error codes, in the API's order. Branch on these (or on the error class), never on messages.
|
|
78
|
+
ERROR_CODES = %w[
|
|
79
|
+
invalid_request unauthorized insufficient_credits forbidden forbidden_origin not_found conflict length_required
|
|
80
|
+
payload_too_large rate_limited abuse_throttled key_daily_cap internal_error service_unavailable upstream_capacity
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
# Codes the client itself uses when it got no usable answer.
|
|
84
|
+
CLIENT_ERROR_CODES = %w[connection_error timeout unexpected_response aborted].freeze
|
|
85
|
+
|
|
86
|
+
# Every +type+ a webhook delivery can carry; +webhook.test+ is sent only by "Send test" in the dashboard.
|
|
87
|
+
WEBHOOK_EVENTS = %w[bulk.completed bulk.failed credits.low credits.expiring webhook.test].freeze
|
|
88
|
+
|
|
89
|
+
# Named constants: +EmailValidation::Status::VALID+, +Reason::CATCH_ALL+, +Action::SEND_WITH_CAUTION+,
|
|
90
|
+
# +SmtpProvider::GOOGLE+, +BulkJobStatus::COMPLETED+, +ErrorCode::RATE_LIMITED+, +WebhookEventType::BULK_COMPLETED+ …
|
|
91
|
+
module Status; end
|
|
92
|
+
module Reason; end
|
|
93
|
+
module Action; end
|
|
94
|
+
module SmtpProvider; end
|
|
95
|
+
module BulkJobStatus; end
|
|
96
|
+
module ErrorCode; end
|
|
97
|
+
module WebhookEventType; end
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
Status => STATUSES, Reason => REASONS, Action => ACTIONS, SmtpProvider => SMTP_PROVIDERS,
|
|
101
|
+
BulkJobStatus => BULK_JOB_STATUSES, ErrorCode => ERROR_CODES + CLIENT_ERROR_CODES,
|
|
102
|
+
WebhookEventType => WEBHOOK_EVENTS
|
|
103
|
+
}.each do |mod, values|
|
|
104
|
+
values.each do |value|
|
|
105
|
+
name = value.upcase.tr(".", "_")
|
|
106
|
+
mod.const_set(name, value) unless mod.const_defined?(name, false)
|
|
107
|
+
end
|
|
108
|
+
mod.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
module_function
|
|
112
|
+
|
|
113
|
+
# True when +value+ is one of the five result statuses.
|
|
114
|
+
def status?(value)
|
|
115
|
+
STATUSES.include?(value)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True when +value+ is a known reason.
|
|
119
|
+
def reason?(value)
|
|
120
|
+
REASONS.include?(value)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# +<reason>@sandbox.email-validation.org+: a free test address that answers +reason+.
|
|
124
|
+
# Raises ArgumentError for an unknown reason.
|
|
125
|
+
def sandbox_address(reason)
|
|
126
|
+
reason = reason.to_s
|
|
127
|
+
raise ArgumentError, "unknown reason: #{reason.inspect}" unless reason?(reason)
|
|
128
|
+
|
|
129
|
+
"#{reason}@#{SANDBOX_DOMAIN}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EmailValidation
|
|
4
|
+
# Base class of every error the client raises. Branch on the class or on #code, never on #message.
|
|
5
|
+
#
|
|
6
|
+
# Raised directly only when a 2xx answer is not JSON (code +unexpected_response+).
|
|
7
|
+
#
|
|
8
|
+
# Attributes:
|
|
9
|
+
# - +status+: the HTTP status, or 0 when no answer arrived (or the SDK refused the call before sending).
|
|
10
|
+
# - +code+: an API error code (+insufficient_credits+, +rate_limited+ …) or a client code (+connection_error+,
|
|
11
|
+
# +timeout+, +unexpected_response+, +aborted+).
|
|
12
|
+
# - +request_id+: our id for the request (quote it to support), when the API answered.
|
|
13
|
+
# - +retry_after+: whole seconds from the +Retry-After+ header, or nil.
|
|
14
|
+
# - +cause+: the underlying exception, when there is one (Ruby sets it automatically).
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
attr_reader :status, :code, :request_id, :retry_after
|
|
17
|
+
|
|
18
|
+
def initialize(message = nil, status: 0, code: "unexpected_response", request_id: nil, retry_after: nil)
|
|
19
|
+
super(message || code)
|
|
20
|
+
@status = status
|
|
21
|
+
@code = code
|
|
22
|
+
@request_id = request_id
|
|
23
|
+
@retry_after = retry_after
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The canonical kind name, e.g. "RateLimitError" (the class name without the module).
|
|
27
|
+
def kind
|
|
28
|
+
self.class.name.split("::").last
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Shown for uncaught errors: kind, status, code and request id. Never the key, the body or the checked address.
|
|
32
|
+
def detailed_message(highlight: false, **)
|
|
33
|
+
"#{message} (#{self.class.name}: status=#{status} code=#{code} request_id=#{request_id || "-"})"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def inspect
|
|
37
|
+
"#<#{self.class.name} status=#{status} code=#{code.inspect} request_id=#{request_id.inspect} " \
|
|
38
|
+
"retry_after=#{retry_after.inspect} message=#{message.inspect}>"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The canonical name of the base kind.
|
|
43
|
+
EmailValidationError = Error
|
|
44
|
+
|
|
45
|
+
# 400, 411, 413, any other 4xx without its own class, checks refused before sending (status 0), and a ranged
|
|
46
|
+
# download answered without 206: fix the request.
|
|
47
|
+
class InvalidRequestError < Error; end
|
|
48
|
+
|
|
49
|
+
# 401: missing, malformed, revoked or rolled key.
|
|
50
|
+
class AuthenticationError < Error; end
|
|
51
|
+
|
|
52
|
+
# 402: not enough credits. Nothing was charged.
|
|
53
|
+
class InsufficientCreditsError < Error; end
|
|
54
|
+
|
|
55
|
+
# 403: missing scope, disallowed origin, suspended account or billing hold.
|
|
56
|
+
class PermissionDeniedError < Error; end
|
|
57
|
+
|
|
58
|
+
# 404: no such resource for this account.
|
|
59
|
+
class NotFoundError < Error; end
|
|
60
|
+
|
|
61
|
+
# 409: the resource is in the wrong state.
|
|
62
|
+
class ConflictError < Error; end
|
|
63
|
+
|
|
64
|
+
# 429: slow down. Retried automatically when the wait is short (except +key_daily_cap+).
|
|
65
|
+
class RateLimitError < Error; end
|
|
66
|
+
|
|
67
|
+
# 503: temporarily unavailable, nothing charged. Retried automatically like 429.
|
|
68
|
+
class ServiceUnavailableError < Error; end
|
|
69
|
+
|
|
70
|
+
# 500, any other status >= 500 and any other unexpected status (e.g. a 3xx redirect, which is never followed).
|
|
71
|
+
class ServerError < Error; end
|
|
72
|
+
|
|
73
|
+
# No HTTP answer: network failure (+connection_error+), attempt timeout (+timeout+), or a #wait deadline
|
|
74
|
+
# (+timeout+). Status is 0.
|
|
75
|
+
class ConnectionError < Error; end
|
|
76
|
+
|
|
77
|
+
# A webhook delivery failed a header, timestamp, secret or signature check. Not part of the Error family.
|
|
78
|
+
class WebhookVerificationError < StandardError; end
|
|
79
|
+
|
|
80
|
+
# @api private
|
|
81
|
+
ERRORS_BY_STATUS = {
|
|
82
|
+
400 => InvalidRequestError, 401 => AuthenticationError, 402 => InsufficientCreditsError,
|
|
83
|
+
403 => PermissionDeniedError, 404 => NotFoundError, 409 => ConflictError, 411 => InvalidRequestError,
|
|
84
|
+
413 => InvalidRequestError, 429 => RateLimitError, 503 => ServiceUnavailableError
|
|
85
|
+
}.freeze
|
|
86
|
+
private_constant :ERRORS_BY_STATUS
|
|
87
|
+
|
|
88
|
+
# @api private
|
|
89
|
+
def self.error_class_for_status(status)
|
|
90
|
+
ERRORS_BY_STATUS.fetch(status) { (400..499).cover?(status) ? InvalidRequestError : ServerError }
|
|
91
|
+
end
|
|
92
|
+
end
|