Validate an email address in Rails
Rails ships a format regex and stops there — here's a custom ActiveModel validator that calls an API for MX, disposable and role detection.
Contents
You typed "validate email in Rails" into a search bar because a format: line accepted test@test — or worse, accepted a real-looking address that bounces the moment you send to it. Rails has an answer for the first problem and nothing for the second. This walks through both: the built-in regex that catches malformed input, and a custom validator that asks whether the domain can actually receive mail.
What Rails gives you out of the box
Rails does not ship a dedicated email validator. What it ships is a well-formed regular expression in the Ruby standard library — URI::MailTo::EMAIL_REGEXP — and the format: validation to run it against:
class User < ApplicationRecord
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
end
This is genuinely useful and, for a lot of forms, enough. It rejects not-an-email, a missing @, stray spaces, and the other typos that make up most bad input. For plenty of apps you can stop right here.
What it does not do is check whether the address can receive mail. URI::MailTo::EMAIL_REGEXP is a syntax check. It does not look up DNS, it has never heard of the domain, and it will happily pass ceo@some-domain-that-expired-in-2019.com. Rails does not check MX records — do not let a green checkmark suggest otherwise.
Where syntax validation stops
Three things a regex structurally cannot see:
- MX records. Whether the domain publishes a mail server at all. No MX, no delivery. (If "MX record" is new, see what is an MX record.)
- Disposable domains.
mailinator.com,guerrillamail.com, and a long list of throwaway hosts are perfectly valid syntax and exist to evade you. - Role accounts.
info@,support@,admin@— real, deliverable, and usually not the person signing up.
None of these are format problems, so no regex will catch them. You have to look the domain up.
A custom validator that checks more than syntax
ActiveModel::EachValidator is the Rails-native way to add a rule. Here is one that calls Boundstone's email endpoint and turns its answer into standard record.errors:
# app/validators/email_deliverability_validator.rb
require "net/http"
require "json"
class EmailDeliverabilityValidator < ActiveModel::EachValidator
API_URL = URI("https://api.boundstone.io/v1/verify/email")
def validate_each(record, attribute, value)
return if value.blank?
result = verify(value)
return if result.nil? # network trouble: fail open, don't block signup
unless result["mx_found"]
record.errors.add(attribute, "does not have a mail server")
end
if result["disposable"]
record.errors.add(attribute, "is a disposable address")
end
if options[:reject_role] && result["role_account"]
record.errors.add(attribute, "is a shared role address")
end
end
private
def verify(email)
request = Net::HTTP::Post.new(API_URL)
request["Authorization"] = "Bearer #{ENV.fetch('BOUNDSTONE_API_KEY')}"
request["Content-Type"] = "application/json"
request.body = { email: email }.to_json
response = Net::HTTP.start(API_URL.host, API_URL.port, use_ssl: true) do |http|
http.request(request)
end
JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
rescue StandardError
nil
end
end
Wire it into the model alongside the syntax check — cheap check first, network check second:
class User < ApplicationRecord
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP },
email_deliverability: { reject_role: true }
end
format: runs locally and rejects garbage before you spend a network round-trip; the deliverability validator only runs for input that is at least shaped like an email.
A note on that rescue: the validator returns nil and adds no error when the API is unreachable. That is a deliberate fail-open — a signup form should not hard-block because a third party had a bad second. Decide that trade-off on purpose rather than inheriting it by accident.
Reading the response — and what it doesn't claim
A call to POST /v1/verify/email returns:
{
"valid_syntax": true,
"domain": "example.com",
"mx_found": true,
"disposable": false,
"role_account": false,
"free_provider": false,
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
}
The mx_found, disposable, and role_account booleans drive the validator. free_provider flags Gmail/Outlook-style consumer domains — returned for your own logic, but note it is not a member of checks.performed.
The checks object is the point. performed lists exactly what was done: ["syntax", "mx", "disposable_list", "role_list"]. not_performed lists what was not: ["smtp_mailbox", "catch_all"]. Boundstone does not knock on the mailbox over SMTP and does not detect catch-all domains — so it will not tell you a specific inbox exists. Nobody can promise that reliably, and a "valid" you can trust is one that tells you where it stops. What you get is the MX + disposable + role layer under one contract, identical whether you call it from Rails, a Sidekiq job, or curl.
Prefer plain Ruby without Rails, or want the Net::HTTP details on their own? See validate an email address in Ruby. Want to eyeball a few addresses before writing any code? The free email validator runs the same checks in the browser, no signup.
The short version
- Rails validates email syntax with
URI::MailTo::EMAIL_REGEXPviaformat:. It does not check MX. For many forms, syntax is all you need — ship it. - To catch dead domains, disposable hosts, and role accounts, add an
ActiveModel::EachValidatorthat calls an API and maps the result ontorecord.errors. - Run the local regex first, the network check second, and fail open when the API is down.
- Trust the
not_performedlist: Boundstone checks syntax, MX, disposable and role — not the mailbox itself. That boundary is the feature, not the fine print.