Tutorial · Ruby on Rails

Rails email validation: format, MX and mailbox checks in Ruby

Four layers of email validation for a Rails app: a format validation in the model, a stricter custom validator, a DNS check of the domain, and a check that the mailbox really exists.

Updated October 10, 2026 · 9 min read

Rails email validation: URI::MailTo::EMAIL_REGEXP accepts jane@example and jane..doe@example.com, the stricter EmailValidator rejects them

Rails email validation usually starts and ends with one line in the model: a format check with URI::MailTo::EMAIL_REGEXP. That line is a good start, but it accepts addresses that can never receive mail and says nothing about whether the mailbox exists. This guide adds the missing layers in the order they run: format, a stricter pattern, the domain’s MX records, and the mailbox itself.

The format validators and the API check were run with Ruby 3.3.6 and ActiveModel 6.1.7; normalizes and uniqueness need Active Record (Rails 7.1+) and were checked against the Rails API docs. The API examples ran against a local mock that returns the documented response format.

Rails validate email format with URI::MailTo::EMAIL_REGEXP

The Rails Guides on Active Record validations validate email with the regex constant from Ruby’s uri library:

class SignupForm
  include ActiveModel::Model

  attr_accessor :email

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end

form = SignupForm.new(email: "jane doe@example.com")
form.valid?               # => false
form.errors.full_messages # => ["Email is invalid"]

SignupForm.new(email: "jane@example").valid? # => true

The same validates line works in an ApplicationRecord model. “is invalid” is the default message of the format validator; pass message: to change it.

URI::MailTo::EMAIL_REGEXP isn’t a Rails invention. The comment above it in Ruby’s source calls it a “practical regexp for email address” and links to the HTML standard’s definition, which is the rule browsers use for <input type="email">:

URI::MailTo::EMAIL_REGEXP
# => /\A[a-zA-Z0-9.!\#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/

That makes it lenient in a few places that matter for sign-ups: it doesn’t require a dot after the @, it allows dots at the start and end of the local part and two dots in a row, and it has no length limits. The HTML email validator guide explains why the standard chose these rules.

A stricter email validator for Rails

A custom validator class keeps the rule in one place. The Rails Guides show this pattern with an EmailValidator < ActiveModel::EachValidator; here it uses the stricter, tested pattern from the email regex guide instead:

