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 four 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 results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.
require "net/http"
require "json"
require "uri"
class ZapBounce
API = URI("https://api.zapbounce.com/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["Authorization"] = "Bearer #{@key}"
req["Content-Type"] = "application/json"
req.body = JSON.generate(email: email)
res = @http.request(req)
raise ApiError.build(res) unless res.is_a?(Net::HTTPSuccess)
JSON.parse(res.body)
end
end
zb = ZapBounce.new
result = zb.verify("ada@example.com")
case result["result"]
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"])
endVerify a list
Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
class ZapBounce
def submit_batch(emails, name:)
post("batches", { name: name, emails: emails },
"Idempotency-Key" => SecureRandom.uuid)["batch_id"]
end
# An Enumerator, so callers can .lazy.select without loading a million rows.
def results(batch_id)
Enumerator.new do |yielder|
cursor = nil
loop do
query = { limit: 1000 }
query[:cursor] = cursor if cursor
page = get("batches/#{batch_id}/results", query)
page["data"].each { |row| yielder << row }
break unless page["has_more"]
cursor = page["next_cursor"]
end
end
end
end
unknowns = zb.results(batch_id).lazy.reject { |r| r["billed"] }.map { |r| r["email"] }
puts "#{unknowns.count} addresses nobody could resolve, and none of them cost a credit"Errors and retries
A 429 and a 5xx are worth retrying. A 402 for credits 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
def self.build(response)
body = JSON.parse(response.body) rescue {}
error = body["error"] || {}
new(error["message"] || response.message).tap do |e|
e.instance_variable_set(:@code, error["code"])
e.instance_variable_set(:@status, response.code.to_i)
e.instance_variable_set(:@request_id, error["request_id"])
end
end
# 400 and 402 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
sleep((2**tries) * 0.5 * (0.5 + rand)) # jitter, or the whole fleet retries together
retry
rescue Net::ReadTimeout, Net::OpenTimeout
raise if (tries += 1) >= attempts
retry
end
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 that cost nothing.