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:
| Address | EMAIL_REGEXP | EmailValidator |
|---|---|---|
jane@example.com | ✓ | ✓ |
jane+news@ | ✓ | ✓ |
o'brien@ | ✓ | ✓ |
jane@example | ✓ | ✗ |
.jane@ | ✓ | ✗ |
jane | ✓ | ✗ |
jane..doe@ | ✓ | ✗ |
jane@example.c | ✓ | ✗ |
| 65 characters before the @ | ✓ | ✗ |
jane@-example.com | ✗ | ✗ |
jane@ | ✗ | ✗ |
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. 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):
- valid_email2 validates addresses “with the help of the
mailgem instead of some clunky regexp”. It plugs into ActiveModel (validates :email, 'valid_email_2/email': true) and has options for an MX/A record check (mx: true), a strict MX check (strict_mx: true), a list of disposable domains (disposable: true), a deny list and rejecting subaddresses such asjane+news@(disallow_subaddressing: true). The README notes that it lets an empty email pass, so you still needpresence: true, and that specs withmxfail without an internet connection unless you stub the lookup. - email_address calls itself “an opinionated validation library for email addresses”. In a model it runs as
validates_with EmailAddress::ActiveRecordValidator, field: :email. By default “MX records are required in DNS”, so every validation makes a DNS lookup unless you sethost_validation: :syntax. It can also compute a canonical form of an address (lowercased, without the tag after+) for duplicate checks. - truemail is a “framework agnostic plain Ruby email validator” with four layers: regex, MX, MX blacklist and SMTP. Its only required setting is
verifier_email, an existing address “on behalf of which verification will be performed”, and its SMTP port defaults to 25.
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 with | mx_hosts | accepts_mail? |
|---|---|---|
| Two MX records | ["mx1 | 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:
state:deliverable,undeliverableorunknown.reason: why, for examplevalid_mailbox,invalid_mailbox,invalid_mxorinvalid_format. Addresses on catch-all domains, which accept every address, come back asdeliverablewith the reasonlow_deliverability; see catch-all email.disposable,role,free: flags for throwaway inboxes, addresses likeinfo@, and free providers.did_you_mean: a suggested correction when the domain looks like a typo.
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.
Recommended setup for Rails sign-up forms
- Normalize the address (strip, downcase) before validation.
presence: trueplus theEmailValidatorabove for instant, offline feedback.uniqueness: { case_sensitive: false }and a unique index.- The API check on create, skipped when the format already failed, and never blocking when the API is unreachable.
- 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:
- Rails Guides: Active Record Validations (format, uniqueness, custom validators); Rails 7.1 release notes; Rails API: ActiveRecord::Normalization
- Ruby: lib/uri/mailto.rb (
EMAIL_REGEXP), Resolv::DNS - READMEs: valid_email2, email_address, truemail
- HTML Standard: valid email address
- RFC 5321 (SMTP), RFC 7505 (null MX)