# app/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
  PATTERN = /\A(?=.{1,254}\z)(?=.{1,64}@)[A-Za-z0-9!\#$%&'*+\/=?^_`{|}~-]+(?:\.[A-Za-z0-9!\#$%&'*+\/=?^_`{|}~-]+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{2,59})\z/

  def validate_each(record, attribute, value)
    return if value.blank? # leave empty values to `presence: true`
    return if value.is_a?(String) && PATTERN.match?(value)

    record.errors.add(attribute, options[:message] || "is not a valid email address")
  end
end

Rails finds the class by name, so email: true in a model turns it on:

validates :email, presence: true, email: true

Both patterns, run against the same addresses:

AddressEMAIL_REGEXPEmailValidator
jane@example.com✓✓
jane+news@example.com✓✓
o'brien@example.ie✓✓
jane@example✓✗
.jane@example.com✓✗
jane.@example.com✓✗
jane..doe@example.com✓✗
jane@example.c✓✗
65 characters before the @✓✗
jane@-example.com✗✗
jane@exa_mple.com✗✗
jane doe@example.com✗✗
josé@example.com✗✗
jane@example.com + line break✗✗

Both are anchored with \A and \z, so a value with a line break fails. That matters in Ruby, where ^ and $ match at every line, and a pattern written with them lets "<script>\njane@example.com" through.

Ruby email validation without Rails

Outside Rails, the same checks are plain method calls; uri and resolv ship with Ruby:

require "uri"

URI::MailTo::EMAIL_REGEXP.match?("jane@example.com") # => true
EmailValidator::PATTERN.match?("jane@example")        # => false

Normalize before you validate and store

Strip whitespace and downcase the address before it is validated and saved. Rails 7.1 added normalizes to Active Record for this; the Rails API documentation uses email as its example: normalizes :email, with: -> email { email.strip.downcase }. The normalization also applies to finder arguments such as find_by(email:), but not to records already in the database.

For uniqueness, the Rails Guides offer uniqueness: { case_sensitive: false } and warn that the validation doesn’t create a uniqueness constraint in the database: two parallel requests can still both pass it. Add a unique index on the column as well. Strictly, RFC 5321 says the local part “MUST BE treated as case sensitive” by the receiving server, but adds that exploiting this “impedes interoperability and is discouraged”, so downcasing is the practical choice.

Email validation gems for Rails

Three gems come up in most Rails email validation threads. What they do, according to their own READMEs (we didn’t install them for this guide):

A gem that talks SMTP from your own server needs outbound port 25, which many cloud hosts restrict, and a mail server that answers honestly. The guide to checking if an email exists explains what can go wrong with direct SMTP checks.

Check the domain’s MX records with Resolv

A domain that can’t receive mail makes every address on it useless. Resolv::DNS from Ruby’s standard library looks up the MX records:

require "resolv"

def mx_hosts(domain)
  Resolv::DNS.open do |dns|
    dns.timeouts = 3
    dns.getresources(domain, Resolv::DNS::Resource::IN::MX)
       .sort_by(&:preference)
       .map { |mx| mx.exchange.to_s }
  end
end

# true: takes mail; false: null MX; nil: nothing found or DNS failed (Resolv can't tell which), so don't reject
def accepts_mail?(domain)
  Resolv::DNS.open do |dns|
    dns.timeouts = 3
    mx = dns.getresources(domain, Resolv::DNS::Resource::IN::MX)
    return false if mx.size == 1 && mx.first.exchange.to_s.empty? # null MX (RFC 7505)
    return true if mx.any?
    # No MX record: mail goes to the A/AAAA record (implicit MX)
    return true if dns.getresources(domain, Resolv::DNS::Resource::IN::A).any? ||
                   dns.getresources(domain, Resolv::DNS::Resource::IN::AAAA).any?
    nil
  end
end

Run against a local test DNS server, it returned:

Domain set up withmx_hostsaccepts_mail?
Two MX records["mx1.example.org", "mx2.example.org"]true
Null MX (0 .)[""]false
A record only[]true
No such domain / SERVFAIL / timeout[]nil

Resolv returns an empty list for a missing domain, a SERVFAIL and a timeout alike, so treat nil as unknown, not as a reject. With timeouts = 3, a server that never answers costs up to 9 seconds, because accepts_mail? makes three lookups.

The null MX is the way a domain says it receives no email at all (RFC 7505); example.com publishes one, which is why test addresses at example.com bounce. A domain without any MX record isn’t necessarily dead either: senders fall back to its A or AAAA record (RFC 7505, section 1), which is why accepts_mail? checks those before it gives up. The guide what is an MX record covers the details.

Check that the mailbox exists with the API

Format and MX checks pass for any made-up address at a large mail provider. Only the recipient’s mail server knows whether the mailbox exists. The emailvalidation.io email validation API asks it, without sending an email, and also flags disposable, role and free-provider addresses. No SDK is needed; Net::HTTP is enough:

# app/services/email_verification.rb
require "net/http"
require "json"

module EmailVerification
  ENDPOINT = URI("https://api.emailvalidation.io/v1/info")

  # Returns the parsed result, or nil when the API can't be reached in time.
  def self.check(email)
    uri = ENDPOINT.dup
    uri.query = URI.encode_www_form(email: email)
    request = Net::HTTP::Get.new(uri)
    request["apikey"] = ENV.fetch("EMAILVALIDATION_API_KEY")

    response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
                               open_timeout: 5, read_timeout: 10, max_retries: 0) do |http|
      http.request(request)
    end
    return nil unless response.is_a?(Net::HTTPSuccess)

    JSON.parse(response.body)
  rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, IOError,
         OpenSSL::SSL::SSLError, JSON::ParserError
    nil
  end
end

