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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cef3267cdbc0f471acc37793832fa635ad5fae74f6a12e885dd852ee9f2e6673
|
|
4
|
+
data.tar.gz: 8bf364214be99dc1b0faef104424c0585ac41808b3034b8b26dbde4596e41858
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 986d270b3fb54ceef83d8cd1ea279ddd57d4cc5b20136978a58c87d1c41a6de1321442cb69a175d368ddb1037c6b594e15dd1268c08b097af30d4170cd276178
|
|
7
|
+
data.tar.gz: 0751cdba9c258ca15890e9d94a862e061292b95c50b73d8b43ac0deb1f420ba6618a9c03726e060dd4e5f81a453210b5885a6c68e9cb2032059537a1f1f59f2f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the Ruby SDK (`email-validation-sdk`). The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the SDK uses [Semantic Versioning](https://semver.org/):
|
|
5
|
+
a new vocabulary value, option or operation is a minor release; a removed or renamed public symbol is a major one.
|
|
6
|
+
|
|
7
|
+
## [1.0.0] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
First version, with the same behaviour as the JavaScript and Python SDKs (API v1).
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The entry file is `email_validation_sdk.rb` (`require "email_validation_sdk"`), so the gem never collides with the
|
|
14
|
+
unrelated `email_validation` gem on the load path.
|
|
15
|
+
|
|
16
|
+
- `EmailValidation::Client` with `validate`, `credits`, `usage` and `status`, and `client.bulk` with `create`, `get`,
|
|
17
|
+
`start`, `cancel`, `results` (whole body or streamed in chunks with a block, optional `range`) and `wait`.
|
|
18
|
+
- Immutable `Data` models (`ValidationResult`, `Credits`, `UsageResponse`, `BulkJob`, `ServiceStatus`,
|
|
19
|
+
`WebhookEvent` …) that keep unknown enum values as raw strings and re-encode the API's JSON.
|
|
20
|
+
- Error classes under `EmailValidation::Error` with `status`, `code`, `request_id` and `retry_after`.
|
|
21
|
+
- Automatic retries of `429` / `503` answers (honouring `Retry-After`, capped by `max_retry_wait`) and of `GET`
|
|
22
|
+
requests after a network failure; never for `key_daily_cap`.
|
|
23
|
+
- `EmailValidation.verify_webhook` / `sign_webhook` (Standard Webhooks scheme, constant-time comparison).
|
|
24
|
+
- Vocabulary constants (`STATUSES`, `REASONS`, `REASONS_BY_STATUS`, `REASON_INFO`, `ACTIONS`, `SMTP_PROVIDERS`,
|
|
25
|
+
`BULK_JOB_STATUSES`, `ERROR_CODES`, `WEBHOOK_EVENTS` …), named-value modules and `sandbox_address`.
|
|
26
|
+
- `NetHttpTransport` (standard library, keep-alive pool, no redirects) and a pluggable transport interface.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Email Validation LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# email-validation.org Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby client for the [email-validation.org](https://email-validation.org) API: validate single addresses, read
|
|
4
|
+
credits, usage and service status, run bulk jobs and verify webhook signatures. Immutable result models, typed errors,
|
|
5
|
+
automatic retries for answers that are never charged, and no runtime dependencies (only the standard library:
|
|
6
|
+
`net/http`, `json`, `openssl`). Ruby 3.2 or later.
|
|
7
|
+
|
|
8
|
+
Documentation: https://email-validation.org/docs/sdks/ruby/
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gem install email-validation-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
or in your `Gemfile`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "email-validation-sdk", require: "email_validation_sdk"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The gem is required as `email_validation_sdk` (not `email_validation`, which is an unrelated gem) and lives in the
|
|
23
|
+
`EmailValidation` module.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "email_validation_sdk"
|
|
29
|
+
|
|
30
|
+
client = EmailValidation::Client.new(api_key: ENV.fetch("EV_API_KEY"))
|
|
31
|
+
|
|
32
|
+
result = client.validate("accepted@sandbox.email-validation.org") # free test address
|
|
33
|
+
puts result.status # => valid
|
|
34
|
+
puts result.reason # => accepted
|
|
35
|
+
puts result.action # => send
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Addresses at `sandbox.email-validation.org` are free and answer the reason in their local part
|
|
39
|
+
(`catch_all@…`, `no_mailbox@…`, `timeout@…`); `EmailValidation.sandbox_address("catch_all")` builds one.
|
|
40
|
+
|
|
41
|
+
Build the client once and share it: it is safe to use from several threads and keeps connections alive.
|
|
42
|
+
`client.close` releases them.
|
|
43
|
+
|
|
44
|
+
## Client options
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
client = EmailValidation::Client.new(
|
|
48
|
+
api_key: ENV.fetch("EV_API_KEY"), # required; never logged or shown by #inspect
|
|
49
|
+
base_url: "https://api.email-validation.org", # default
|
|
50
|
+
timeout: 30, # seconds per attempt (connect + whole response), default 30
|
|
51
|
+
max_retries: 2, # retries after the first attempt, default 2
|
|
52
|
+
max_retry_wait: 10 # longest single wait (seconds) the client accepts before giving up, default 10
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
EU data residency (validated and stored in the EU): pass `base_url: EmailValidation::EU_BASE_URL` to call `https://api-eu.email-validation.org`
|
|
57
|
+
instead of the default `https://api.email-validation.org`. See https://email-validation.org/docs/eu-data-residency/.
|
|
58
|
+
|
|
59
|
+
A missing key, a key containing whitespace, a `base_url` that is not `http://` or `https://`, or a non-positive
|
|
60
|
+
`timeout` raises `ArgumentError` at construction.
|
|
61
|
+
|
|
62
|
+
## Methods
|
|
63
|
+
|
|
64
|
+
### `client.validate(email, ip_address: nil, timeout: nil, fresh: nil)` → `ValidationResult`
|
|
65
|
+
|
|
66
|
+
Checks one address. Costs 1 credit, except `unknown` results and local answers (malformed and sandbox addresses),
|
|
67
|
+
which are free (`result.credits_used` is `0` or `1`).
|
|
68
|
+
|
|
69
|
+
- `ip_address`: where the signup came from (optional).
|
|
70
|
+
- `timeout`: seconds the service may spend on the check (5–15, server default 15). This is not the client's
|
|
71
|
+
per-attempt `timeout`, which should stay a few seconds above it.
|
|
72
|
+
- `fresh: true` checks the address again even when it was checked in the last 12 months (by default the earlier
|
|
73
|
+
definite result may be returned; `processed_at` is when the answer was produced).
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
result = client.validate("someone@example.com", ip_address: "203.0.113.7", timeout: 10)
|
|
77
|
+
case result.action
|
|
78
|
+
when EmailValidation::Action::SEND then puts "safe to send"
|
|
79
|
+
when EmailValidation::Action::SEND_WITH_CAUTION then puts "send carefully"
|
|
80
|
+
when EmailValidation::Action::REMOVE then puts "drop it"
|
|
81
|
+
when EmailValidation::Action::RETRY_LATER then puts "try again later"
|
|
82
|
+
end
|
|
83
|
+
puts result.did_you_mean if result.did_you_mean
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`ValidationResult` fields: `email`, `status`, `reason`, `action`, `account`, `domain`, `did_you_mean`, `free_email`,
|
|
87
|
+
`role_account`, `disposable`, `catch_all_domain`, `mx_found`, `mx_record`, `smtp_provider`, `domain_age_days`,
|
|
88
|
+
`processed_at`, `credits_used`, `request_id`.
|
|
89
|
+
|
|
90
|
+
### `client.credits` → `Credits`
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
credits = client.credits
|
|
94
|
+
puts credits.balance, credits.debt, credits.pending
|
|
95
|
+
credits.lots.each { |lot| puts "#{lot.remaining} expire at #{lot.expires_at}" } # soonest-expiring first
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `client.usage(from: nil, to: nil, group_by: nil)` → `UsageResponse`
|
|
99
|
+
|
|
100
|
+
Dates are `"YYYY-MM-DD"` strings (UTC) or `Date` objects. `group_by` is `"day"` (default) or `"key"`.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
usage = client.usage(from: "2026-10-01", to: "2026-10-20")
|
|
104
|
+
puts usage.totals.credits_used
|
|
105
|
+
usage.items.each { |day| puts "#{day.date}: #{day.credits_used}" } # UsageDay
|
|
106
|
+
|
|
107
|
+
by_key = client.usage(group_by: "key")
|
|
108
|
+
by_key.items.each { |key| puts "#{key.name || "(deleted key)"}: #{key.credits_used}" } # UsageByKey
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `client.status` → `ServiceStatus`
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
status = client.status
|
|
115
|
+
puts status.status # operational, degraded or outage
|
|
116
|
+
puts status.components.api # also website, bulk, sign_in, payments, support
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Bulk jobs: `client.bulk`
|
|
120
|
+
|
|
121
|
+
A key with the `bulk` scope can validate lists of up to 100,000 addresses (JSON) or files up to 100 MB.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
# From a list (sent as JSON) …
|
|
125
|
+
job = client.bulk.create(emails: ["accepted@sandbox.email-validation.org", "no_mailbox@sandbox.email-validation.org"])
|
|
126
|
+
|
|
127
|
+
# … or from a CSV / plain-text file: a String, an IO or a Pathname.
|
|
128
|
+
job = client.bulk.create(file: Pathname.new("newsletter.csv"), column: "email", has_header: true)
|
|
129
|
+
job = client.bulk.create(file: File.open("list.txt", "rb"), content_type: "text/plain")
|
|
130
|
+
|
|
131
|
+
job = client.bulk.wait(job.id, interval: 5, timeout: 3600) do |progress|
|
|
132
|
+
puts "#{progress.status}: #{progress.processed}/#{progress.unique_emails || "?"}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if job.status == EmailValidation::BulkJobStatus::COMPLETED
|
|
136
|
+
csv = client.bulk.results(job.id, filter: %w[valid catch_all]) # whole body as a String
|
|
137
|
+
File.open("results.csv", "wb") do |file| # or streamed in chunks
|
|
138
|
+
client.bulk.results(job.id) { |chunk| file.write(chunk) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
| Method | Request | Returns |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `bulk.create(emails: nil, file: nil, content_type: "text/csv", column: nil, has_header: nil, start: nil, webhook_endpoint_id: nil)` | `POST /v1/bulk` | `BulkJob` |
|
|
146
|
+
| `bulk.get(job_id)` | `GET /v1/bulk/{id}` | `BulkJob` |
|
|
147
|
+
| `bulk.start(job_id)` | `POST /v1/bulk/{id}/start` (credits are held now) | `BulkJob` |
|
|
148
|
+
| `bulk.cancel(job_id)` | `DELETE /v1/bulk/{id}` (unused credits return) | `BulkJob` |
|
|
149
|
+
| `bulk.results(job_id, filter: nil, columns: nil, escape_formulas: nil, range: nil) { \|chunk\| … }` | `GET /v1/bulk/{id}/results` | CSV `String`, or `nil` with a block |
|
|
150
|
+
| `bulk.wait(job_id, interval: 5, timeout: nil, on_progress: nil) { \|job\| … }` | polls `bulk.get` | `BulkJob` |
|
|
151
|
+
|
|
152
|
+
- `create`: pass exactly one of `emails:` (an Array) or `file:`; `start: false` keeps the job in `ready` until you call
|
|
153
|
+
`start`. A file given as an IO is read whole, so a retry can resend the same bytes.
|
|
154
|
+
- `results`: `filter` is `"all"`, one status, or an Array of statuses; `columns` is `"original"` (default) or
|
|
155
|
+
`"email_only"`; `escape_formulas` defaults to `true` on the server. The CSV is returned unchanged (never parsed).
|
|
156
|
+
`range: "bytes=0-1048575"` fetches one part of the file to resume a download; only `filter: "all"` (or one status
|
|
157
|
+
with `escape_formulas: false`) with `columns: "original"` can be served in parts, and any other shape raises
|
|
158
|
+
`InvalidRequestError` instead of returning the whole file.
|
|
159
|
+
- `wait` returns once the job is `completed`, `failed`, `cancelled` or `expired`, or once it is `ready` with an
|
|
160
|
+
`error_code` (its start was refused, e.g. `insufficient_credits`: buy credits, then call `start`). Check
|
|
161
|
+
`job.status` before downloading. A job created with `start: false` waits in `ready` until you call `start`, so call
|
|
162
|
+
`start` before `wait`. `interval` is at least 1 second; when `timeout` (seconds) passes, `wait` raises
|
|
163
|
+
`ConnectionError` with code `timeout`.
|
|
164
|
+
- A job id is checked locally before any request (`job_` followed by 22 letters and digits); a malformed one raises
|
|
165
|
+
`InvalidRequestError` with status `0`.
|
|
166
|
+
|
|
167
|
+
`BulkJob` fields: `id`, `status`, `filename`, `total_rows`, `unique_emails`, `duplicates`, `syntax_invalid`,
|
|
168
|
+
`local_answered`, `processed`, `credits_reserved`, `credits_charged`, `counts`, `error_code`, `webhook_endpoint_id`,
|
|
169
|
+
`created_at`, `started_at`, `finished_at`, `expires_at`; helpers `terminal?` and `stuck?`.
|
|
170
|
+
|
|
171
|
+
### Models
|
|
172
|
+
|
|
173
|
+
Results are immutable `Data` objects with the API's field names. `to_h` returns the API's fields (nested models
|
|
174
|
+
converted, `nil` kept) and `to_json` re-encodes the API's JSON. Times are kept as the API's ISO 8601 UTC strings
|
|
175
|
+
(`Time.iso8601(result.processed_at)` parses one). Unknown fields are ignored and unknown values (a reason or provider
|
|
176
|
+
added after this SDK version) are kept as the raw string, so compare with the constants rather than assuming a closed
|
|
177
|
+
list.
|
|
178
|
+
|
|
179
|
+
## Errors
|
|
180
|
+
|
|
181
|
+
Every failure raises a subclass of `EmailValidation::Error` (a `StandardError`). Branch on the class or on `code`,
|
|
182
|
+
never on `message` (it is for people and may change).
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
begin
|
|
186
|
+
client.validate("accepted@sandbox.email-validation.org")
|
|
187
|
+
rescue EmailValidation::InsufficientCreditsError
|
|
188
|
+
puts "Out of credits: buy a pack."
|
|
189
|
+
rescue EmailValidation::RateLimitError => e
|
|
190
|
+
puts "Rate limited (#{e.code}); retry in #{e.retry_after || "a few"} seconds."
|
|
191
|
+
rescue EmailValidation::ConnectionError => e
|
|
192
|
+
puts "No answer (#{e.code})." # connection_error or timeout
|
|
193
|
+
rescue EmailValidation::Error => e
|
|
194
|
+
puts "#{e.kind} #{e.status} #{e.code} #{e.request_id}"
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Every error has `status` (HTTP status, `0` when no answer arrived or the SDK refused the call before sending),
|
|
199
|
+
`code`, `message`, `request_id` (quote it to support), `retry_after` (seconds or `nil`) and `cause`.
|
|
200
|
+
|
|
201
|
+
| Class | When | Codes |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| `InvalidRequestError` | 400, 411, 413, other 4xx, local checks (status 0), a range answered without 206 | `invalid_request`, `length_required`, `payload_too_large` |
|
|
204
|
+
| `AuthenticationError` | 401 | `unauthorized` |
|
|
205
|
+
| `InsufficientCreditsError` | 402 (nothing charged) | `insufficient_credits` |
|
|
206
|
+
| `PermissionDeniedError` | 403 | `forbidden`, `forbidden_origin` |
|
|
207
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
208
|
+
| `ConflictError` | 409 | `conflict` |
|
|
209
|
+
| `RateLimitError` | 429 | `rate_limited`, `abuse_throttled`, `key_daily_cap` |
|
|
210
|
+
| `ServiceUnavailableError` | 503 | `service_unavailable`, `upstream_capacity` |
|
|
211
|
+
| `ServerError` | 500, other 5xx, any other unexpected status (a 3xx redirect is never followed) | `internal_error` |
|
|
212
|
+
| `ConnectionError` | no answer: network failure, attempt timeout, `bulk.wait` deadline | `connection_error`, `timeout` |
|
|
213
|
+
| `Error` (base) | a 2xx answer that is not JSON | `unexpected_response` |
|
|
214
|
+
|
|
215
|
+
An answer without our JSON error envelope (or with an unknown code) has code `unexpected_response`. Argument mistakes
|
|
216
|
+
(e.g. `emails:` and `file:` together, an unknown `filter`) raise `ArgumentError` before any request. Ruby has no
|
|
217
|
+
cancellation mechanism for blocking calls, so the `aborted` code is not used by this SDK.
|
|
218
|
+
|
|
219
|
+
## Retries and timeouts
|
|
220
|
+
|
|
221
|
+
- `429` and `503` answers are never charged, so they are retried for every method, honouring `Retry-After`, up to
|
|
222
|
+
`max_retries` times, unless the wait would be longer than `max_retry_wait` seconds. `key_daily_cap` is never
|
|
223
|
+
retried (it resets the next day).
|
|
224
|
+
- Network failures and timeouts are retried only for `GET` requests. A validation, bulk upload, start or cancel that
|
|
225
|
+
reached the service may already have been charged or applied, so it is not sent again.
|
|
226
|
+
- Nothing else is retried. Without `Retry-After` the wait is exponential with jitter (0.25–0.5 s, 0.5–1 s, 1–2 s …,
|
|
227
|
+
at most 8 s).
|
|
228
|
+
- `timeout` bounds each attempt (connect, TLS and the whole response), not the whole call. For very large result
|
|
229
|
+
downloads, raise it or fetch the file in parts with `range:`.
|
|
230
|
+
- Redirects are never followed, so the `Authorization` header never travels to another host.
|
|
231
|
+
|
|
232
|
+
## Verify webhooks
|
|
233
|
+
|
|
234
|
+
Deliveries are signed with the [Standard Webhooks](https://www.standardwebhooks.com/) scheme. Pass the raw body (before
|
|
235
|
+
any JSON parsing), the request headers and the endpoint's secret:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# Rack / Sinatra / Rails
|
|
239
|
+
payload = request.body.read
|
|
240
|
+
event = EmailValidation.verify_webhook(payload, request.env, ENV.fetch("EV_WEBHOOK_SECRET"))
|
|
241
|
+
|
|
242
|
+
case event.type
|
|
243
|
+
when "bulk.completed", "bulk.failed" then puts "#{event.data.job_id}: #{event.data.status}"
|
|
244
|
+
when "credits.low" then puts "Balance #{event.data.balance} is under #{event.data.threshold}"
|
|
245
|
+
when "credits.expiring" then puts "#{event.data.credits} credits expire at #{event.data.expires_at}"
|
|
246
|
+
when "webhook.test" then puts "Test from endpoint #{event.data.endpoint_id}"
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- Headers: any Hash-like object with header names in any case, or a Rack env (`HTTP_WEBHOOK_ID` keys).
|
|
251
|
+
- Failures raise `EmailValidation::WebhookVerificationError` (missing headers, a timestamp outside `tolerance`
|
|
252
|
+
seconds (default 300), an invalid secret, no matching signature or a body that is not JSON). Answer `400` then.
|
|
253
|
+
- Deduplicate on the `webhook-id` header (retries reuse it) and answer `2xx` quickly; do slow work afterwards.
|
|
254
|
+
- `webhook.test` events come from "Send test" in the dashboard.
|
|
255
|
+
- `EmailValidation.sign_webhook(msg_id, timestamp, payload, secret)` returns a `v1,<base64>` signature, to test your
|
|
256
|
+
endpoint locally. Both functions are also available as `EmailValidation::Webhooks.verify` / `.sign`.
|
|
257
|
+
|
|
258
|
+
## Constants
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
EmailValidation::STATUSES # ["valid", "invalid", "catch_all", "unknown", "disposable"]
|
|
262
|
+
EmailValidation::REASONS # all 30 reasons, grouped by status in REASONS_BY_STATUS
|
|
263
|
+
EmailValidation::REASONS_BY_STATUS["catch_all"] # ["catch_all", "role_catch_all", "forwarding_domain"]
|
|
264
|
+
EmailValidation::REASON_INFO["catch_all"] # { status: "catch_all", action: "send_with_caution" }
|
|
265
|
+
EmailValidation::ACTIONS # ["send", "send_with_caution", "remove", "retry_later"]
|
|
266
|
+
EmailValidation::SMTP_PROVIDERS # ["google", "microsoft", …, "other"]
|
|
267
|
+
EmailValidation::BULK_JOB_STATUSES # ["analyzing", "ready", …, "expired"]
|
|
268
|
+
EmailValidation::TERMINAL_BULK_JOB_STATUSES # ["completed", "failed", "cancelled", "expired"]
|
|
269
|
+
EmailValidation::BULK_JOB_ERROR_CODES # { "invalid_csv" => "failed", …, "insufficient_credits" => "ready", … }
|
|
270
|
+
EmailValidation::ERROR_CODES # the 15 API error codes, in the API's order
|
|
271
|
+
EmailValidation::CLIENT_ERROR_CODES # ["connection_error", "timeout", "unexpected_response", "aborted"]
|
|
272
|
+
EmailValidation::WEBHOOK_EVENTS # ["bulk.completed", "bulk.failed", "credits.low", …]
|
|
273
|
+
EmailValidation::DEFAULT_BASE_URL # "https://api.email-validation.org"
|
|
274
|
+
EmailValidation::EU_BASE_URL # "https://api-eu.email-validation.org" (EU data residency)
|
|
275
|
+
EmailValidation::SANDBOX_DOMAIN # "sandbox.email-validation.org"
|
|
276
|
+
EmailValidation::DEFAULT_WEBHOOK_TOLERANCE # 300
|
|
277
|
+
EmailValidation.status?("valid") # true
|
|
278
|
+
EmailValidation.reason?("catch_all") # true
|
|
279
|
+
EmailValidation.sandbox_address("no_mailbox") # "no_mailbox@sandbox.email-validation.org"
|
|
280
|
+
|
|
281
|
+
# Named values: Status, Reason, Action, SmtpProvider, BulkJobStatus, ErrorCode, WebhookEventType
|
|
282
|
+
EmailValidation::Status::VALID # "valid"
|
|
283
|
+
EmailValidation::Reason::CATCH_ALL # "catch_all"
|
|
284
|
+
EmailValidation::ErrorCode::RATE_LIMITED # "rate_limited"
|
|
285
|
+
EmailValidation::WebhookEventType::CREDITS_LOW # "credits.low"
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Bring your own HTTP client
|
|
289
|
+
|
|
290
|
+
The client talks to a transport: any object with `call(request)` that returns an `EmailValidation::HttpResponse`, or
|
|
291
|
+
raises `EmailValidation::TransportError` (with `timeout: true` for a timeout) when no answer arrived. `request` has
|
|
292
|
+
`method`, `url`, `headers` (lower-case names), `body` (a binary String or `nil`) and `timeout` (seconds per attempt).
|
|
293
|
+
The default is `EmailValidation::NetHttpTransport` (Net::HTTP with a keep-alive pool; it honours the standard proxy
|
|
294
|
+
environment variables like Net::HTTP does).
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
class LoggingTransport
|
|
298
|
+
def initialize(inner = EmailValidation::NetHttpTransport.new)
|
|
299
|
+
@inner = inner
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def call(request, &on_head)
|
|
303
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
304
|
+
@inner.call(request, &on_head).tap do |response|
|
|
305
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
306
|
+
warn "#{request.method} #{request.url} -> #{response.status} in #{elapsed.round(3)}s"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def close
|
|
311
|
+
@inner.close
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
client = EmailValidation::Client.new(api_key: ENV.fetch("EV_API_KEY"), transport: LoggingTransport.new)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
A transport you pass in is not closed by `client.close`. Streaming is optional: when `call` receives a block, call it
|
|
319
|
+
with `(status, headers)` once the response head arrives; if it returns a callable, pass each body chunk to it and
|
|
320
|
+
return an `HttpResponse` with an empty body. A transport that ignores the block still works (the body is handed over
|
|
321
|
+
in one piece). `request.inspect` never shows the headers or the body.
|
|
322
|
+
|
|
323
|
+
## Privacy
|
|
324
|
+
|
|
325
|
+
The API key is sent only in the `Authorization` header. It never appears in a URL, in an error message, or in the
|
|
326
|
+
`inspect`/`to_s`/`pp` output of the client, its config or a request. The SDK writes nothing to stdout or stderr and
|
|
327
|
+
has no telemetry.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module EmailValidation
|
|
6
|
+
# Bulk jobs, reached as +client.bulk+. Needs a key with the +bulk+ scope.
|
|
7
|
+
class Bulk
|
|
8
|
+
JOB_ID = /\Ajob_[0-9A-Za-z]{22}\z/
|
|
9
|
+
CONTENT_TYPES = %w[text/csv text/plain].freeze
|
|
10
|
+
FILTERS = (["all"] + STATUSES).freeze
|
|
11
|
+
BAD_JOB_ID = "Not a bulk job id (expected job_ followed by 22 letters and digits)."
|
|
12
|
+
NOT_RANGEABLE = 'This download cannot be fetched in parts: use filter: "all" (or one status with ' \
|
|
13
|
+
'escape_formulas: false) and columns: "original" with a range.'
|
|
14
|
+
private_constant :JOB_ID, :CONTENT_TYPES, :FILTERS, :BAD_JOB_ID, :NOT_RANGEABLE
|
|
15
|
+
|
|
16
|
+
# Wraps an exception raised by the caller's chunk block, so a transport never mistakes it for a network error.
|
|
17
|
+
class SinkError < StandardError
|
|
18
|
+
attr_reader :original
|
|
19
|
+
|
|
20
|
+
def initialize(original)
|
|
21
|
+
super("results block raised #{original.class}")
|
|
22
|
+
@original = original
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
private_constant :SinkError
|
|
26
|
+
|
|
27
|
+
def initialize(client)
|
|
28
|
+
@client = client
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Creates a job (HTTP 202) and returns its BulkJob. Pass exactly one of:
|
|
32
|
+
# - +emails+: an Array of addresses, sent as JSON (up to 100,000 addresses / 4 MB);
|
|
33
|
+
# - +file+: the CSV or plain-text file as a String, an IO (read whole, so retries can resend it) or a Pathname
|
|
34
|
+
# (up to 100 MB), with +content_type+ "text/csv" (default) or "text/plain".
|
|
35
|
+
#
|
|
36
|
+
# Options: +column+ (1–512 or a header name), +has_header+, +start+ (server default true; with +false+ the job
|
|
37
|
+
# waits in "ready" until #start), +webhook_endpoint_id+.
|
|
38
|
+
def create(emails: nil, file: nil, content_type: "text/csv", column: nil, has_header: nil, start: nil,
|
|
39
|
+
webhook_endpoint_id: nil)
|
|
40
|
+
raise ArgumentError, "pass exactly one of emails: or file:" if emails.nil? == file.nil?
|
|
41
|
+
raise ArgumentError, 'content_type must be "text/csv" or "text/plain"' unless
|
|
42
|
+
CONTENT_TYPES.include?(content_type)
|
|
43
|
+
|
|
44
|
+
query = { "column" => column, "has_header" => has_header, "start" => start,
|
|
45
|
+
"webhook_endpoint_id" => webhook_endpoint_id }
|
|
46
|
+
unless emails.nil?
|
|
47
|
+
raise ArgumentError, "emails must be an Array of addresses, not a String" if emails.is_a?(String)
|
|
48
|
+
raise ArgumentError, "emails must be an Array of addresses" unless emails.respond_to?(:to_a)
|
|
49
|
+
|
|
50
|
+
return @client.request_model(BulkJob, "POST", "/v1/bulk", query: query, json: { "emails" => emails.to_a })
|
|
51
|
+
end
|
|
52
|
+
@client.request_model(BulkJob, "POST", "/v1/bulk", query: query, body: read_file(file),
|
|
53
|
+
content_type: content_type)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The job's current state.
|
|
57
|
+
def get(job_id)
|
|
58
|
+
@client.request_model(BulkJob, "GET", job_path(job_id))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Starts a job waiting in "ready"; credits are held now.
|
|
62
|
+
def start(job_id)
|
|
63
|
+
@client.request_model(BulkJob, "POST", job_path(job_id, "/start"))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Cancels a job; unused credits are returned.
|
|
67
|
+
def cancel(job_id)
|
|
68
|
+
@client.request_model(BulkJob, "DELETE", job_path(job_id))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Downloads the result CSV (UTF-8, returned unchanged; never parsed).
|
|
72
|
+
#
|
|
73
|
+
# - +filter+: "all", one status, or an Array of statuses.
|
|
74
|
+
# - +columns+: "original" (default) or "email_only".
|
|
75
|
+
# - +escape_formulas+: server default true.
|
|
76
|
+
# - +range+: e.g. "bytes=0-1048575", to fetch one part of the file (resume a download). Only
|
|
77
|
+
# +filter: "all"+ (or one status with +escape_formulas: false+) and +columns: "original"+ can be served in parts;
|
|
78
|
+
# any other shape raises InvalidRequestError instead of returning the whole file.
|
|
79
|
+
#
|
|
80
|
+
# Without a block it returns the whole body as a String. With a block it yields the body in chunks (binary
|
|
81
|
+
# Strings) as they arrive and returns nil, so a large file never sits in memory:
|
|
82
|
+
#
|
|
83
|
+
# File.open("results.csv", "wb") { |f| client.bulk.results(job_id) { |chunk| f.write(chunk) } }
|
|
84
|
+
def results(job_id, filter: nil, columns: nil, escape_formulas: nil, range: nil, &block)
|
|
85
|
+
query = { "filter" => results_filter(filter), "columns" => columns, "escape_formulas" => escape_formulas }
|
|
86
|
+
headers = range ? { "range" => range } : nil
|
|
87
|
+
path = job_path(job_id, "/results")
|
|
88
|
+
return download(path, query, headers, range) unless block
|
|
89
|
+
|
|
90
|
+
stream_download(path, query, headers, range, block)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Polls a job until it is completed, failed, cancelled or expired, or until it is "ready" with an +error_code+
|
|
94
|
+
# (its start was refused, e.g. +insufficient_credits+: buy credits, then call #start). Returns the last BulkJob;
|
|
95
|
+
# check its +status+ before downloading results.
|
|
96
|
+
#
|
|
97
|
+
# - +interval+: seconds between polls (default 5, also when nil; at least 1).
|
|
98
|
+
# - +timeout+: seconds before giving up with ConnectionError (code +timeout+); default none.
|
|
99
|
+
# - +on_progress+ (or a block): called with every polled BulkJob.
|
|
100
|
+
#
|
|
101
|
+
# A job created with +start: false+ waits in "ready" until you call #start, so call #start before #wait.
|
|
102
|
+
def wait(job_id, interval: 5, timeout: nil, on_progress: nil, &block)
|
|
103
|
+
job_path(job_id)
|
|
104
|
+
progress = on_progress || block
|
|
105
|
+
interval = [(interval || 5).to_f, 1.0].max
|
|
106
|
+
deadline = timeout.nil? ? nil : monotonic + timeout
|
|
107
|
+
loop do
|
|
108
|
+
job = get(job_id)
|
|
109
|
+
progress&.call(job)
|
|
110
|
+
return job if job.terminal? || job.stuck?
|
|
111
|
+
if deadline && monotonic + interval > deadline
|
|
112
|
+
raise ConnectionError.new("Bulk job #{job.id} is still #{job.status}.", status: 0, code: "timeout")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@client.sleep_for(interval)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def inspect
|
|
120
|
+
"#<#{self.class.name}>"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def job_path(job_id, suffix = "")
|
|
126
|
+
unless job_id.is_a?(String) && JOB_ID.match?(job_id)
|
|
127
|
+
raise InvalidRequestError.new(BAD_JOB_ID, status: 0, code: "invalid_request")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
"/v1/bulk/#{job_id}#{suffix}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def results_filter(value)
|
|
134
|
+
return nil if value.nil?
|
|
135
|
+
|
|
136
|
+
parts = value.is_a?(Array) ? value.map(&:to_s) : [value.to_s]
|
|
137
|
+
parts.each do |part|
|
|
138
|
+
raise ArgumentError, "unknown filter: #{part.inspect} (use \"all\" or a status)" unless FILTERS.include?(part)
|
|
139
|
+
end
|
|
140
|
+
parts.join(",")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def read_file(file)
|
|
144
|
+
case file
|
|
145
|
+
when String then file
|
|
146
|
+
when Pathname then File.binread(file)
|
|
147
|
+
else
|
|
148
|
+
raise ArgumentError, "file must be a String, an IO or a Pathname" unless file.respond_to?(:read)
|
|
149
|
+
|
|
150
|
+
data = file.read
|
|
151
|
+
raise ArgumentError, "file returned no data" if data.nil?
|
|
152
|
+
|
|
153
|
+
data
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def download(path, query, headers, range)
|
|
158
|
+
response = @client.perform("GET", path, query: query, accept: "text/csv", headers: headers)
|
|
159
|
+
refuse_whole_file(response.status, response.header("x-request-id")) if range && response.status != 206
|
|
160
|
+
response.body.dup.force_encoding(Encoding::UTF_8)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def stream_download(path, query, headers, range, block)
|
|
164
|
+
stream = Stream.new(range, block)
|
|
165
|
+
response = @client.perform("GET", path, query: query, accept: "text/csv", headers: headers, stream: stream)
|
|
166
|
+
refuse_whole_file(response.status, response.header("x-request-id")) if range && response.status != 206
|
|
167
|
+
# A transport without streaming support buffered the body: hand it over in one piece.
|
|
168
|
+
block.call(response.body) unless stream.head_seen? || response.body.empty?
|
|
169
|
+
nil
|
|
170
|
+
rescue SinkError => e
|
|
171
|
+
raise e.original, cause: e.original.cause
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def refuse_whole_file(status, request_id)
|
|
175
|
+
raise InvalidRequestError.new(NOT_RANGEABLE, status: status, code: "invalid_request", request_id: request_id)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def monotonic
|
|
179
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Decides, when the response head arrives, where the body goes: the caller's block for an accepted 2xx, nowhere
|
|
183
|
+
# for a whole file answered to a range request (it is discarded), and the buffer for error answers.
|
|
184
|
+
class Stream
|
|
185
|
+
def initialize(range, block)
|
|
186
|
+
@range = range
|
|
187
|
+
@block = block
|
|
188
|
+
@head_seen = false
|
|
189
|
+
@started = false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def on_head(status, _headers)
|
|
193
|
+
return nil unless (200..299).cover?(status)
|
|
194
|
+
|
|
195
|
+
@head_seen = true
|
|
196
|
+
return ->(_chunk) {} if @range && status != 206
|
|
197
|
+
|
|
198
|
+
lambda do |chunk|
|
|
199
|
+
@started = true
|
|
200
|
+
begin
|
|
201
|
+
@block.call(chunk)
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
raise SinkError, e
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def head_seen?
|
|
209
|
+
@head_seen
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def started?
|
|
213
|
+
@started
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
private_constant :Stream
|
|
217
|
+
end
|
|
218
|
+
end
|