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 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