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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmailValidation
4
+ # SDK version (Semantic Versioning). Sent in the User-Agent header.
5
+ VERSION = "1.0.0"
6
+ 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: []