The key goes in the apikey header, which keeps it out of URLs and logs. max_retries: 0 matters: by default Net::HTTP repeats a GET once after a read timeout, so a slow answer would hold the sign-up for 20 seconds instead of 10. Then call it from the model, only when a record is created and only when the format is already fine:

class User < ApplicationRecord
  validates :email, presence: true, email: true
  validate :email_must_be_deliverable, on: :create

  private

  def email_must_be_deliverable
    return if errors.include?(:email) # format already failed, skip the request

    result = EmailVerification.check(email)
    return if result.nil? # API unreachable: accept rather than block sign-ups

    if result["state"] == "undeliverable"
      errors.add(:email, "does not exist (#{result['reason']})")
    elsif result["disposable"]
      errors.add(:email, "is a disposable address, please use a permanent one")
    end
  end
end

With the mock answering, a deliverable address saved, an address the mock reported as undeliverable failed with “Email does not exist (invalid_mailbox)”, and a disposable one failed with the second message. With the mock switched off, all well-formed addresses saved, which is the intended behavior: a validation outage should never stop sign-ups. Log the nil results so you notice a wrong key (401) or a used-up quota (429).

The response has 15 fields. The ones you’ll branch on:

unknown means the mail server gave no clear answer, for example because it timed out. Accept those addresses and recheck them later. The free plan includes 100 requests a month at up to 10 a minute; see email validation API pricing for more. To check addresses already in your database, export them and run them through the bulk email verifier or an email list cleaning service by API instead of looping over /v1/info.

  1. Normalize the address (strip, downcase) before validation.
  2. presence: true plus the EmailValidator above for instant, offline feedback.
  3. uniqueness: { case_sensitive: false } and a unique index.
  4. The API check on create, skipped when the format already failed, and never blocking when the API is unreachable.
  5. In the form itself, type="email" gives mobile users the right keyboard; the JavaScript email validation guide adds typo hints on the client.

Sources

All checked October 10, 2026:

Validate email in other languages

Frequently asked questions

How do I validate an email address in Rails?

Add a format validation to the model: validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }. That checks the shape of the address. To catch addresses that are well-formed but don’t exist, add a check against the recipient’s mail server, for example with an email validation API, in a separate validate method that only runs on create.

What regex does Rails use for email validation?

Rails has no email validator of its own. The Rails Guides use URI::MailTo::EMAIL_REGEXP from Ruby’s standard library, which implements the email rule of the HTML standard. It accepts jane@example without a top-level domain and dots at the start or end of the local part, so use a stricter pattern if those matter to you.

How do I validate an email in Ruby without Rails?

Use URI::MailTo::EMAIL_REGEXP.match?(email) from the uri library that ships with Ruby, or a stricter pattern anchored with \A and \z. For the domain, Resolv::DNS from the standard library looks up MX records without extra gems.

Should email uniqueness in Rails be case-insensitive?

Usually yes. RFC 5321 lets a mail server treat the local part as case-sensitive but says relying on that is discouraged, and in practice Jane@example.com and jane@example.com are the same person. Downcase the address before saving, use uniqueness: { case_sensitive: false }, and add a unique index, since the Rails Guides point out that the validation alone can’t prevent duplicates from parallel requests.

Email validation API

Validate emails in your app

emailvalidation.io checks syntax, MX records and the mailbox over SMTP, flags disposable, role and free addresses and returns a quality score, in one request.

/v1/info Email validation API Read the documentation

100 free validations every month. No credit card required.

GET https://api.emailvalidation.io/v1/info?email=support@emailvalidation.io

{
  "email": "support@emailvalidation.io",
  "user": "support",
  "tag": "",
  "domain": "emailvalidation.io",
  "format_valid": true,
  "mx_found": true,
  "smtp_check": true,
  "catch_all": null,
  "role": true,
  "disposable": false,
  "free": false,
  "score": 0.64,
  "state": "deliverable",
  "reason": "valid_mailbox",
  "did_you_mean": ""
}

Keep reading

Start using our email validation software today!

Get 100 validations per month for free