← Blog

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_REGEXP via format:. 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::EachValidator that calls an API and maps the result onto record.errors.
  • Run the local regex first, the network check second, and fail open when the API is down.
  • Trust the not_performed list: Boundstone checks syntax, MX, disposable and role — not the mailbox itself. That boundary is the feature, not the fine print.

Frequently asked questions

How do I validate an email address in a Rails app?

Rails ships with a format validator, so you can write validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } in your model to reject malformed strings. That only checks syntax, though, so many teams add an API call after the format passes to confirm the domain actually publishes mail servers and to flag disposable or role addresses. Boundstone's email endpoint does exactly that in one call from a Rails service object or background job, returning valid_syntax, mx_found (MX records resolved over DNS), disposable, role_account, and free_provider.

Does validating an email in Rails prove the address can actually receive mail?

No, and it is worth being clear about that. Syntax validation only confirms the format is well-formed, and an MX check only confirms the domain publishes mail servers; neither one proves a specific mailbox exists or will accept your message. Boundstone is deliberate about this honesty: its email check performs syntax, MX, disposable-list, and role-list checks, and it reports SMTP mailbox verification and catch-all detection as not_performed. So a 'valid' result means the address looks deliverable, not that delivery is guaranteed.

How can I block disposable or role-based email signups in Rails?

Call an email-validation API in your signup flow and reject or flag the address based on the response before you create the user. Boundstone returns a disposable flag (matched against a maintained list of throwaway domains) and a role_account flag (addresses like info@ or support@), plus free_provider, so you can enforce your own policy in a before_save callback or service object. Treat these as risk signals rather than absolute proof, since Boundstone does not perform SMTP or catch-all checks and cannot confirm the mailbox is fake.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist