How to Validate Emails at Signup in Rails 8 Without Blocking Real Users

hangrydev ·

The Regex That Costs You Users

Every Rails app starts the same way. You add validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } to your User model, write two specs, and move on. Months later, support tickets roll in. Someone’s o'[email protected] gets rejected. A user with a .agency TLD can’t sign up. Your “quick and clean” regex is now a customer acquisition bug.

Regex-only email validation creates two problems at once. It lets garbage through ([email protected] passes just fine) and blocks real people whose addresses don’t fit your pattern. Industry benchmarks put average email bounce rates around 2%, with bad list hygiene pushing that much higher. Overly aggressive validation makes it worse by rejecting legitimate addresses. Those users don’t file bug reports. They just leave.

This guide walks through the progression from basic to production-grade email validation in Rails 8, with code you can ship today.

What Rails 8 Gives You Out of the Box

Rails doesn’t ship its own email regex. It relies on Ruby’s standard library: URI::MailTo::EMAIL_REGEXP. This is the actual pattern:

# From Ruby's lib/uri/mailto.rb
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/

The standard Rails 8 model validation looks like this:

class User < ApplicationRecord
  normalizes :email, with: ->(e) { e.strip.downcase }

  validates :email, presence: true,
    format: { with: URI::MailTo::EMAIL_REGEXP, message: "doesn't look valid" },
    uniqueness: true
end

The normalizes call is a Rails 7.1+ feature that strips whitespace and downcases before validation. With normalization in place, you don’t need case_sensitive: false on the uniqueness check.

This handles the basics. It rejects strings without an @, catches spaces and control characters, and follows the RFC 5321 local-part rules closely enough for most cases.

But it has blind spots. Big ones.

What URI::MailTo::EMAIL_REGEXP won’t catch:

  • No MX check. [email protected] passes validation. The domain has no mail server. Every email you send there bounces.
  • No disposable detection. [email protected] looks perfectly valid to the regex. That address self-destructs in an hour.
  • No catch-all handling. Catch-all domains accept mail for any address, real or fake. The regex can’t distinguish [email protected] from [email protected].
  • No typo suggestions. [email protected] passes syntax validation. A human would catch the typo. The regex won’t.
  • Allows TLD-less addresses. user@localhost is valid per the regex. Not useful for a SaaS signup form.

For a signup form that just needs to prevent obvious garbage, the built-in regex is fine. For anything beyond that, you need more layers.

Level 2: The valid_email2 Gem

The valid_email2 gem is the most popular step up from bare regex. It wraps the mail gem’s parser, adds MX record lookups, ships a disposable email blocklist, and supports subaddress detection.

# Gemfile
gem "valid_email2", "~> 7.0"
class User < ApplicationRecord
  validates :email, presence: true,
    'valid_email_2/email': {
      mx: true,
      disposable: true,
      disallow_subaddressing: true,
      dns_timeout: 3
    }
end

The mx: true option triggers a DNS lookup for MX records on the email’s domain. Default timeout is 5 seconds, but you should lower it for signup forms. Three seconds is plenty.

You can also use the Address class directly:

address = ValidEmail2::Address.new("[email protected]")
address.valid?          # => true (syntax is fine)
address.disposable?     # => true (it's a throwaway domain)
address.valid_mx?       # => true (domain has MX records)
address.valid_strict_mx? # => true (MX points to actual mail server)
address.subaddressed?   # => false

The gem also supports strict_mx, which verifies the MX records point to actual mail servers rather than just existing in DNS. And you can maintain a custom deny list in config/deny_listed_email_domains.yml for domains you’ve seen abuse from.

Where valid_email2 Falls Short

The gem’s disposable domain list is static, bundled in the gem itself. It tracks a few thousand domains. New disposable services pop up weekly. Between gem updates, new throwaway providers slip through.

MX lookups add 50-300ms to every validation. That’s noticeable on a signup form, especially if DNS resolution is slow. And if the DNS query times out? The validation fails. A real user with a valid email on a slow DNS server gets blocked.

There’s no SMTP verification, no catch-all detection, and no typo suggestion. For many apps, that’s acceptable. But if email deliverability is core to your business (transactional SaaS, marketing platforms, e-commerce), you’ll hit these limits fast.

Level 3: API-Based Validation with MailCop

An email validation API checks everything the regex and gem can’t: SMTP mailbox existence, catch-all detection, real-time disposable domain lists (180,000+ domains), and typo correction. The trade-off is an external HTTP call.

A custom validator wrapping the MailCop API:

# app/validators/email_deliverability_validator.rb
class EmailDeliverabilityValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.blank?

    timeout = options.fetch(:timeout, 3)
    result = MailCop.validate(value, timeout: timeout)

    case result.status
    when "invalid", "undeliverable"
      record.errors.add(attribute, "doesn't appear to be deliverable")
    when "disposable"
      record.errors.add(attribute, "disposable emails aren't allowed")
    end
  rescue MailCop::TimeoutError, MailCop::ConnectionError
    # Fail open: don't block signup if API is unreachable
  end
