Module: Nondisposable::Suggestion
- Defined in:
- lib/nondisposable/suggestion.rb
Overview
"Did you mean gmail.com?"
A TLD check catches the typos that produce a domain ending which cannot
exist — .con, .cpm, .ocm. It structurally cannot catch the far more
common ones, because .co (Colombia), .cm (Cameroon) and .om (Oman) are
all real, delegated TLDs, as are .se and .es. user@gmail.co is a
perfectly well-formed address at a perfectly real TLD, and it is still
almost always a finger that slipped off the m.
So this layer asks a different question: is this domain one keystroke away from a well-known email provider, while not being one itself?
HOW IT DECIDES
Optimal string alignment distance (Damerau-Levenshtein restricted to
adjacent transpositions) against data/email_providers.txt. Transposition
matters more than it looks: gmial.com is the single most common Gmail
misspelling, and plain Levenshtein scores it 2 while a human sees one
mistake. Under OSA it scores 1, alongside gmai.com, gmail.co and
gmail.con.
WHY THE DEFAULT THRESHOLD IS 1, AND WHY THIS IS OFF BY DEFAULT
Every suggestion is a guess about intent, and a wrong guess wired to
reject_lookalike_domains stops a real person from signing up with their
real address. One edit is the distance at which a guess is safe enough to
act on; at two, legitimately distinct domains start colliding. The exact
match check runs first and always wins, which is why the provider list has
to be generous — see the header of data/email_providers.txt.
Suggesting is always available and never blocks:
Nondisposable.suggestion_for("someone@gmial.com") # => "someone@gmail.com"
Nondisposable.suggestion_for("someone@gmail.com") # => nil
Blocking on it is a separate, deliberate opt-in
(config.reject_lookalike_domains), because "probably a typo" is a weaker
claim than "this TLD does not exist" and deserves a weaker remedy.
Constant Summary collapse
- LIST_PATH =
File.('../../data/email_providers.txt', __dir__)
Class Method Summary collapse
-
.correct_domain(domain) ⇒ Object
The corrected DOMAIN alone, or nil.
-
.for(email) ⇒ Object
The full corrected address, or nil when we have nothing useful to say.
-
.providers ⇒ Object
Every domain we would suggest, as a frozen Set: the bundled list plus anything the host added.
- .reload! ⇒ Object
Class Method Details
.correct_domain(domain) ⇒ Object
The corrected DOMAIN alone, or nil. Split out so a host can offer "did you mean …?" next to a domain field, not just an email field.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/nondisposable/suggestion.rb', line 60 def correct_domain(domain) domain = domain.to_s.strip.downcase.delete_suffix('.') return nil if domain.empty? # A real provider is never a typo of another real provider. return nil if providers.include?(domain) threshold = Nondisposable.configuration.lookalike_distance.to_i return nil if threshold < 1 best = nil best_distance = threshold + 1 providers.each do |candidate| # Cheap rejection before the O(n*m) walk: an edit changes the length # by at most 1 per operation, so anything further apart than the # threshold in length alone cannot be within it. next if (candidate.length - domain.length).abs > threshold distance = osa_distance(domain, candidate, best_distance) next if distance >= best_distance best = candidate best_distance = distance break if distance == 1 # nothing can beat one edit; stop early end best end |
.for(email) ⇒ Object
The full corrected address, or nil when we have nothing useful to say.
47 48 49 50 51 52 53 54 55 56 |
# File 'lib/nondisposable/suggestion.rb', line 47 def for(email) email = email.to_s.strip local, _, domain = email.rpartition('@') return nil if local.empty? || domain.empty? corrected = correct_domain(domain.downcase) return nil if corrected.nil? "#{local}@#{corrected}" end |
.providers ⇒ Object
Every domain we would suggest, as a frozen Set: the bundled list plus anything the host added. Memoised per configuration object so a host changing config in a test or console is picked up.
92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/nondisposable/suggestion.rb', line 92 def providers config = Nondisposable.configuration extra = Array(config.additional_email_providers).map { |d| d.to_s.strip.downcase } cache_key = extra.hash if @cache_key != cache_key || @providers.nil? @providers = (bundled_providers + extra).to_set.freeze @cache_key = cache_key end @providers end |
.reload! ⇒ Object
105 106 107 108 109 110 |
# File 'lib/nondisposable/suggestion.rb', line 105 def reload! @providers = nil @bundled_providers = nil @cache_key = nil self end |