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

How to validate an email address in Rails

8 minutes read

Qualisend team
A code editor window titled user.rb with a red Rails track mark and a green deliverable result pill, above the caption validates to MX to mailbox.

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.

Your reputation, protected.

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

Get started