end

Wire it into your model:

class User < ApplicationRecord
  validates :email, presence: true,
    format: { with: URI::MailTo::EMAIL_REGEXP },
    email_deliverability: { timeout: 3 }
end

The rescue block is critical. If the validation API is down or slow, you accept the signup and verify later. Never let an external service become a single point of failure for user registration.

The False Positive Problem

Most teams get burned right here. You tighten validation to block junk, and real users start bouncing off your signup form.

RFC 5322 allows email addresses that look bizarre to most developers. Quoted local parts like "john doe"@example.com. Apostrophes like o'[email protected]. Plus addressing like [email protected].

Even IP literal domains like user@[192.168.1.1] are technically valid.

A custom regex that tries to be “stricter” than the RFC will reject these. And the users behind those addresses don’t file bug reports. They just leave.

The fix? Stop trying to be smarter than the spec. Use URI::MailTo::EMAIL_REGEXP for syntax (it follows the RFC), then validate deliverability with MX or API checks. Don’t invent your own regex. The one in Ruby’s standard library already handles the edge cases you haven’t thought of.

If you’re rejecting plus-addressed emails to prevent duplicate signups, normalize them instead:

# Strip plus addressing for uniqueness check only
def normalized_email
  local, domain = email.split("@")
  local = local.split("+").first
  "#{local}@#{domain}".downcase
end

Async Validation for Batch Imports

Signup forms need real-time validation. But what about CSV imports? A 10,000-row contact upload can’t block on 10,000 synchronous API calls.

Queue the heavy checks with Sidekiq:

# app/jobs/validate_email_batch_job.rb
class ValidateEmailBatchJob < ApplicationJob
  queue_as :low

  def perform(contact_ids)
    Contact.where(id: contact_ids).find_each do |contact|
      result = MailCop.validate(contact.email, timeout: 10)
      contact.update!(
        email_status: result.status,
        email_disposable: result.disposable,
        email_catch_all: result.catch_all
      )
    end
  end
end

Enqueue in batches to stay within rate limits:

# In your import controller or service
contact_ids = Contact.where(email_status: "pending").pluck(:id)

contact_ids.each_slice(100) do |batch|
  ValidateEmailBatchJob.perform_later(batch)
end

For imports over 50,000 records, stagger the jobs. Most validation APIs allow 50-100 requests per second. A 100,000-contact import at 80 req/s takes about 20 minutes. Build that expectation into your UI.

Performance: The Latency Budget

Your signup form has roughly a 2-3 second latency budget before users start abandoning. Each validation layer eats into it:

  • Regex only: under 1ms. Negligible.
  • valid_email2 with MX: 50-300ms. Acceptable for most forms.
  • API call (syntax + MX): 100-400ms. Still within budget.
  • API call (full SMTP): 500-3,000ms. Too slow for synchronous signup.

The production pattern that works: run syntax + MX synchronously at signup, queue full SMTP verification as a background job. You catch the obvious garbage in real time (typo domains, missing MX records) and deep-verify later without blocking the form.

class RegistrationsController < ApplicationController
  def create
    @user = User.new(user_params)

    if @user.save
      DeepEmailVerificationJob.perform_later(@user.id)
      redirect_to dashboard_path
    else
      render :new, status: :unprocessable_entity
    end
  end
end

Timeout Handling and Graceful Degradation

External API calls fail. DNS lookups time out. Your validation can’t take down your signup flow.

# config/initializers/truemail.rb
MailCop.configure do |config|
  config.api_key = Rails.application.credentials.truemail_api_key
  config.timeout = 3  # seconds, for real-time checks
  config.open_timeout = 1
end

Set aggressive timeouts for the synchronous path. If the API doesn’t respond in 3 seconds, accept the email and flag it for async verification. The user gets in, and you validate in the background.

What about the API going down entirely? Cache recent validation results in Redis with a 24-hour TTL. If the same email hits your signup form twice in a day, serve the cached result instead of making another API call. This also cuts your validation costs.

Putting It All Together

The full progression, from zero to production-grade:

Stage 1 (MVP): URI::MailTo::EMAIL_REGEXP format validation. Free, instant, catches syntax errors. Ship this on day one.

Stage 2 (Growing): Add valid_email2 with mx: true and disposable: true. Catches dead domains and throwaway addresses. Adds 50-300ms latency.

Stage 3 (Production): API-based validation with MailCop. Synchronous syntax + MX at signup, async SMTP + catch-all verification via Sidekiq. Sub-400ms real-time path, full verification in background.

Most Rails apps should start at Stage 1 and move to Stage 3 when email deliverability starts affecting revenue. Don’t over-engineer validation for a pre-launch app. Don’t under-engineer it when you’re sending 100,000 transactional emails a month and your bounce rate is climbing.

The goal isn’t to block every bad email at the door. It’s to let every real user through while catching the addresses that’ll cost you money downstream.