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:
- Perform basic format validation
- Block non-email domains
- 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 anArray
) - Lines 18 and 19: The
allow_nil
andallow_blank
options are supported, much like thevalidates_format_of
Rails validation. If set totrue
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.