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,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module EmailValidation
|
|
6
|
+
# Shared behaviour of the response models (immutable +Data+ classes).
|
|
7
|
+
#
|
|
8
|
+
# - +Model.from_h(hash)+ decodes the API's JSON object (string or symbol keys). Unknown fields are ignored and
|
|
9
|
+
# unknown enum values are kept as the raw string, so a newer API never breaks decoding.
|
|
10
|
+
# - +#to_h+ returns the API's field names (as symbols) with nested models converted and +nil+ kept;
|
|
11
|
+
# +#to_json+ re-encodes the API's JSON.
|
|
12
|
+
module Model
|
|
13
|
+
# @api private
|
|
14
|
+
module ClassMethods
|
|
15
|
+
def from_h(hash)
|
|
16
|
+
raise ArgumentError, "#{name} expects a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
values = members.to_h do |member|
|
|
19
|
+
raw = hash.key?(member.to_s) ? hash[member.to_s] : hash[member]
|
|
20
|
+
[member, decode_member(member, raw, hash)]
|
|
21
|
+
end
|
|
22
|
+
new(**values)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Overridden by models with nested fields.
|
|
28
|
+
def decode_member(_member, raw, _hash)
|
|
29
|
+
raw
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.included(base)
|
|
34
|
+
base.extend(ClassMethods)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.deep(value)
|
|
38
|
+
case value
|
|
39
|
+
when Model then value.to_h
|
|
40
|
+
when Array then value.map { |v| deep(v) }
|
|
41
|
+
when Hash then value.transform_values { |v| deep(v) }
|
|
42
|
+
else value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
super.transform_values { |v| Model.deep(v) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_json(*args)
|
|
51
|
+
to_h.to_json(*args)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Counts by result status. In bulk jobs and webhook events the object is partial (only the statuses seen so far),
|
|
56
|
+
# so absent statuses are nil and are left out again by #to_h.
|
|
57
|
+
StatusCounts = Data.define(:valid, :invalid, :catch_all, :unknown, :disposable) do
|
|
58
|
+
include Model
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
super.compact
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The result of one check (+POST /v1/validate+). Fields in the API's order.
|
|
66
|
+
ValidationResult = Data.define(
|
|
67
|
+
:email, :status, :reason, :action, :account, :domain, :did_you_mean, :free_email, :role_account, :disposable,
|
|
68
|
+
:catch_all_domain, :mx_found, :mx_record, :smtp_provider, :domain_age_days, :processed_at, :credits_used,
|
|
69
|
+
:request_id
|
|
70
|
+
) do
|
|
71
|
+
include Model
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# One lot of purchased credits.
|
|
75
|
+
CreditLot = Data.define(:remaining, :expires_at) do
|
|
76
|
+
include Model
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# +GET /v1/credits+: balance, debt, pending credits and lots (soonest-expiring first).
|
|
80
|
+
Credits = Data.define(:balance, :debt, :pending, :lots) do
|
|
81
|
+
include Model
|
|
82
|
+
|
|
83
|
+
def self.decode_member(member, raw, _hash)
|
|
84
|
+
member == :lots && raw.is_a?(Array) ? raw.map { |lot| CreditLot.from_h(lot) } : raw
|
|
85
|
+
end
|
|
86
|
+
private_class_method :decode_member
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Usage totals for the period.
|
|
90
|
+
UsageTotals = Data.define(:credits_used, :by_status) do
|
|
91
|
+
include Model
|
|
92
|
+
|
|
93
|
+
def self.decode_member(member, raw, _hash)
|
|
94
|
+
member == :by_status && raw.is_a?(Hash) ? StatusCounts.from_h(raw) : raw
|
|
95
|
+
end
|
|
96
|
+
private_class_method :decode_member
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# One day of usage (+group_by: "day"+). +by_status+ is approximate.
|
|
100
|
+
UsageDay = Data.define(:date, :credits_used, :by_status) do
|
|
101
|
+
include Model
|
|
102
|
+
|
|
103
|
+
def self.decode_member(member, raw, _hash)
|
|
104
|
+
member == :by_status && raw.is_a?(Hash) ? StatusCounts.from_h(raw) : raw
|
|
105
|
+
end
|
|
106
|
+
private_class_method :decode_member
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Usage of one API key (+group_by: "key"+). +name+ and +last4+ are nil for a deleted key.
|
|
110
|
+
UsageByKey = Data.define(:key_id, :name, :last4, :credits_used) do
|
|
111
|
+
include Model
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# +GET /v1/usage+. +items+ holds UsageDay values when +group_by+ is "day" and UsageByKey values when "key".
|
|
115
|
+
UsageResponse = Data.define(:from, :to, :group_by, :totals, :items) do
|
|
116
|
+
include Model
|
|
117
|
+
|
|
118
|
+
def self.decode_member(member, raw, hash)
|
|
119
|
+
case member
|
|
120
|
+
when :totals then raw.is_a?(Hash) ? UsageTotals.from_h(raw) : raw
|
|
121
|
+
when :items
|
|
122
|
+
return raw unless raw.is_a?(Array)
|
|
123
|
+
|
|
124
|
+
group_by = hash["group_by"] || hash[:group_by]
|
|
125
|
+
item_class = { "day" => UsageDay, "key" => UsageByKey }[group_by]
|
|
126
|
+
item_class ? raw.map { |item| item_class.from_h(item) } : raw
|
|
127
|
+
else raw
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
private_class_method :decode_member
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A bulk job. Fields in the API's order; the counters from +total_rows+ to +local_answered+ are nil while the job
|
|
134
|
+
# is +analyzing+.
|
|
135
|
+
BulkJob = Data.define(
|
|
136
|
+
:id, :status, :filename, :total_rows, :unique_emails, :duplicates, :syntax_invalid, :local_answered, :processed,
|
|
137
|
+
:credits_reserved, :credits_charged, :counts, :error_code, :webhook_endpoint_id, :created_at, :started_at,
|
|
138
|
+
:finished_at, :expires_at
|
|
139
|
+
) do
|
|
140
|
+
include Model
|
|
141
|
+
|
|
142
|
+
def self.decode_member(member, raw, _hash)
|
|
143
|
+
member == :counts && raw.is_a?(Hash) ? StatusCounts.from_h(raw) : raw
|
|
144
|
+
end
|
|
145
|
+
private_class_method :decode_member
|
|
146
|
+
|
|
147
|
+
# Completed, failed, cancelled or expired: the job will not change any more.
|
|
148
|
+
def terminal?
|
|
149
|
+
TERMINAL_BULK_JOB_STATUSES.include?(status)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# In +ready+ with an +error_code+: its start was refused (e.g. +insufficient_credits+) and it never moves on.
|
|
153
|
+
def stuck?
|
|
154
|
+
status == "ready" && error_code.is_a?(String) && !error_code.empty?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The state of each public component.
|
|
159
|
+
ServiceComponents = Data.define(:website, :api, :bulk, :sign_in, :payments, :support) do
|
|
160
|
+
include Model
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# +GET /v1/status+. Each state is "operational", "degraded" or "outage".
|
|
164
|
+
ServiceStatus = Data.define(:status, :components, :updated_at) do
|
|
165
|
+
include Model
|
|
166
|
+
|
|
167
|
+
def self.decode_member(member, raw, _hash)
|
|
168
|
+
member == :components && raw.is_a?(Hash) ? ServiceComponents.from_h(raw) : raw
|
|
169
|
+
end
|
|
170
|
+
private_class_method :decode_member
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# +data+ of +bulk.completed+ and +bulk.failed+ events.
|
|
174
|
+
BulkEventData = Data.define(
|
|
175
|
+
:job_id, :status, :total_rows, :unique_emails, :processed, :credits_charged, :counts, :error_code
|
|
176
|
+
) do
|
|
177
|
+
include Model
|
|
178
|
+
|
|
179
|
+
def self.decode_member(member, raw, _hash)
|
|
180
|
+
member == :counts && raw.is_a?(Hash) ? StatusCounts.from_h(raw) : raw
|
|
181
|
+
end
|
|
182
|
+
private_class_method :decode_member
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# +data+ of +credits.low+ events.
|
|
186
|
+
CreditsLowData = Data.define(:balance, :threshold) do
|
|
187
|
+
include Model
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# +data+ of +credits.expiring+ events.
|
|
191
|
+
CreditsExpiringData = Data.define(:credits, :expires_at) do
|
|
192
|
+
include Model
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# +data+ of +webhook.test+ events (sent by "Send test" in the dashboard).
|
|
196
|
+
WebhookTestData = Data.define(:endpoint_id) do
|
|
197
|
+
include Model
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
WEBHOOK_DATA_CLASSES = {
|
|
201
|
+
"bulk.completed" => BulkEventData, "bulk.failed" => BulkEventData, "credits.low" => CreditsLowData,
|
|
202
|
+
"credits.expiring" => CreditsExpiringData, "webhook.test" => WebhookTestData
|
|
203
|
+
}.freeze
|
|
204
|
+
private_constant :WEBHOOK_DATA_CLASSES
|
|
205
|
+
|
|
206
|
+
# A verified webhook delivery. +data+ is a typed model for the known event types and the raw Hash (string keys)
|
|
207
|
+
# for a type this SDK does not know yet.
|
|
208
|
+
WebhookEvent = Data.define(:type, :timestamp, :data) do
|
|
209
|
+
include Model
|
|
210
|
+
|
|
211
|
+
def self.decode_member(member, raw, hash)
|
|
212
|
+
return raw unless member == :data && raw.is_a?(Hash)
|
|
213
|
+
|
|
214
|
+
data_class = WEBHOOK_DATA_CLASSES[hash["type"] || hash[:type]]
|
|
215
|
+
data_class ? data_class.from_h(raw) : raw
|
|
216
|
+
end
|
|
217
|
+
private_class_method :decode_member
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module EmailValidation
|
|
9
|
+
# One HTTP request as the client hands it to a transport.
|
|
10
|
+
#
|
|
11
|
+
# #inspect and #to_s hide the headers (they carry the API key) and the body (it carries the checked address).
|
|
12
|
+
class HttpRequest
|
|
13
|
+
attr_reader :method, :url, :headers, :body, :timeout
|
|
14
|
+
|
|
15
|
+
# +headers+ is a Hash with lower-case names; +body+ is a binary String or nil; +timeout+ is seconds per attempt.
|
|
16
|
+
def initialize(method:, url:, headers:, body:, timeout:)
|
|
17
|
+
@method = method
|
|
18
|
+
@url = url
|
|
19
|
+
@headers = headers.freeze
|
|
20
|
+
@body = body&.freeze
|
|
21
|
+
@timeout = timeout
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def inspect
|
|
26
|
+
"#<#{self.class.name} #{method} #{url} headers=[hidden] body=[hidden]>"
|
|
27
|
+
end
|
|
28
|
+
alias to_s inspect
|
|
29
|
+
|
|
30
|
+
def pretty_print(pp)
|
|
31
|
+
pp.text(inspect)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# One HTTP answer as a transport hands it back. +headers+ is a Hash with lower-case names (several values joined
|
|
36
|
+
# with ", "); +body+ is a binary String ("" when the body was streamed to a sink).
|
|
37
|
+
class HttpResponse
|
|
38
|
+
attr_reader :status, :headers, :body
|
|
39
|
+
|
|
40
|
+
def initialize(status:, headers: {}, body: "")
|
|
41
|
+
@status = Integer(status)
|
|
42
|
+
@headers = headers.to_h { |k, v| [k.to_s.downcase, v.to_s] }.freeze
|
|
43
|
+
@body = (body || "").b.freeze
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The value of a header (any case), or nil.
|
|
48
|
+
def header(name)
|
|
49
|
+
headers[name.to_s.downcase]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inspect
|
|
53
|
+
"#<#{self.class.name} status=#{status} bytes=#{body.bytesize}>"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Raised by a transport when a request got no HTTP answer. #timeout? tells a timeout from a network failure.
|
|
58
|
+
class TransportError < StandardError
|
|
59
|
+
def initialize(message = "The API could not be reached.", timeout: false)
|
|
60
|
+
super(message)
|
|
61
|
+
@timeout = timeout
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def timeout?
|
|
65
|
+
@timeout
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The default transport, on the standard library's Net::HTTP. Redirects are never followed (Net::HTTP does not
|
|
70
|
+
# follow them), connections are kept alive and reused across threads through a small pool, and the client's
|
|
71
|
+
# +timeout+ bounds every attempt (connect, TLS, request and the whole response).
|
|
72
|
+
#
|
|
73
|
+
# A transport is any object with +call(request)+ returning an HttpResponse (raise TransportError when no answer
|
|
74
|
+
# arrives). Optional streaming: when +call+ is given a block, the transport calls it once with
|
|
75
|
+
# +(status, headers)+ as soon as the response head arrives; if the block returns a callable, the transport passes
|
|
76
|
+
# each body chunk to it (instead of buffering the body) and returns an HttpResponse with an empty body.
|
|
77
|
+
class NetHttpTransport
|
|
78
|
+
NETWORK_ERRORS = [
|
|
79
|
+
IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError, Net::ProtocolError,
|
|
80
|
+
Net::HTTPBadResponse, Zlib::Error
|
|
81
|
+
].freeze
|
|
82
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error].freeze
|
|
83
|
+
private_constant :NETWORK_ERRORS, :TIMEOUT_ERRORS
|
|
84
|
+
|
|
85
|
+
# Raised by the attempt deadline. A private class outside StandardError, so neither a timeout raised by other
|
|
86
|
+
# code nor a +rescue => e+ in a results block can be confused with it or swallow it.
|
|
87
|
+
class AttemptTimeout < Exception; end # rubocop:disable Lint/InheritException
|
|
88
|
+
private_constant :AttemptTimeout
|
|
89
|
+
|
|
90
|
+
# +max_idle+: idle connections kept per host. +keep_alive_timeout+: seconds an idle connection may be reused.
|
|
91
|
+
def initialize(max_idle: 8, keep_alive_timeout: 15)
|
|
92
|
+
@max_idle = max_idle
|
|
93
|
+
@keep_alive_timeout = keep_alive_timeout
|
|
94
|
+
@idle = Hash.new { |h, k| h[k] = [] }
|
|
95
|
+
@lock = Mutex.new
|
|
96
|
+
@closed = false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def call(request, &on_head)
|
|
100
|
+
uri = URI.parse(request.url)
|
|
101
|
+
conn = nil
|
|
102
|
+
reusable = false
|
|
103
|
+
begin
|
|
104
|
+
response = Timeout.timeout(request.timeout, AttemptTimeout) do
|
|
105
|
+
conn = checkout(uri, request.timeout)
|
|
106
|
+
exchange(conn, uri, request, on_head)
|
|
107
|
+
end
|
|
108
|
+
reusable = true
|
|
109
|
+
response
|
|
110
|
+
rescue AttemptTimeout, *TIMEOUT_ERRORS
|
|
111
|
+
raise TransportError.new("The request timed out.", timeout: true)
|
|
112
|
+
rescue *NETWORK_ERRORS
|
|
113
|
+
raise TransportError, "The API could not be reached."
|
|
114
|
+
ensure
|
|
115
|
+
reusable ? checkin(uri, conn) : discard(conn)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Closes the idle connections. Safe to call more than once.
|
|
120
|
+
def close
|
|
121
|
+
conns = @lock.synchronize do
|
|
122
|
+
@closed = true
|
|
123
|
+
all = @idle.values.flatten
|
|
124
|
+
@idle.clear
|
|
125
|
+
all
|
|
126
|
+
end
|
|
127
|
+
conns.each { |c| discard(c) }
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def inspect
|
|
132
|
+
"#<#{self.class.name}>"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def exchange(conn, uri, request, on_head)
|
|
138
|
+
req = build_request(uri, request)
|
|
139
|
+
status = nil
|
|
140
|
+
headers = nil
|
|
141
|
+
body = nil
|
|
142
|
+
conn.request(req) do |res|
|
|
143
|
+
status = res.code.to_i
|
|
144
|
+
headers = {}
|
|
145
|
+
res.each_header { |name, value| headers[name] = value }
|
|
146
|
+
sink = on_head&.call(status, headers)
|
|
147
|
+
if sink
|
|
148
|
+
res.read_body { |chunk| sink.call(chunk) }
|
|
149
|
+
body = ""
|
|
150
|
+
else
|
|
151
|
+
body = res.read_body.to_s
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
HttpResponse.new(status: status, headers: headers, body: body)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_request(uri, request)
|
|
158
|
+
has_body = !request.body.nil?
|
|
159
|
+
# Net::HTTPGenericRequest lets a body-less POST go out without a body or a default Content-Type.
|
|
160
|
+
req = Net::HTTPGenericRequest.new(request.method, has_body, true, uri, request.headers.dup)
|
|
161
|
+
req.body = request.body if has_body
|
|
162
|
+
req
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def checkout(uri, timeout)
|
|
166
|
+
key = pool_key(uri)
|
|
167
|
+
conn = @lock.synchronize { @idle[key].pop }
|
|
168
|
+
return configure(conn, timeout) if conn&.started?
|
|
169
|
+
|
|
170
|
+
conn = Net::HTTP.new(uri.host, uri.port)
|
|
171
|
+
conn.use_ssl = uri.scheme == "https"
|
|
172
|
+
conn.max_retries = 0 # the client owns the retry policy (a POST that reached us may have been charged)
|
|
173
|
+
conn.keep_alive_timeout = @keep_alive_timeout
|
|
174
|
+
configure(conn, timeout)
|
|
175
|
+
conn.start
|
|
176
|
+
conn
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def configure(conn, timeout)
|
|
180
|
+
conn.open_timeout = timeout
|
|
181
|
+
conn.read_timeout = timeout
|
|
182
|
+
conn.write_timeout = timeout
|
|
183
|
+
conn.ssl_timeout = timeout if conn.use_ssl?
|
|
184
|
+
conn
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def checkin(uri, conn)
|
|
188
|
+
keep = @lock.synchronize do
|
|
189
|
+
list = @idle[pool_key(uri)]
|
|
190
|
+
next false if @closed || list.size >= @max_idle
|
|
191
|
+
|
|
192
|
+
list.push(conn)
|
|
193
|
+
true
|
|
194
|
+
end
|
|
195
|
+
discard(conn) unless keep
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def discard(conn)
|
|
199
|
+
conn.finish if conn&.started?
|
|
200
|
+
rescue StandardError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def pool_key(uri)
|
|
205
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module EmailValidation
|
|
7
|
+
# Webhook signature verification (Standard Webhooks scheme).
|
|
8
|
+
#
|
|
9
|
+
# Signed content: +<webhook-id>.<webhook-timestamp>.<raw body>+, HMAC-SHA256 with the endpoint secret (the part
|
|
10
|
+
# after +whsec_+, base64-decoded); header +webhook-signature: v1,<base64> [v1,<base64> …]+.
|
|
11
|
+
module Webhooks
|
|
12
|
+
MISSING_HEADERS = "Missing webhook headers."
|
|
13
|
+
INVALID_TIMESTAMP = "Invalid webhook timestamp."
|
|
14
|
+
OUT_OF_TOLERANCE = "Webhook timestamp is outside the allowed tolerance."
|
|
15
|
+
MISSING_SECRET = "Missing webhook secret."
|
|
16
|
+
INVALID_SECRET = "The webhook secret is not valid (expected whsec_ followed by base64)."
|
|
17
|
+
NO_MATCH = "No webhook signature matched."
|
|
18
|
+
INVALID_BODY = "The webhook body is not JSON."
|
|
19
|
+
TIMESTAMP = /\A[0-9]{1,12}\z/
|
|
20
|
+
BASE64 = %r{\A[A-Za-z0-9+/]*={0,2}\z}
|
|
21
|
+
private_constant :MISSING_HEADERS, :INVALID_TIMESTAMP, :OUT_OF_TOLERANCE, :MISSING_SECRET, :INVALID_SECRET,
|
|
22
|
+
:NO_MATCH, :INVALID_BODY, :TIMESTAMP, :BASE64
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Verifies a delivery and returns the WebhookEvent. Raises WebhookVerificationError otherwise.
|
|
27
|
+
#
|
|
28
|
+
# - +payload+: the raw request body exactly as received (before any JSON parsing), as a String.
|
|
29
|
+
# - +headers+: the request headers, any Hash-like object (names in any case; a Rack env with +HTTP_WEBHOOK_ID+
|
|
30
|
+
# style keys works too). Several values for one header are joined with a space.
|
|
31
|
+
# - +secret+: the endpoint's signing secret (+whsec_…+).
|
|
32
|
+
# - +tolerance+: seconds the timestamp may differ from now (default 300; nil means the default). +now+: injectable (Integer or Time).
|
|
33
|
+
#
|
|
34
|
+
# Deduplicate on the +webhook-id+ header (retries reuse it) and answer 2xx quickly.
|
|
35
|
+
def verify(payload, headers, secret, tolerance: DEFAULT_WEBHOOK_TOLERANCE, now: nil)
|
|
36
|
+
msg_id = header(headers, "webhook-id")
|
|
37
|
+
timestamp = header(headers, "webhook-timestamp")
|
|
38
|
+
signatures = header(headers, "webhook-signature")
|
|
39
|
+
fail!(MISSING_HEADERS) if [msg_id, timestamp, signatures].any? { |v| v.nil? || v.empty? }
|
|
40
|
+
fail!(INVALID_TIMESTAMP) unless TIMESTAMP.match?(timestamp)
|
|
41
|
+
tolerance = DEFAULT_WEBHOOK_TOLERANCE if tolerance.nil?
|
|
42
|
+
current = now.nil? ? Time.now.to_i : now.to_i
|
|
43
|
+
fail!(OUT_OF_TOLERANCE) if (current - Integer(timestamp, 10)).abs > tolerance
|
|
44
|
+
|
|
45
|
+
body = bytes(payload)
|
|
46
|
+
expected = OpenSSL::HMAC.digest("SHA256", secret_key(secret), "#{msg_id}.#{timestamp}.".b + body)
|
|
47
|
+
signatures.split(/ /).each do |entry|
|
|
48
|
+
version, signature = entry.split(",", 2)
|
|
49
|
+
next unless version == "v1" && signature && !signature.empty?
|
|
50
|
+
|
|
51
|
+
given = decode64(signature)
|
|
52
|
+
next if given.nil? || given.bytesize != expected.bytesize
|
|
53
|
+
next unless OpenSSL.fixed_length_secure_compare(given, expected)
|
|
54
|
+
|
|
55
|
+
return parse_event(body)
|
|
56
|
+
end
|
|
57
|
+
fail!(NO_MATCH)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The +webhook-signature+ value (+"v1,<base64>"+) for a payload: useful to test your endpoint locally.
|
|
61
|
+
def sign(msg_id, timestamp, payload, secret)
|
|
62
|
+
digest = OpenSSL::HMAC.digest("SHA256", secret_key(secret), "#{msg_id}.#{Integer(timestamp)}.".b + bytes(payload))
|
|
63
|
+
"v1,#{[digest].pack("m0")}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def header(headers, name)
|
|
67
|
+
return nil unless headers.respond_to?(:each)
|
|
68
|
+
|
|
69
|
+
rack_name = "HTTP_#{name.upcase.tr("-", "_")}"
|
|
70
|
+
values = []
|
|
71
|
+
headers.each do |key, value|
|
|
72
|
+
key = key.to_s
|
|
73
|
+
next unless key.casecmp?(name) || key == rack_name
|
|
74
|
+
|
|
75
|
+
Array(value).each { |v| values << v.to_s }
|
|
76
|
+
end
|
|
77
|
+
values.empty? ? nil : values.join(" ")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def secret_key(secret)
|
|
81
|
+
fail!(MISSING_SECRET) if !secret.is_a?(String) || secret.empty?
|
|
82
|
+
raw = secret.delete_prefix("whsec_")
|
|
83
|
+
key = decode64(raw)
|
|
84
|
+
fail!(INVALID_SECRET) if key.nil? || key.empty?
|
|
85
|
+
key
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def decode64(text)
|
|
89
|
+
return nil unless BASE64.match?(text) && (text.length % 4).zero?
|
|
90
|
+
|
|
91
|
+
text.unpack1("m0")
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def bytes(payload)
|
|
97
|
+
raise ArgumentError, "payload must be the raw body as a String" unless payload.respond_to?(:to_str)
|
|
98
|
+
|
|
99
|
+
payload.to_str.b
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_event(body)
|
|
103
|
+
parsed = JSON.parse(body.dup.force_encoding(Encoding::UTF_8))
|
|
104
|
+
fail!(INVALID_BODY) unless parsed.is_a?(Hash)
|
|
105
|
+
WebhookEvent.from_h(parsed)
|
|
106
|
+
rescue JSON::ParserError, EncodingError
|
|
107
|
+
fail!(INVALID_BODY)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fail!(message)
|
|
111
|
+
raise WebhookVerificationError, message
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private_class_method :header, :secret_key, :decode64, :bytes, :parse_event, :fail!
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
module_function
|
|
118
|
+
|
|
119
|
+
# Verifies a webhook delivery; see Webhooks.verify.
|
|
120
|
+
def verify_webhook(payload, headers, secret, tolerance: DEFAULT_WEBHOOK_TOLERANCE, now: nil)
|
|
121
|
+
Webhooks.verify(payload, headers, secret, tolerance: tolerance, now: now)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Signs a payload like the service does; see Webhooks.sign.
|
|
125
|
+
def sign_webhook(msg_id, timestamp, payload, secret)
|
|
126
|
+
Webhooks.sign(msg_id, timestamp, payload, secret)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Official Ruby client for the email-validation.org REST API.
|
|
4
|
+
#
|
|
5
|
+
# require "email_validation_sdk"
|
|
6
|
+
#
|
|
7
|
+
# client = EmailValidation::Client.new(api_key: ENV.fetch("EV_API_KEY"))
|
|
8
|
+
# result = client.validate("accepted@sandbox.email-validation.org")
|
|
9
|
+
# puts result.status # => valid
|
|
10
|
+
#
|
|
11
|
+
# Documentation: https://email-validation.org/docs/sdks/ruby/
|
|
12
|
+
module EmailValidation
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "email_validation_sdk/version"
|
|
16
|
+
require_relative "email_validation_sdk/constants"
|
|
17
|
+
require_relative "email_validation_sdk/errors"
|
|
18
|
+
require_relative "email_validation_sdk/models"
|
|
19
|
+
require_relative "email_validation_sdk/transport"
|
|
20
|
+
require_relative "email_validation_sdk/client"
|
|
21
|
+
require_relative "email_validation_sdk/bulk"
|
|
22
|
+
require_relative "email_validation_sdk/webhooks"
|
metadata
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: email-validation-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- email-validation.org
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Validate addresses, read credits and usage, run bulk jobs and verify
|
|
13
|
+
webhook signatures with the email-validation.org REST API. No runtime dependencies.
|
|
14
|
+
email:
|
|
15
|
+
- support@email-validation.org
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/email_validation_sdk.rb
|
|
24
|
+
- lib/email_validation_sdk/bulk.rb
|
|
25
|
+
- lib/email_validation_sdk/client.rb
|
|
26
|
+
- lib/email_validation_sdk/constants.rb
|
|
27
|
+
- lib/email_validation_sdk/errors.rb
|
|
28
|
+
- lib/email_validation_sdk/models.rb
|
|
29
|
+
- lib/email_validation_sdk/transport.rb
|
|
30
|
+
- lib/email_validation_sdk/version.rb
|
|
31
|
+
- lib/email_validation_sdk/webhooks.rb
|
|
32
|
+
homepage: https://email-validation.org/docs/sdks/ruby/
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://email-validation.org/docs/sdks/ruby/
|
|
37
|
+
documentation_uri: https://email-validation.org/docs/
|
|
38
|
+
rubygems_mfa_required: 'true'
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.2'
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 4.0.23
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: Official Ruby client for the email-validation.org email validation API.
|
|
56
|
+
test_files: []
|