Skip to content

Validating email domains in Ruby on Rails

· 5 min read · Tutorials

To validate email domains in Rails, write a custom validator that subclasses ActiveModel::EachValidator, call an email-classification API from it over Net::HTTP with short open and read timeouts, and add an error to the record when the address is disposable, machine-generated, or (on B2B forms) free webmail. Cache the domain's category in Rails.cache, look up the per-address verdict each time, and let the record save when the API can't answer.

Format checking with URI::MailTo::EMAIL_REGEXP stays as it is. This adds the data question a regex can't answer: what kind of domain is this, and does the address look generated?

The pieces

File Job
app/services/email_screener.rb Calls the API, handles timeouts and 429, caches categories
app/validators/screened_email_validator.rb Turns the result into model errors
app/models/user.rb One validates line

Rails autoloads every directory under app/, so both new folders work without configuration.

Step 1: store the key

bin/rails credentials:edit
pingutil:
  api_key: pk_live_your_key_here

The screener reads it with Rails.application.credentials.dig(:pingutil, :api_key) and falls back to ENV["PINGUTIL_KEY"]. Either way it stays on the server.

Step 2: the screener

# app/services/email_screener.rb
require "net/http"
require "json"

class EmailScreener
  URL = URI("https://pingutil.com/v1/lookup").freeze
  BACKOFF_KEY = "pingutil:backoff"
  NETWORK_ERRORS = [
    Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
    SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError
  ].freeze

  Result = Data.define(:category, :spam, :reason, :degraded)

  def self.call(email) = new.call(email)

  def initialize(cache: Rails.cache, api_key: nil)
    @cache = cache
    @api_key = api_key || Rails.application.credentials.dig(:pingutil, :api_key) || ENV["PINGUTIL_KEY"]
  end

  def call(email)
    _, at, host = email.to_s.strip.rpartition("@")
    host = host.downcase.chomp(".")
    blank = Result.new(category: nil, spam: false, reason: nil, degraded: nil)
    return blank.with(degraded: "no_domain") if at.empty? || host.empty?

    # Domain-level cache holds the category only, never the verdict.
    base = blank.with(category: @cache.read(category_key(host)))
    return base.with(spam: true, reason: "disposable_domain") if base.category == "disposable"
    return base.with(degraded: "not_configured") if @api_key.blank?

    # After a 429, skip the API until the window passes. Cached categories still apply.
    return base.with(degraded: "backoff") if @cache.exist?(BACKOFF_KEY)

    response =
      begin
        post(email.to_s.strip)
      rescue *NETWORK_ERRORS => e
        return base.with(degraded: e.class.name) # timeout or connection failure: fail open
      end

    case response
    when Net::HTTPTooManyRequests
      wait = (parse(response.body)&.dig("retry_after_seconds") || 60).to_i
      @cache.write(BACKOFF_KEY, true, expires_in: [wait, 1].max.seconds)
      base.with(degraded: "rate_limited")
    when Net::HTTPSuccess
      data = parse(response.body)
      verdict = data&.dig("verdict")
      return base.with(degraded: "bad_body") unless verdict.is_a?(Hash)

      if data["domain"].is_a?(String) && data["category"].is_a?(String)
        @cache.write(category_key(data["domain"]), data["category"], expires_in: 1.day)
      end

      Result.new(category: data["category"], spam: verdict["is_spam"] == true,
                 reason: verdict["reason"], degraded: nil)
    else
      base.with(degraded: "http_#{response.code}") # 400, 401, 5xx: fail open
    end
  end

  private

  def post(email)
    Net::HTTP.start(URL.host, URL.port, use_ssl: true,
                    open_timeout: 1, read_timeout: 2, write_timeout: 2) do |http|
      request = Net::HTTP::Post.new(URL)
      request["Authorization"] = "Bearer #{@api_key}"
      request["Content-Type"] = "application/json"
      request["Accept"] = "application/json"
      request.body = JSON.generate(input: email, skip_enrichment: true)
      http.request(request)
    end
  end

  def parse(body)
    parsed = JSON.parse(body.to_s)
    parsed.is_a?(Hash) ? parsed : nil
  rescue JSON::ParserError
    nil
  end

  def category_key(domain) = "pingutil:category:#{domain}"
end

Notes on the choices:

  • Timeouts. Net::HTTP waits 60 seconds by default for both opening a connection and reading a response, which is far too long inside a form submission. Net::HTTP.start accepts open_timeout, read_timeout and write_timeout as options and applies them before connecting; see the Net::HTTP docs. The TLS handshake counts against open_timeout.
  • No retry. Net::HTTP only retries idempotent requests on its own, so this POST is tried once. In a signup request that's the right call: if the first try didn't answer within two seconds, a second one mostly doubles the wait.
  • 429. pingutil allows 60 requests per minute per key and answers over-limit requests with { "error": "rate_limited", "retry_after_seconds": 60 }. The screener writes a backoff entry that expires after that many seconds and skips the API until it does. If the body can't be parsed, it waits 60 seconds.
  • skip_enrichment: true asks for category and verdict without the LLM company enrichment, which keeps the call short. Drop it if you want the company profile for company domains.
  • The cache. The API returns the registrable domain (mail.yopmail.fr comes back as yopmail.fr); that's the key written. Reads use the host from the address, so they hit for normal addresses and miss for subdomains, which then just call the API. The trade-offs behind key choice and TTL are in caching domain lookups.

