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::HTTPwaits 60 seconds by default for both opening a connection and reading a response, which is far too long inside a form submission.Net::HTTP.startacceptsopen_timeout,read_timeoutandwrite_timeoutas options and applies them before connecting; see the Net::HTTP docs. The TLS handshake counts againstopen_timeout. - No retry.
Net::HTTPonly 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: trueasks forcategoryandverdictwithout the LLM company enrichment, which keeps the call short. Drop it if you want the company profile forcompanydomains.- The cache. The API returns the registrable domain (
mail.yopmail.frcomes back asyopmail.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
userstable.
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.