Email address checks
Every address a person types into the app is checked before it is saved: at sign-up, in Settings, on Devise's /users/edit, and in the admin's user form. Two kinds of address are stopped, for different reasons:
- Throwaway addresses (10-minute inboxes and the like) belong to someone who does not want to be reachable. They are the raw material of abusive sign-ups: trial farming, fake accounts, verification flows exploited for SMS pumping.
- Mistyped addresses (
gmail.con,gmial.com) belong to someone who wanted to reach you and never will. The confirmation email bounces, they wait for it, and they leave thinking the product is broken.
It is the nondisposable gem (0.6.2), configured in config/initializers/nondisposable.rb and validated on User with validates :email, nondisposable: true. The template's docs/email-address-checks.md is the full reference.
What a person sees
| They type | What happens |
|---|---|
[email protected], [email protected] |
Accepted. |
[email protected], [email protected] |
Accepted. Privacy relays forward to a real inbox (block_privacy_relays = false). |
[email protected], [email protected] |
Refused: "is from a temporary email service". |
[email protected], ana@gmailcom |
Refused: "Did you mean [email protected]?". No mail can arrive at a TLD that does not exist. |
[email protected], [email protected] |
Refused: "Did you mean …?". A reviewed typo nobody has a mailbox at. |
[email protected] |
Asked: "Did you mean [email protected]?" plus a "Yes, this is my email" checkbox. Ticking it and submitting again lets the address through. |
[email protected] |
Refused: "doesn't look like a real email address". |
The lookalike case is the one to understand. One letter away from a big provider is not proof of a typo: real companies sit one letter from me.com or aol.com. So a guess never refuses anyone on its own. It asks, and the person who really owns the address answers once.
Messages come from errors.messages.* in config/locales/{en,es}/users.yml, so each person reads them in their own language.
Where the checks run, and where they do not
The validation runs only when the email is being set (if: :will_save_change_to_email?). Sign-in, password reset, invitation acceptance and background saves are untouched, and an existing user whose domain a newer rule would refuse can still use the account and change away from it.
Three paths answer the lookalike question without a checkbox, because they carry stronger evidence than a tick: the reconfirmation link (it proves the new mailbox exists), a social sign-up (the provider verified the address), and an address already on the account.
Any form you add that writes User#email needs two lines, or a real owner of a lookalike address is stuck behind an error they cannot clear: render the confirmation partial right under the email input, and permit :email_lookalike_confirmed in that controller's strong parameters.
<%= render "users/email_lookalike_confirmation", form: f %>
Tuning
Everything lives in config/initializers/nondisposable.rb, with every setting written out and explained:
- A throwaway domain the lists miss:
config.additional_domains. - A real domain a list refuses:
config.excluded_domains. It wins over everything else. - Your company domain, or a regional provider your customers use, being questioned as a typo:
config.additional_email_providers. - A TLD newer than the installed gem:
config.additional_tlds. - The lookalike message:
config.lookalike_error_message = :email_lookalike, an error name resolved in the request's locale with%{suggestion}as the corrected address. Since 0.6.2 it is a Symbol like the other three messages.
The blocklist and its nightly refresh
The throwaway domains live in the nondisposable_disposable_domains table. A fresh install is protected from the first request because db/seeds.rb loads the list bundled with the gem (Nondisposable::DomainListUpdater.seed); db:prepare runs the seeds when it creates a database. Keep that line if you rewrite your seeds.
DisposableEmailDomainListUpdateJob refreshes the list every night at 3am (refresh_disposable_domains in config/recurring.yml). Each source fetch is kept as a snapshot in nondisposable_source_snapshots, and a fetch that shrinks by half, triples, or is mostly junk is refused, so one bad upstream night can never empty the list.
Useful from a console:
Nondisposable::DomainListUpdater.update # refresh now
Nondisposable.disposable?("[email protected]")
Nondisposable.suggestion_for("[email protected]") # => "[email protected]"
Upgrading an existing app
The checks arrived in RailsFast v1.3.0 and v1.3.1. After merging:
bundle install, thenbin/rails db:migratefor thenondisposable_source_snapshotstable.- Deploy outside the 3am refresh window, so no old process is mid-refresh.
- If your disposable-domain table is empty, run
bin/rails db:seedonce, orNondisposable::DomainListUpdater.updatefrom a console.
Existing users are never re-judged: only an address being set is checked. If your config/initializers/nondisposable.rb still carries the old lookalike adapter object from v1.3.0, it keeps working, but the Symbol is the recommended form.