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.

Ruby
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"])
end

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

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

Ruby
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
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 that cost nothing.