Email verification in Ruby
Ruby's standard library is enough. Net::HTTP with start block form keeps one connection open across many verifications, which is the difference between a list that finishes over lunch and one that finishes overnight.
No gem here on purpose. The API is a handful of endpoints, and a wrapper gem is one more thing to keep pinned in a Gemfile that already has plenty.
Net::HTTP from the standard library, held open with start, plus Retriable-style backoff written by hand in ten lines.
Verify one address
You branch on four statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
require "net/http"
require "json"
require "uri"
class ZapBounce
API = URI("https://app.zapbounce.com/api/v1/")
def initialize(key: ENV.fetch("ZAPBOUNCE_KEY"))
@key = key
@http = Net::HTTP.new(API.host, API.port)
@http.use_ssl = true
@http.open_timeout = 3
@http.read_timeout = 10 # the mail server sets this pace, not us
@http.start
end
def verify(email)
req = Net::HTTP::Post.new(API + "verify")
req["X-API-Key"] = @key
req["Content-Type"] = "application/json"
req.body = JSON.generate(emails: [email])
res = @http.request(req)
raise ApiError.build(res) unless res.is_a?(Net::HTTPSuccess)
JSON.parse(res.body)["results"].first
end
end
zb = ZapBounce.new
result = zb.verify("ada@example.com")
case result["status"]
when "valid" then Mailer.deliver(result["email"])
when "catch_all" then Segment.push(:accepts_everything, result["email"])
when "unknown" then RecheckJob.set(wait: 2.days).perform_later(result["email"])
else Suppression.add(result["email"])
end
Suppression.add(result["email"]) if result["flags"].include?("disposable")Verify a list
Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
class ZapBounce
TERMINAL = %w[completed partial failed cancelled].freeze
def submit_job(emails)
post("jobs", { emails: emails, source: "api" }, # up to 50,000 per job
"Idempotency-Key" => SecureRandom.uuid)["id"]
end
# No webhook: poll until the job reaches a terminal status.
def wait_for(job_id, every: 5)
loop do
job = get("jobs/#{job_id}")
return job if TERMINAL.include?(job["status"])
sleep every
end
end
# An Enumerator, so callers can .lazy.select without loading a million rows.
def rows(job_id)
Enumerator.new do |yielder|
page = 1
loop do
body = get("jobs/#{job_id}/emails", { page: page, pageSize: 100 })
body["items"].each { |row| yielder << row }
break if page >= body["meta"]["totalPages"]
page += 1
end
end
end
end
job_id = zb.submit_job(emails)
zb.wait_for(job_id)
unknowns = zb.rows(job_id).lazy.select { |r| r["status"] == "unknown" }.map { |r| r["email"] }
puts "#{unknowns.count} addresses nobody could resolve, and their credits were refunded"Errors and retries
A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT and a 400 for a malformed address will fail the same way on every attempt, so stop.
class ApiError < StandardError
attr_reader :code, :status, :request_id, :window
def self.build(response)
body = JSON.parse(response.body) rescue {} # { statusCode, code, message, requestId }
new(body["message"] || response.message).tap do |e|
e.instance_variable_set(:@code, body["code"])
e.instance_variable_set(:@status, response.code.to_i)
e.instance_variable_set(:@request_id, body["requestId"])
e.instance_variable_set(:@window, response["X-RateLimit-Window-Seconds"].to_i)
end
end
# 400 and 422 (CREDITS_INSUFFICIENT) will fail the same way on every attempt.
def retryable? = status == 429 || status >= 500
end
def with_retry(attempts: 5)
tries = 0
begin
yield
rescue ApiError => e
raise unless e.retryable?
raise if (tries += 1) >= attempts
# There is no Retry-After header. A 429 backs off from the rate-limit window length.
base = e.status == 429 ? [e.window, 1].max : 0.5
sleep((2**tries) * base * (0.5 + rand)) # jitter, or the whole fleet retries together
retry
rescue Net::ReadTimeout, Net::OpenTimeout
raise if (tries += 1) >= attempts
retry
end
end
# At signup, fail open once the retries are spent:
def verify_or_unknown(zb, email)
with_retry { zb.verify(email) }
rescue Net::ReadTimeout, Net::OpenTimeout
{ "email" => email, "status" => "unknown", "reasonCode" => "TIMEOUT", "flags" => [] }
endRuby: common questions
Is there a gem?
No. The class on this page is the whole client, and it has no version to keep current.
Where does this belong in Rails?
In a background job, not in the controller. The Rails page shows the ActiveJob, the validator and how to keep a signup form responsive.
Does Faraday work?
Fine. The retry middleware saves a few lines. The standard library is shown here because it needs nothing added to the Gemfile.
Run this Ruby code today
100 free checks a month, no card, credits that never expire, and unknown results refunded.