Validating an email address in Ruby is three jobs hiding behind one method name. Most tutorials hand you a regex, watch it pass, and call the address valid — but a pattern match only tells you the string is shaped right, not that mail will ever reach it. Real validation is layered: a cheap syntax check, a DNS lookup for the domain's mail servers, and an SMTP probe of the actual mailbox. Ruby's standard library gives you the first two with no gems at all; the third is a network problem worth handing off. This guide builds each layer with working code and shows exactly where the standard library stops.
The short answer#
Use URI::MailTo::EMAIL_REGEXP from the uri library for syntax, Resolv::DNS
from the resolv library for the MX lookup, and a verification API for the SMTP
mailbox check — cheapest first, short-circuiting as soon as one layer is
decisive. Don't reach for net/smtp to probe mailboxes from your own app:
outbound port 25 is blocked on most hosts, and the answer depends on your sending
IP's reputation and greylisting behaviour you don't want to reimplement. Each
layer rules addresses out more cheaply than the last; only the final one can
rule an address in. That last step is the gap
email verification exists to close.
Layer 1: syntax#
Regex belongs here and nowhere else — and in Ruby you don't even have to write
one. The uri library, part of the standard library, ships a well-tested pattern
in URI::MailTo::EMAIL_REGEXP, so reach for that instead of pasting something
from Stack Overflow:
require "uri"
def valid_syntax?(email)
email.is_a?(String) &&
email.length <= 320 &&
email.match?(URI::MailTo::EMAIL_REGEXP)
end
valid_syntax?("jane@example.com") # => true
valid_syntax?("not-an-email") # => false
valid_syntax?("a@@b.com") # => false
Two things are worth knowing about that constant. It is already anchored with
\A and \z, so match? tests the whole string rather than a substring — you
don't need to wrap or anchor it yourself. And it is deliberately permissive: it
follows the WHATWG/HTML5 definition of a valid email, which is looser than RFC
5322 and, for example, happily accepts jane@localhost because it doesn't
require a dot in the domain. That is a feature, not a bug. The job of layer one is
to catch fat-finger typos at the input, not to litigate the RFCs — which
wouldn't help anyway. The 320-character
guard is belt-and-braces: nothing longer can be a real address, and it's cheaper
to reject early than to hand a giant string downstream. A pass here means "worth
checking", never "valid".
Layer 2: can the domain receive mail?#
This is where Ruby's standard library earns its keep. 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. The resolv library resolves
records with no gems:
require "resolv"
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
mail_route?("gmail.com") # => true
mail_route?("company-that-folded.com") # => false
Resolv::DNS.open hands you a resolver and closes it when the block exits, and
getresources returns an array of MX records — an empty array when the domain
publishes none or doesn't exist, which is exactly the "no route" case you want
to catch. The rescue handles the other failure mode: a resolver that errors or
times out. Note that Resolv::ResolvTimeout is not a subclass of
Resolv::ResolvError, so name both if you want a slow nameserver to fall through
cleanly rather than blow up the call.
When you want the actual mail hosts rather than a yes/no — to log them or inspect
priorities — each record carries exchange and preference, so sort by the
latter:
def mail_hosts(domain)
records = Resolv::DNS.open do |dns|
dns.getresources(domain, Resolv::DNS::Resource::IN::MX)
end
records.sort_by(&:preference).map { |mx| mx.exchange.to_s }
# => ["gmail-smtp-in.l.google.com", "alt1.gmail-smtp-in.l.google.com", ...]
rescue Resolv::ResolvError, Resolv::ResolvTimeout
[]
end
Some domains accept mail on an A record with an implicit MX; if you need to
honour that edge case, fall back to getresources(domain, Resolv::DNS::Resource::IN::A) when the MX set comes back empty. For the
overwhelming majority of real addresses, an MX check is the right filter.
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 that a specific mailbox exists 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 standard library will happily open that conversation for you with
net/smtp. 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.
Doing the full check with an API#
Qualisend's verification endpoint runs the whole pipeline — syntax, DNS, and the
SMTP mailbox probe — from reputation-managed infrastructure built for it, and
returns a single verdict. Because it's a plain JSON POST, the standard library's
net/http covers it with no gems. Read your key from the environment rather than
hard-coding it:
require "net/http"
require "json"
require "uri"
VERIFY_URL = URI("https://api.qualisend.com/v1/verify")
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) do |http|
http.request(request)
end
raise "Qualisend responded #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body, symbolize_names: true)
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 }
}
}
Reach into the envelope and act on status:
body = verify("jane@example.com")
result = body[:result]
result[:status] # "deliverable" | "risky" | "undeliverable" | "unknown"
result[:score] # confidence, 0–100
result[:reason] # machine-readable reason, or nil
result[:sub_flags] # { role:, disposable:, catch_all:, ... }
net/http keeps this dependency-free, which is handy in a background job or a
small service. If your app already leans on Faraday or HTTParty, the same request
is a couple of lines shorter — the envelope you read back is identical.
A complete way to validate an email address in Ruby#
Chain the three layers cheapest-first and stop the moment one is decisive:
def validate_email(email)
return { status: "undeliverable", reason: "invalid_email" } unless valid_syntax?(email)
domain = email.rpartition("@").last
return { status: "undeliverable", reason: "invalid_domain" } unless mail_route?(domain)
verify(email)[:result] # deliverable | risky | undeliverable | unknown
end
rpartition("@") splits on the last @, so you always take the real domain
even for oddly quoted local parts. The two local layers cost nothing and catch
most junk instantly; the API runs only on addresses worth the network round-trip.
That ordering — not the language — is what makes validation reliable, and it's the
same shape you'll find in the
Node.js and
Python versions of this guide.
Rails users can drop the whole thing into a model as a custom validation, so a bad address never reaches the database:
class User < ApplicationRecord
validate :email_must_be_deliverable
private
def email_must_be_deliverable
return if email.blank?
verdict = validate_email(email)
errors.add(:email, "doesn't look deliverable") if verdict[:status] == "undeliverable"
end
end
Keep the synchronous path to the two fast local layers plus the immediate API verdict; anything slower belongs in a background job. But the core is plain Ruby, and it behaves the same in a Sinatra route, a rake task, or a one-off script — the layering is what does the work.
Frequently asked questions#
Can I validate an email address in Ruby without any gems?#
For two of the three layers, yes. URI::MailTo::EMAIL_REGEXP from the uri
library handles syntax, and Resolv::DNS from the resolv library resolves MX
records — both ship with Ruby, so a syntax-plus-domain check needs nothing
installed. The third layer, confirming the mailbox itself, means an SMTP probe you
shouldn't run from your own server. That's the piece a verification service
handles for you.
Is URI::MailTo::EMAIL_REGEXP good enough on its own?#
It's the right layer-one check and a far better bet than a hand-rolled regex —
but it validates shape, not deliverability. It never resolves DNS or contacts a
mail server, and it's permissive by design (it accepts jane@localhost), so a
pass means "looks like an email," not "will deliver." Pair it with an MX lookup
and a mailbox check before you trust the address.
Why not use net/smtp to check whether a mailbox exists?#
net/smtp can open the SMTP conversation, but running it from your application
server is unreliable and risky: most hosts block outbound port 25, the response
depends on your sending IP's reputation, and probing at any volume gets you
rate-limited or blocklisted. A verification API runs the probe from
reputation-managed infrastructure built for exactly that.
Should I validate emails at signup or when cleaning a list?#
Both, at different depths. Run syntax and the MX lookup 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, slower work for list cleaning and back-office jobs; the serverless signup guide shows the live pattern end to end.
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.