Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / June 16, 2026

How to validate an email address in Ruby

7 minutes read

Qualisend team
A code editor window titled validate.rb with a red ruby gem mark and a green deliverable result pill, above the caption syntax to MX to mailbox.

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.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started