# Validate an email address in Rails

_2026-10-21 · Boundstone (https://boundstone.io/blog/validate-email-rails)_


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:

```ruby
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](/blog/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`:

```ruby
# 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:

```ruby
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:

```json
{
  "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](/blog/validate-email-ruby). Want to eyeball a few addresses before writing any code? The [free email validator](/tools/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.
