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.
# 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
endVerify 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.
# 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
endErrors 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.
# 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.