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.

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

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

Ruby
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" => [] }
end

Ruby: 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.