Email verification in Rails

Rails gives this three homes: a validator on the model, an ActiveJob for lists, and a polling job for the results. The validator is where people get it wrong, usually by adding an error whenever the API is slow.

A validator that rejects on timeout means a mail server having a bad minute becomes a customer you never acquired, and nothing in your logs says so. Rescue the timeout, record the verdict as unknown, and let the record save.

Net::HTTP wrapped in a service object, called from an ActiveModel::EachValidator and an ActiveJob.

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
# app/validators/deliverable_email_validator.rb
class DeliverableEmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    checked = ZapBounce.new.verify(value, timeout: 2)
    verdict = checked["status"]
    record.email_verdict = verdict if record.respond_to?(:email_verdict=)

    # catch_all and unknown both pass. Only a definite no is an error, plus the
    # disposable flag, which is an entry in the flags array beside the status.
    return unless verdict == "invalid" || checked["flags"].include?("disposable")

    record.errors.add(attribute, "will not receive mail. Check it for a typo.")
  rescue Net::ReadTimeout, Net::OpenTimeout, ApiError => e
    # Fail open, loudly in the log and silently to the user.
    Rails.logger.warn("zapbounce unavailable: #{e.message}")
    record.email_verdict = "unknown" if record.respond_to?(:email_verdict=)
  end
end

# app/models/user.rb
class User < ApplicationRecord
  validates :email, presence: true, deliverable_email: true, on: :create
end

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
# app/jobs/verify_contact_list_job.rb
class VerifyContactListJob < ApplicationJob
  queue_as :low
  retry_on ApiError, wait: :polynomially_longer, attempts: 3
  discard_on ActiveRecord::RecordNotFound

  def perform(list_id)
    list = ContactList.find(list_id)
    emails = list.contacts.where(verdict: nil).pluck(:email)
    return if emails.empty?

    job_id = ZapBounce.new.submit_job(emails, idempotency_key: "list-#{list_id}")   # up to 50,000
    list.update!(job_id: job_id)

    # No webhook exists. A second job polls and re-enqueues itself until the job is done.
    ImportJobResultsJob.set(wait: 30.seconds).perform_later(job_id)
  end
end

# app/jobs/import_job_results_job.rb
class ImportJobResultsJob < ApplicationJob
  def perform(job_id)
    zb = ZapBounce.new
    unless ZapBounce::TERMINAL.include?(zb.get("jobs/#{job_id}")["status"])
      return self.class.set(wait: 30.seconds).perform_later(job_id)
    end

    zb.rows(job_id).each_slice(1000) do |rows|
      Contact.upsert_all(
        rows.map { |r| { email: r["email"], verdict: r["status"], updated_at: Time.current } },
        unique_by: :email,
      )
    end
  end
end

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
# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
  # ApiError carries code, status and window (see the Ruby page). No Retry-After header
  # exists, so a 429 waits on X-RateLimit-Window-Seconds instead.
  rescue_from ApiError do |e|
    if e.code == "CREDITS_INSUFFICIENT"      # 422: retrying will not help
      Rails.logger.error("zapbounce out of credits request_id=#{e.request_id}")
      raise e
    end
    raise e unless e.retryable? && executions < 5

    base = e.status == 429 ? [e.window, 1].max : 5
    retry_job wait: (2**executions) * base * (0.5 + rand)    # jitter, so a fleet does not retry together
  end
end

# app/validators/deliverable_email_validator.rb already fails open on a timeout:
#   rescue Net::ReadTimeout, Net::OpenTimeout -> verdict "unknown", reasonCode "TIMEOUT"

Rails: common questions

Where does the key belong?

Rails credentials, read through Rails.application.credentials. It stays encrypted in the repo and out of the environment.

Does this slow the signup form?

By the timeout you set, at most. Two seconds with a fail-open rescue is the usual trade; anything longer and you are choosing the verifier over the customer.

Should I verify on every login?

No. Verify at collection, then re-check dormant addresses on a schedule. Addresses decay over months, not between sessions.

Run this Rails code today

100 free checks a month, no card, credits that never expire, and unknown results refunded.