Catching the other kind of bad address. A disposable address is someone hiding from you; a typo is someone who wanted to reach you and can't. user@gmail.con isn't a throwaway — it's a real person whose account nobody will ever be able to reach, because .con has never existed. Both new checks are opt-in: upgrading this gem will not start rejecting addresses that were fine yesterday.
- TLD validation (
config.check_tld, defaultfalse): rejects addresses whose TLD isn't in the IANA root zone. The gem now bundles a snapshot of data.iana.org/TLD/tlds-alpha-by-domain.txt (~1,438 entries, ~9 KB) loaded once into a frozenSet— no migration, no table, no query per signup, and it can't be empty on a fresh install. A domain with no dot at all (example@gmailmcom) has no TLD and is rejected too; IP literals and unicode TLDs are deliberately left alone. config.additional_tlds: accept a TLD delegated after your installed version shipped, without waiting for a release. This is the escape hatch that keeps a stale snapshot from ever locking anyone out.Nondisposable::Tld::SPECIAL_USE: the RFC 6761 / RFC 2606 reserved names (test,example,invalid,localhost,local,onion).⚠️ Heads up when you switchcheck_tldon: these are reserved so they can never be delegated, so they aren't in the root zone and are rejected — which is correct for a signup form, and will also turn your fixtures atuser@example.testred. One line, scoped to where it belongs:config.additional_tlds = Nondisposable::Tld::SPECIAL_USE if Rails.env.local?config.blocked_tldsandconfig.allowed_tlds: refuse real TLDs you don't want (the free Freenom settk ml ga cf gqis the usual reason), or invert it into an allowlist.- Lookalike detection:
Nondisposable.suggestion_for("someone@gmial.com") # => "someone@gmail.com", matching against a bundled list of ~240 well-known providers using optimal string alignment distance, so an adjacent swap counts as the one mistake a human actually made. This catches what a TLD check structurally cannot —.co,.cmand.omare Colombia, Cameroon and Oman, all real. Always available, never blocks anything. config.reject_lookalike_domains(defaultfalse): turns that suggestion into a validation error naming the correction. Off by default on purpose — a suggestion is a guess about intent, and a wrong guess stops a real person signing up with their real address.config.lookalike_distance(default1) andconfig.additional_email_providerstune it.Nondisposable.valid_tld?(email)andNondisposable.suggestion_for(email)as direct checks, alongside the existingdisposable?.rake nondisposable:tlds:update: refreshes the bundled snapshot from IANA. A maintenance task for a checkout of this gem, not something host apps run — it refuses to overwrite a good list with an implausibly short one, writes atomically, and preserves IANA's version header (readable viaNondisposable::Tld.version).- New error messages:
invalid_tld_error_message,blocked_tld_error_message,lookalike_error_message(which interpolates%{suggestion}).
- The validator now adds at most one error however many checks fire, and prefers the most useful message available: whenever the gem can name a correction, that phrasing wins over the generic "doesn't look like a real email address". The disposable check still takes precedence over both — a correct spelling wouldn't help a throwaway provider.
- Bundled seed list: the gem now ships a snapshot of the upstream blocklist (8,201 domains) and
DomainListUpdater.seedpopulates the table from it when (and only when) the table is empty. The install generator's migration seeds automatically, andDomainListUpdater.updatefalls back to seeding when the remote fetch fails against an empty table. This closes the fresh-install fail-open window where every signup passed until the first successful remote update. - Parent-domain matching (
config.check_parent_domains, defaulttrue): an email atx.tempmail.comis now blocked whentempmail.comis on the list. The check walks up to 3 parent labels and never matches bare TLDs. Set tofalseto restore exact-only matching. - Configurable failure mode (
config.on_check_failure, default:allow): controls what happens when the disposable check itself raises (e.g. database hiccup).:allowlets the record through with a loud error log (availability-first);:rejectpreserves the previous behavior and error message. Invalid values raiseArgumentError.
- Behavior change: a broken check no longer rejects signups by default (previously any
StandardErrorduring validation added an error to the record). Setconfig.on_check_failure = :rejectto keep the old fail-closed behavior. - Behavior change: subdomains of listed domains are now blocked by default (see parent-domain matching above). Set
config.check_parent_domains = falsefor the old exact-only behavior. DomainListUpdaternow uses 10s open/read timeouts on the fetch (previously Net::HTTP's 60s defaults) so a hung connection can't block the update job.- Domain list updates use a single bulk
insert_allinstead of per-rowcreate, and report any rows skipped by the unique index instead of silently swallowing them. - Downloaded domains are normalized before insert: whitespace/CR stripped, blank lines dropped, case-insensitive dedupe.
additional_domainsandexcluded_domainsnow match case-insensitively everywhere. - An exact entry in
excluded_domainsalways wins, so a specific subdomain can be allowlisted under a listed parent domain. Nondisposable.configurationis lazily initialized: apps without an initializer no longer fail every validation with "cannot check if it's disposable".
- Dead
require 'open-uri'in the domain list updater.
- Add
[nondisposable]prefix to all logger calls for better log identification
- Fixed
NoMethodErrorwhen email is"@"or malformed (empty domain after split) - Removed non-existent asset references from engine (
nondisposable/application.cssand.js) - Removed buggy
railtie.rbfile that attempted to include a class instead of a module - Added comprehensive Minitest 6 test suite with 256 tests and 90%+ line / branch coverage
- Initial release