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.

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["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
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
# 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
end

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
# 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_webhook

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