Why not cache the verdict too? Because it's about the address. verdict.is_spam is true for disposable domains and also for randomized local parts like ccjxx90sze@gmail.com. Cache that under gmail.com and the next real Gmail user gets rejected.

For backoff to work across Puma workers and machines, Rails.cache needs a shared store. New Rails 8 apps use Solid Cache in production; Redis or Memcached work too. A per-process :memory_store still works, just with each process backing off on its own.

Step 3: the validator

# app/validators/screened_email_validator.rb
class ScreenedEmailValidator < ActiveModel::EachValidator
  PERSONAL_CATEGORIES = %w[free isp].freeze

  def validate_each(record, attribute, value)
    return if value.blank? || !value.to_s.include?("@")

    result = EmailScreener.call(value)
    Rails.logger.warn("[email_screen] degraded: #{result.degraded}") if result.degraded

    if result.spam
      record.errors.add(attribute, :disposable,
                        message: options[:message] || "must be a permanent email address")
    elsif options[:allow_personal] == false && PERSONAL_CATEGORIES.include?(result.category)
      record.errors.add(attribute, :personal, message: "must be a work email address")
    end
  end
end

The class name maps to the screened_email: key in validates, and anything you pass in the hash shows up in options, as described in the Rails guide on custom validators. The error types (:disposable, :personal) let you look them up in errors.details and translate them later.

Step 4: wire it into the model

# app/models/user.rb
class User < ApplicationRecord
  normalizes :email, with: ->(email) { email.strip.downcase }

  validates :email, presence: true,
                    format: { with: URI::MailTo::EMAIL_REGEXP },
                    uniqueness: true
  validates :email, screened_email: { allow_personal: true },
                    if: -> { will_save_change_to_email? && errors[:email].empty? }
end

Validations run in the order they're declared, so the errors[:email].empty? guard skips the API call when the format or uniqueness check already failed. will_save_change_to_email? keeps it off unrelated updates. allow_personal: false rejects free (gmail.com, outlook.com and roughly 100 other webmail domains) and isp (comcast.net and the like); use it only on forms that truly need a company, and read how to tell business from personal email for where that classification breaks.

If you prefer Faraday, the screener's post method becomes Faraday.new(url: "https://pingutil.com", request: { open_timeout: 1, timeout: 2 }), and the rescue list becomes Faraday::TimeoutError and Faraday::ConnectionFailed. Everything else stays the same.

Step 5: tests

# test/services/email_screener_test.rb
require "test_helper"
require "webmock/minitest"

class EmailScreenerTest < ActiveSupport::TestCase
  URL = "https://pingutil.com/v1/lookup"

  setup { @screener = EmailScreener.new(cache: ActiveSupport::Cache::MemoryStore.new, api_key: "pk_test") }

  def reply(domain, category, spam, reason = nil)
    { status: 200, headers: { "Content-Type" => "application/json" },
      body: { domain:, category:, verdict: { is_spam: spam, low_trust: false, reason: } }.to_json }
  end

  test "flags disposable domains" do
    stub_request(:post, URL).to_return(reply("mailinator.com", "disposable", true, "disposable_domain"))
    assert @screener.call("a@mailinator.com").spam
  end

  test "fails open on timeout" do
    stub_request(:post, URL).to_timeout
    result = @screener.call("jane@acme.com")
    refute result.spam
    assert result.degraded
  end

  test "backs off after 429" do
    stub_request(:post, URL).to_return(status: 429, body: { error: "rate_limited", retry_after_seconds: 60 }.to_json)
    @screener.call("a@acme.com")
    assert_equal "backoff", @screener.call("b@acme.com").degraded
    assert_requested :post, URL, times: 1
  end
end

The default test environment uses a null cache store, which is why the test injects a MemoryStore. Data#with and the domain: shorthand need Ruby 3.2 or later, which Rails 8 already requires.

Limits

  • New throwaway domains aren't on any list until someone reports them.
  • Privacy relays such as Apple Hide My Email can be flagged as randomized. Read why relay addresses are not disposable before you reject them.
  • No batch endpoint. This is for live signups at up to 60 lookups a minute per key, not for backfilling a users table.

Try addresses in the live demo first, and check every field in the API reference.

Questions people ask

Is URI::MailTo::EMAIL_REGEXP enough to validate emails in Rails?
It's enough to check the shape of an address. It can't tell you whether the domain is a disposable inbox, free webmail or a company, and it accepts machine-generated local parts. Use it for format, then check the domain separately.
Should the email check run on every save?
No. Run it when the email is new or has changed, for example with if: :will_save_change_to_email?. Otherwise every profile update makes a network call.
What happens if the lookup API is down?
With the code in this article, the validator lets the record save and logs a warning. Blocking signups because a third-party service is slow costs you real users.