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.expand_path('../../data/email_providers.txt', __dir__)

Class Method Summary collapse

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

.providersObject

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