Angelos Orfanakos

DIY email validation in Rails

I recently needed email validation in a Rails app and decided to roll my own for the following reasons:

  • I have a strong aversion to introducing external dependencies (i.e. Gems) for simple things
  • What I wanted was pretty specific
  • Writing custom validators in Rails is easy

The implementation consists of 3 steps:

  1. Perform basic format validation
  2. Block non-email domains
  3. Block domains from disposable email services

Perform basic format validation

It’s notoriously hard to validate an email address with a regular expression. The reason is that both the local part (before the @) and the domain (after the @) can accept a wide variety of characters and symbols, so you may end up rejecting a perfectly valid email address you haven’t covered for.

To keep things simple, I used a simple regular expression to check for basic things, accepting the risk it might accept emails it shouldn’t.

Block non-email domains

In order to accept emails, a domain name must have the necessary MX nameserver records.

Block domains from disposable email services

Disposable email services like Mailinator are useful for users and a headache for website authors.

Thankfully, there are projects that maintain a list of such domains we can check against.

The solution

First, we need to get the list of disposable email domains into our Rails app:

cd railsapp/
git submodule add https://github.com/martenson/disposable-email-domains vendor/disposable-email-domains
git add vendor/disposable-email-domains
git commit -m 'Add disposable-email-domains as Git submodule'

Whenever there’s an update in the repo, we can retrieve the new blacklisted domains as follows:

cd railsapp/
git submodule update vendor/disposable-email-domains
git add vendor/disposable-email-domains
git commit -m 'Update disposable-email-domains Git submodule'

Now it’s time for the validator code which I place under app/lib/email_validator.rb so that it gets autoloaded:

require 'resolv'

class EmailValidator < ActiveModel::EachValidator
  EMAIL_REGEX = /[a-z0-9._-]{1,64}@[a-z0-9._-]{1,255}/.freeze
  private_constant :EMAIL_REGEX

  DISPOSABLE_EMAIL_DOMAINS = File.readlines(    Rails.root.join(
      'vendor',
      'disposable-email-domains',
      'disposable_email_blocklist.conf'
    ),
    chomp: true
  ).each_with_object({}) { |domain, hash| hash[domain] = true }.freeze
  private_constant :DISPOSABLE_EMAIL_DOMAINS

  def validate_each(record, attribute, value)
    return if value.nil? && options.fetch(:allow_nil, false)
    return if value.blank? && options.fetch(:allow_blank, false)

    if options.fetch(:format, true) &&
        (value.nil? || !valid_format?(value))
      record.errors.add(attribute, :invalid)
      return    end

    if options.fetch(:non_disposable_domain, true) &&
        !value.nil? &&
        disposable_domain?(value)
      record.errors.add(attribute, :disposable_domain)
    end

    if options.fetch(:mail_domain, true)
        (value.nil? || !mail_domain?(value))
      record.errors.add(attribute, :no_mail_domain)
    end
  end

  private def valid_format?(value)
    value.match?(/\A#{EMAIL_REGEX}\z/)
  end

  private def email_domain(value)
    value.split('@', 2).last
  end

  private def disposable_domain?(value)
    DISPOSABLE_EMAIL_DOMAINS.key?(email_domain(value))
  end

  private def mail_domain?(value)
    Resolv::DNS.open do |dns|
      dns.getresources(
        email_domain(value),
        Resolv::DNS::Resource::IN::MX
      ).any?
    end
  end
end

Some things to note:

  • Line 1: The resolv built-in Ruby library is used to check for MX nameserver records
  • Line 4: The email regular expression
  • Lines 7-14: The list of blacklisted emails is kept in a constant as a Hash to speed up lookups (versus iterating over an Array)
  • Lines 18 and 19: The allow_nil and allow_blank options are supported, much like the validates_format_of Rails validation. If set to true with a presence validation and the email is missing, the only error will be that from the presence validation.
  • Lines 21-25: Format validation
  • Line 24: If the email fails the format validation, we early-return since the later checks are more expensive and we already know the email is invalid.
  • Lines 27-31: Disposable email domain validation
  • Lines 33-37: Mail domain validation

To use it in a hypothetical model:

class User < ApplicationRecord
  validates(
    :email,
    presence: true,
    uniqueness: { case_sensitive: false, allow_blank: true },
    email: { allow_blank: true }
  )

  # ...
end

To skip checking disposable email domains:

email: { allow_blank: true, non_disposable_domain: false }

To skip checking non-email domains:

email: { allow_blank: true, mail_domain: false }

Update: An earlier version of this post used git pull instead of git submodule update to update the submodule. Thanks to Fotis Alexandrou for pointing it out.