Email verification in Rails
Rails gives this three homes: a validator on the model, an ActiveJob for lists, and a controller for the callback. 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 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.
# 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["result"]
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 a boolean beside the result.
return unless verdict == "invalid" || checked["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 batch 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?
batch_id = ZapBounce.new.submit_batch(
emails,
name: "list-#{list_id}",
idempotency_key: "list-#{list_id}",
webhook_url: Rails.application.routes.url_helpers.zapbounce_webhook_url,
)
list.update!(batch_id: batch_id)
# No polling. WebhooksController picks it up on completion.
end
end
# app/jobs/import_batch_results_job.rb
class ImportBatchResultsJob < ApplicationJob
def perform(batch_id)
ZapBounce.new.results(batch_id).each_slice(1000) do |rows|
Contact.upsert_all(
rows.map { |r| { email: r["email"], verdict: r["result"], updated_at: Time.current } },
unique_by: :email,
)
end
end
endErrors 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.
# app/controllers/webhooks_controller.rb
class WebhooksController < ActionController::API
def zapbounce
raw = request.raw_post # the bytes, before params touched anything
parts = request.headers["ZapBounce-Signature"].to_s
.split(",").map { |p| p.split("=", 2) }.to_h
return head :unauthorized if (Time.now.to_i - parts["t"].to_i).abs > 300
expected = OpenSSL::HMAC.hexdigest(
"SHA256", Rails.application.credentials.zapbounce[:webhook_secret],
"#{parts['t']}.#{raw}"
)
return head :unauthorized unless ActiveSupport::SecurityUtils
.secure_compare(expected, parts["v1"].to_s)
event = JSON.parse(raw)
# Deliveries repeat. The unique index makes a replay a no-op.
WebhookEvent.create!(id: event["id"], event_type: event["type"])
ImportBatchResultsJob.perform_later(event.dig("data", "batch_id"))
head :no_content
rescue ActiveRecord::RecordNotUnique
head :no_content # already handled, and that is fine
end
end
# config/routes.rb
post "/webhooks/zapbounce", to: "webhooks#zapbounce", as: :zapbounce_webhookRails: 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 that cost nothing.