The moment you need to validate an email address in Rails, Active Record makes it
look trivial — type validates :email, watch the test pass, move on. But that
one line answers only the first of the three questions real validation asks. Is
the address shaped correctly? Can its domain receive mail? Does the mailbox
actually exist? Rails ships a tidy answer to the first and nothing at all for the
other two. This guide builds each layer as an idiomatic Rails validation — a
format rule, then two custom ActiveModel::EachValidator classes — and shows
exactly where the framework stops. It extends the
Ruby email validation guide: Rails wraps the
same standard-library building blocks in the declarative validations API.
The short answer#
Use validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } for syntax, a
custom validator backed by the resolv standard library for the MX lookup, and a
verification API for the SMTP mailbox check — three validations on one attribute,
cheapest first, each skipping when a cheaper layer already failed. Don't open an
SMTP session from your app to probe mailboxes yourself: outbound port 25 is
blocked on most hosts, and the answer depends on sending-IP reputation and
greylisting you don't want to reimplement. One wrinkle sets Rails apart from
frameworks with a bail keyword — Active Model runs every validator you declare
and collects all the errors, so short-circuiting is a guard you add, not a flag
you pass. Each layer rules addresses out more cheaply than the last; only the
final one can rule an address in.
Layer 1: format validation with a validates rule#
Rails owns layer one, and you don't have to write a regex to claim it. Ruby's
uri library — which Rails already loads — ships a well-tested pattern in
URI::MailTo::EMAIL_REGEXP, so hand it to a format validation instead of
pasting something from Stack Overflow:
app/models/user.rb
class User < ApplicationRecord
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
end
That constant is the same one the Ruby guide
leans on. It's already anchored with \A and \z, so it tests the whole string,
and it's deliberately permissive: it follows the WHATWG/HTML5 definition, which
is looser than RFC 5322 and happily accepts jane@localhost because it doesn't
require a dot in the domain. That's a feature. The job of layer one is to catch
fat-finger typos at the input, not to litigate the RFCs — which
wouldn't help anyway.
What matters is knowing where Rails stops. Every built-in email affordance — the
format rule, the scaffolds, the email_field helper — validates shape. None
of them resolves DNS or contacts a mail server, so noreply-9f2x@gmail.com,
typo@gmial.com, and sales@company-that-folded.com all pass. All three are
undeliverable. Rails ships no deliverability check at all.
Layer 2: can the domain receive mail?#
This is the first layer Rails doesn't hand you, and it's cheap to bolt on. A
domain with no mail route can't accept mail for anyone, so a single MX lookup
eliminates dead domains, misspelled company names, and invented TLDs. A Rails
validator is just a class that subclasses ActiveModel::EachValidator and
implements validate_each, so wrap the resolv library's MX lookup in one:
app/validators/deliverable_domain_validator.rb
require "resolv"
class DeliverableDomainValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank? || record.errors[attribute].present?
domain = value.rpartition("@").last
return if domain.present? && mail_route?(domain)
record.errors.add(attribute, options[:message] || "can't receive email")
end
private
def mail_route?(domain)
records = Resolv::DNS.open do |dns|
dns.getresources(domain, Resolv::DNS::Resource::IN::MX)
end
!records.empty?
rescue Resolv::ResolvError, Resolv::ResolvTimeout
false
end
end
Resolv::DNS.open hands you a resolver and closes it when the block exits, and
getresources returns an array of MX records — empty when the domain publishes
none or doesn't exist, which is exactly the "no route" case. The rescue covers
the other failure mode; note that Resolv::ResolvTimeout isn't a subclass of
Resolv::ResolvError, so name both if you want a slow nameserver to fall through
cleanly. Drop it into the model by its inferred key — Rails camelizes
deliverable_domain to DeliverableDomainValidator and finds the class Zeitwerk
autoloaded from app/validators:
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP },
deliverable_domain: true
The return if ... record.errors[attribute].present? guard is the whole trick.
Active Model runs every validator you declare and aggregates the errors — there's
no bail. But validations run in the order you declare them, so by the time this
one fires, a format failure is already sitting on record.errors[:email], and
the guard skips the DNS lookup entirely. A malformed address never spends a
nameserver round-trip.
Layer 3: does the mailbox actually exist?#
Layers one and two can only rule an address out. A domain can publish perfect MX
records and still have no mailbox at the address you're holding —
noreply-9f2x@gmail.com is valid syntax on a live mail route, and it's still a
box that was never created. Confirming a specific mailbox means the SMTP delivery
conversation: connect to the mail host, issue RCPT TO, read the reply, and
disconnect before sending anything. There's more to it than that —
how email verification works walks the full
pipeline, including catch-all domains that accept every address and defeat a naive
probe.
Ruby's net/smtp will happily open that conversation. The problem isn't the code;
it's the network. Run an SMTP probe from your application server and three things
go wrong: most cloud providers block outbound port 25 entirely, the answer depends
on the reputation of the IP you connect from, and receiving servers greylist and
rate-limit unfamiliar senders — so a probe that works on your laptop quietly
fails, or gets you blocklisted, in production. This is the layer worth delegating.
Calling Qualisend from a custom validator#
The delegation stays clean because a verification API is just another validator.
Qualisend's verify endpoint runs the whole pipeline — syntax, DNS, and the SMTP
mailbox probe — from reputation-managed infrastructure, and returns a single
verdict. It's a plain JSON POST, so the standard library's net/http covers it
with no gems. Read the key from the environment rather than hard-coding it:
app/validators/deliverable_email_validator.rb
require "net/http"
require "json"
require "uri"
class DeliverableEmailValidator < ActiveModel::EachValidator
VERIFY_URL = URI("https://api.qualisend.com/v1/verify")
def validate_each(record, attribute, value)
return if value.blank? || record.errors[attribute].present?
result = verify(value)
return if result.nil? # our outage shouldn't block a real signup
if result["status"] == "undeliverable"
record.errors.add(attribute, options[:message] || "appears to be undeliverable")
end
end
private
def verify(email)
request = Net::HTTP::Post.new(VERIFY_URL)
request["Authorization"] = "Bearer #{ENV.fetch('QUALISEND_API_KEY')}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(email: email)
response = Net::HTTP.start(
VERIFY_URL.hostname, VERIFY_URL.port,
use_ssl: true, open_timeout: 5, read_timeout: 10
) { |http| http.request(request) }
return nil unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)["result"]
rescue StandardError
nil # unreachable or slow — treat as unknown and re-verify later
end
end
That endpoint is a placeholder — check the API reference for the
exact base URL, the request shape, and how to mint a scoped key. The response wraps
a result object:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "catch_all": false }
}
}
status is one of deliverable, risky, undeliverable, or unknown. The
validator above hard-fails only undeliverable and lets risky and unknown
through, so you can decide downstream what to do with them — gate on score, or
branch on a sub_flags entry like disposable — instead of turning a borderline
address into a form error. And when the request itself fails, verify returns
nil and the validator adds no error: the syntax and MX layers already ran
locally, so softening only the network layer keeps a transient outage from
blocking a real signup. Treat the JSON above as the shape, not the contract; the
full field list lives in the developer docs.
Putting the layers together to validate an email address in Rails#
Declare all three validations on the attribute, cheapest first. Declaration order is run order, and each guard skips when a cheaper layer already failed, so the composed rule reads top to bottom exactly the way it executes:
app/models/user.rb
class User < ApplicationRecord
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP },
deliverable_domain: true,
deliverable_email: true
end
Now user.save only reaches the API on addresses that already cleared syntax and
the MX check — a typo fails at format and never spends a nameserver round-trip
or an API credit, and a dead domain fails at deliverable_domain before the
network call. user.valid? runs the whole chain, and user.errors[:email]
carries whichever layer objected. That ordering, not the framework, is what makes
validation reliable — the same shape as the
Ruby version, expressed as declarative
validations instead of a hand-chained method.
Because these are ordinary validations, every standard option works. Scope the two
network layers with if: :email_changed? so an unrelated user.update(name: ...)
doesn't re-run a DNS lookup and re-spend a credit on an address you already
verified. Keep the synchronous path to the fast local layers plus the immediate
API verdict at signup; anything heavier belongs in a background job. The
serverless signup guide shows the
real-time pattern, and how to clean an email list
covers the batch side.
Frequently asked questions#
Does Rails have a built-in way to check if an email is deliverable?#
No. Every built-in email affordance — the format validation with
URI::MailTo::EMAIL_REGEXP, the scaffolds, the email_field helper — validates
shape only. None of them resolves DNS or contacts a mail server, so a made-up
domain or a nonexistent mailbox passes. Add an MX lookup and an SMTP mailbox check
as custom validators to cover deliverability, since Rails ships neither.
How do I write a custom email validator in Rails?#
Subclass ActiveModel::EachValidator and implement
validate_each(record, attribute, value), calling record.errors.add(attribute, message) when the value fails. Save the class under app/validators so Zeitwerk
autoloads it, then attach it by its inferred key — a DeliverableDomainValidator
plugs in as deliverable_domain: true. It accepts the same options as any
validation, so if:, on:, and a custom message: all work unchanged.
Can I verify a mailbox exists in Rails without an API?#
Partly. The resolv standard library confirms the domain accepts mail, which
rules out dead domains for free from inside a custom validator. Confirming the
mailbox means an SMTP conversation you can open with net/smtp but shouldn't
run from your app server — outbound port 25 is widely blocked and the result
depends on your sending IP's reputation. That final layer is what a verification
service runs from reputation-managed infrastructure built for it.
Should I validate emails at signup or when cleaning a list?#
Both, at different depths. Run the syntax and MX layers synchronously at signup —
they're fast enough to block the request and give instant feedback — and act on
the immediate API verdict there too. Reserve deeper, batched verification for
cleaning an existing list, where latency
doesn't matter and thoroughness does. Scoping the network validators to
if: :email_changed? keeps ordinary updates from re-verifying an address you've
already checked.
Ready to add the mailbox layer? The API reference has the /verify
endpoint, scoped keys, and copy-paste snippets, or paste an address into the
free email checker and watch a syntactically perfect string
come back undeliverable.