nondisposable 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +185 -6
  4. data/app/models/nondisposable/disposable_domain.rb +53 -34
  5. data/app/models/nondisposable/source_snapshot.rb +66 -0
  6. data/data/known_typo_domains.txt +99 -0
  7. data/data/never_disposable.txt +480 -0
  8. data/data/privacy_relays.txt +42 -0
  9. data/data/public_suffix_list.dat +16571 -0
  10. data/gemfiles/rails_7.2.gemfile +1 -0
  11. data/gemfiles/rails_8.0.gemfile +1 -0
  12. data/gemfiles/rails_8.1.gemfile +1 -0
  13. data/lib/generators/nondisposable/install_generator.rb +1 -0
  14. data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
  15. data/lib/generators/nondisposable/templates/nondisposable.rb +29 -7
  16. data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
  17. data/lib/nondisposable/confirmable_email.rb +75 -0
  18. data/lib/nondisposable/domain.rb +238 -0
  19. data/lib/nondisposable/domain_list_updater.rb +510 -88
  20. data/lib/nondisposable/email_validator.rb +51 -4
  21. data/lib/nondisposable/known_typos.rb +64 -0
  22. data/lib/nondisposable/precedence.rb +209 -0
  23. data/lib/nondisposable/public_suffix.rb +147 -0
  24. data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
  25. data/lib/nondisposable/source.rb +233 -0
  26. data/lib/nondisposable/source_fetcher.rb +69 -0
  27. data/lib/nondisposable/source_merge.rb +127 -0
  28. data/lib/nondisposable/suggestion.rb +201 -31
  29. data/lib/nondisposable/tld.rb +8 -3
  30. data/lib/nondisposable/version.rb +1 -1
  31. data/lib/nondisposable.rb +68 -13
  32. data/lib/tasks/nondisposable.rake +29 -0
  33. metadata +18 -2
@@ -22,34 +22,85 @@ module Nondisposable
22
22
  # mistake. Under OSA it scores 1, alongside `gmai.com`, `gmail.co` and
23
23
  # `gmail.con`.
24
24
  #
25
+ # WHAT ONE EDIT CANNOT TELL APART, AND THE TWO FILTERS THAT FOLLOW
26
+ #
27
+ # Replaying "one edit from a provider" against the Tranco top 1M (2026-10-17,
28
+ # docs/evidence/) flagged 707 domains, and a lot of them are large real
29
+ # organisations: ge.com, mi.com and se.com are one edit from me.com; ea.com and
30
+ # aa.com from ya.com; bol.com and uol.com from aol.com; grail.com from
31
+ # gmail.com. Distance alone cannot see the difference, so two filters decide
32
+ # what is worth suggesting at all:
33
+ #
34
+ # 1. SHORT NAMES ONLY TAKE EDITS AFTER THE NAME. When a provider's name is
35
+ # four characters or fewer (me, ya, qq, aol, msn, mail, live, zoho…),
36
+ # one edit inside the name lands on somebody else's real domain far more
37
+ # often than on a typo. `me.cmo` and `live.cm` are still suggested;
38
+ # `ge.com`, `mil.ru` and `love.com` are not.
39
+ # 2. EVERY EDIT MUST LOOK LIKE A FINGER. A swap (`gmial`), a dropped letter
40
+ # (`gmal`, `gmail.co`), a doubled letter (`gmmail`), an extra vowel
41
+ # (`gamail`) or a neighbouring key on QWERTY (`gnail`, `gmail.con`) is
42
+ # how people mistype. A letter from across the keyboard (`grail`,
43
+ # `pmail`, `hive`) is how people spell a different word, and the same
44
+ # holds after the name: `.au` is not a slip for `.ar`, nor `.dev` for
45
+ # `.de`. Dropping the FIRST letter is not counted either — `cloud.com`
46
+ # and `aver.com` are what that spells.
47
+ #
48
+ # Measured on the 70% tuning split of test/fixtures/lookalike_corpus.csv
49
+ # (precision over typo+real rows): distance alone 0.07, filter 1 alone 0.27,
50
+ # both 0.55, at 79% recall of reviewed typos. The corpus is the Tranco top 1M,
51
+ # so it is crowded with real popular domains — a signup form sees far more
52
+ # typos per lookalike than this does. The other 30% is a RETROSPECTIVE
53
+ # holdout, measured by test/nondisposable/lookalike_corpus_test.rb: the hash
54
+ # split was chosen before tuning but only committed with the tuned filters,
55
+ # so it is not a pre-registered holdout, and one early draft was seen
56
+ # against it.
57
+ #
58
+ # These filters only decide what to SUGGEST. They are a likelihood, not proof,
59
+ # and nothing in this file ever justifies refusing an address on its own —
60
+ # see Nondisposable::KnownTypos for the only domains the gem refuses outright,
61
+ # and ActiveModel::Validations::NondisposableValidator for how a suggestion
62
+ # becomes a confirmable error rather than a wall.
63
+ #
25
64
  # WHY THE DEFAULT THRESHOLD IS 1, AND WHY THIS IS OFF BY DEFAULT
26
65
  #
27
- # Every suggestion is a guess about intent, and a wrong guess wired to
28
- # `reject_lookalike_domains` stops a real person from signing up with their
29
- # real address. One edit is the distance at which a guess is safe enough to
30
- # act on; at two, legitimately distinct domains start colliding. The exact
31
- # match check runs first and always wins, which is why the provider list has
32
- # to be generous — see the header of data/email_providers.txt.
66
+ # One edit is the distance at which a guess is worth showing; at two,
67
+ # legitimately distinct domains start colliding. The exact match check runs
68
+ # first and always wins, which is why the provider list has to be generous —
69
+ # see the header of data/email_providers.txt.
33
70
  #
34
71
  # Suggesting is always available and never blocks:
35
72
  #
36
73
  # Nondisposable.suggestion_for("someone@gmial.com") # => "someone@gmail.com"
37
74
  # Nondisposable.suggestion_for("someone@gmail.com") # => nil
38
- #
39
- # Blocking on it is a separate, deliberate opt-in
40
- # (`config.reject_lookalike_domains`), because "probably a typo" is a weaker
41
- # claim than "this TLD does not exist" and deserves a weaker remedy.
42
75
  module Suggestion
43
76
  LIST_PATH = File.expand_path('../../data/email_providers.txt', __dir__)
44
77
 
78
+ # A provider name this short only takes edits after the name. See filter 1
79
+ # in the header for the evidence.
80
+ SHORT_NAME_LENGTH = 4
81
+
82
+ VOWELS = 'aeiou'
83
+
84
+ # Physical neighbours on a QWERTY keyboard, the layout of Spain, Latin
85
+ # America and the English-speaking world (and the default on phones there).
86
+ # `ñ` sits right of `l` on the Spanish layout. AZERTY and QWERTZ are left out
87
+ # on purpose: unioning them makes every key a neighbour of more keys, and
88
+ # QWERTZ's z/y swap alone would start suggesting zoho.com to zozo.com.
89
+ KEYBOARD_ROWS = %w[1234567890 qwertyuiop asdfghjklñ zxcvbnm].freeze
90
+
45
91
  class << self
46
92
  # The full corrected address, or nil when we have nothing useful to say.
47
93
  def for(email)
48
- email = email.to_s.strip
94
+ email = email.to_s
95
+ # Bytes that are not UTF-8 have no keystrokes to correct.
96
+ email = email.dup.force_encoding(Encoding::UTF_8) unless email.encoding == Encoding::UTF_8
97
+ return nil unless email.valid_encoding?
98
+
99
+ email = email.strip
49
100
  local, _, domain = email.rpartition('@')
50
101
  return nil if local.empty? || domain.empty?
51
102
 
52
- corrected = correct_domain(domain.downcase)
103
+ corrected = correct_domain(domain)
53
104
  return nil if corrected.nil?
54
105
 
55
106
  "#{local}@#{corrected}"
@@ -58,10 +109,32 @@ module Nondisposable
58
109
  # The corrected DOMAIN alone, or nil. Split out so a host can offer
59
110
  # "did you mean …?" next to a domain field, not just an email field.
60
111
  def correct_domain(domain)
61
- domain = domain.to_s.strip.downcase.delete_suffix('.')
62
- return nil if domain.empty?
112
+ typed = Domain.unicode(domain)
113
+ return nil if typed.empty?
63
114
  # A real provider is never a typo of another real provider.
64
- return nil if providers.include?(domain)
115
+ return nil if providers.include?(Domain.normalize(typed))
116
+ # 0 switches the whole layer off, reviewed list included.
117
+ return nil if Nondisposable.configuration.lookalike_distance.to_i < 1
118
+
119
+ # A reviewed typo carries its own correction, and it wins over any
120
+ # guess: the list exists precisely because we checked it.
121
+ KnownTypos.correction_for(typed) || guess(typed)
122
+ end
123
+
124
+ # The ranker alone: distance plus the two filters, without the reviewed
125
+ # list. Public so the corpus test can measure the guess on its own —
126
+ # measuring it with the list in front would mostly measure the list.
127
+ def guess(domain)
128
+ typed = Domain.unicode(domain)
129
+ return nil if typed.empty?
130
+ return nil if providers.include?(Domain.normalize(typed))
131
+
132
+ # Punycode letters are an encoding, not keystrokes: `xn--gmal-xpa.com`
133
+ # is somebody typing `gmaíl.com`, and its distance to gmail.com in
134
+ # ASCII is meaningless. We judge the unicode form when we get it and
135
+ # stay silent on the encoded one.
136
+ return nil if typed.encoding == Encoding::BINARY # not UTF-8: nothing typed to measure
137
+ return nil if typed.split('.').any? { |label| label.start_with?('xn--') }
65
138
 
66
139
  threshold = Nondisposable.configuration.lookalike_distance.to_i
67
140
  return nil if threshold < 1
@@ -69,41 +142,44 @@ module Nondisposable
69
142
  best = nil
70
143
  best_distance = threshold + 1
71
144
 
72
- providers.each do |candidate|
145
+ targets.each do |candidate|
73
146
  # Cheap rejection before the O(n*m) walk: an edit changes the length
74
147
  # by at most 1 per operation, so anything further apart than the
75
148
  # threshold in length alone cannot be within it.
76
- next if (candidate.length - domain.length).abs > threshold
149
+ next if (candidate.length - typed.length).abs > threshold
77
150
 
78
- distance = osa_distance(domain, candidate, best_distance)
151
+ distance = osa_distance(typed, candidate, best_distance)
79
152
  next if distance >= best_distance
153
+ next unless plausible?(typed, candidate, distance)
80
154
 
81
155
  best = candidate
82
156
  best_distance = distance
83
- break if distance == 1 # nothing can beat one edit; stop early
157
+ break if distance == 1 # nothing plausible can beat one edit; stop early
84
158
  end
85
159
 
86
160
  best
87
161
  end
88
162
 
89
- # Every domain we would suggest, as a frozen Set: the bundled list plus
90
- # anything the host added. Memoised per configuration object so a host
91
- # changing config in a test or console is picked up.
163
+ # Every provider, as a frozen Set of canonical (punycode) names: the
164
+ # bundled list plus anything the host added. This is the EXEMPTION set —
165
+ # an exact match here is never called a typo. Memoised per configuration
166
+ # so a host changing config in a test or console is picked up.
92
167
  def providers
93
- config = Nondisposable.configuration
94
- extra = Array(config.additional_email_providers).map { |d| d.to_s.strip.downcase }
95
- cache_key = extra.hash
96
-
97
- if @cache_key != cache_key || @providers.nil?
98
- @providers = (bundled_providers + extra).to_set.freeze
99
- @cache_key = cache_key
100
- end
101
-
168
+ refresh_cache
102
169
  @providers
103
170
  end
104
171
 
172
+ # The same providers as a human would type them (unicode, not punycode):
173
+ # what a keystroke is measured against. `bücher.d` is one edit from
174
+ # `bücher.de` and eleven from `xn--bcher-kva.de`.
175
+ def targets
176
+ refresh_cache
177
+ @targets
178
+ end
179
+
105
180
  def reload!
106
181
  @providers = nil
182
+ @targets = nil
107
183
  @bundled_providers = nil
108
184
  @cache_key = nil
109
185
  self
@@ -111,6 +187,16 @@ module Nondisposable
111
187
 
112
188
  private
113
189
 
190
+ def refresh_cache
191
+ extra = Array(Nondisposable.configuration.additional_email_providers).map { |d| Domain.unicode(d) }.reject(&:empty?)
192
+ cache_key = extra.hash
193
+ return if @cache_key == cache_key && @providers && @targets
194
+
195
+ @targets = (bundled_providers + extra).uniq.freeze
196
+ @providers = @targets.map { |d| Domain.normalize(d) }.to_set.freeze
197
+ @cache_key = cache_key
198
+ end
199
+
114
200
  def bundled_providers
115
201
  @bundled_providers ||= File.readlines(LIST_PATH, chomp: true).filter_map do |line|
116
202
  value = line.strip.downcase
@@ -118,6 +204,90 @@ module Nondisposable
118
204
  end
119
205
  end
120
206
 
207
+ # Does the difference between what was typed and this provider look like
208
+ # a slip of the finger? The two filters from the header, in code.
209
+ def plausible?(typed, candidate, distance)
210
+ name = candidate.split('.').first
211
+ # The dot itself missed or swapped for a letter (`gmailmcom`,
212
+ # `gmailcom`): nobody owns a domain shaped like that, and the TLD path
213
+ # refuses it anyway, so it is always worth naming the correction.
214
+ return true if typed.count('.') < candidate.count('.') && typed.start_with?(name)
215
+
216
+ inside_name = typed.split('.').first != name
217
+ return false if inside_name && name.length <= SHORT_NAME_LENGTH
218
+ # The edit model below describes ONE edit. A host that widened
219
+ # lookalike_distance has opted into coarser guesses; filter 1 still
220
+ # applies, filter 2 has nothing to say.
221
+ return true if distance > 1
222
+
223
+ finger_slip?(typed, candidate)
224
+ end
225
+
226
+ # One edit, classified — in the name or after it, the same fingers type
227
+ # both (`gmail.cpm` is as plausible as `gmsil.com`, `yahoo.com.au` as
228
+ # implausible as `grail.com`). Callers have already established the
229
+ # distance is exactly 1, so exactly one of these shapes applies.
230
+ def finger_slip?(typed, candidate)
231
+ i = 0
232
+ i += 1 while i < typed.length && i < candidate.length && typed[i] == candidate[i]
233
+
234
+ case typed.length - candidate.length
235
+ when -1
236
+ # A dropped letter (`gmal`, `hotmal`, `gmail.co`) — but not the
237
+ # first one: `cloud.com`, `aver.com` and `mail.ch` are what
238
+ # dropping the first letter of icloud, naver and gmail.ch spells,
239
+ # and every one of them is somebody's real domain.
240
+ i.positive?
241
+ when 1
242
+ # An extra letter is a slip when it repeats, or sits next to, a
243
+ # letter it was typed beside (`gmmail`, `gmaiul` — not `mlive`), or
244
+ # when it is a vowel: spelling by ear adds vowels (`gamail`,
245
+ # `gimail`) far more often than consonants.
246
+ extra = typed[i]
247
+ return true if VOWELS.include?(extra)
248
+
249
+ beside = [i.positive? ? typed[i - 1] : nil, typed[i + 1]].compact
250
+ beside.any? { |near| near == extra || neighbours?(near, extra) }
251
+ else
252
+ # A swap (`gmial`) is the one mistake OSA counts as one edit…
253
+ return true if typed[i + 1] == candidate[i] && typed[i] == candidate[i + 1]
254
+
255
+ # …otherwise it is a substitution, which has to be a neighbouring
256
+ # key — or the same key with an accent (`gmaíl`: on a Spanish
257
+ # keyboard í is i after a dead key, not a different letter).
258
+ same_key?(typed[i], candidate[i]) || neighbours?(typed[i], candidate[i])
259
+ end
260
+ end
261
+
262
+ def same_key?(a, b)
263
+ a.unicode_normalize(:nfd)[0] == b.unicode_normalize(:nfd)[0]
264
+ end
265
+
266
+ def neighbours?(a, b)
267
+ keyboard.fetch(a, nil)&.include?(b) || false
268
+ end
269
+
270
+ # key => the keys around it, on a staggered QWERTY: the row's own left and
271
+ # right, and the two keys above and below that it physically touches.
272
+ def keyboard
273
+ @keyboard ||= begin
274
+ map = Hash.new { |h, k| h[k] = Set.new }
275
+ KEYBOARD_ROWS.each_with_index do |row, r|
276
+ row.each_char.with_index do |key, c|
277
+ touching = [[r, c - 1], [r, c + 1], [r - 1, c], [r - 1, c + 1], [r + 1, c - 1], [r + 1, c]]
278
+ touching.each do |rr, cc|
279
+ next if rr.negative? || cc.negative? || rr >= KEYBOARD_ROWS.size
280
+
281
+ other = KEYBOARD_ROWS[rr][cc]
282
+ map[key] << other if other
283
+ end
284
+ end
285
+ end
286
+ map.default_proc = nil
287
+ map.freeze
288
+ end
289
+ end
290
+
121
291
  # Optimal string alignment distance, with an early bail-out.
122
292
  #
123
293
  # Two rows instead of a full matrix (we only ever need the previous two),
@@ -156,10 +156,15 @@ module Nondisposable
156
156
 
157
157
  private
158
158
 
159
- # The domain half, trimmed, downcased, and without the trailing dot a
160
- # fully-qualified name may carry (`gmail.com.` is the same host).
159
+ # The domain half in Nondisposable::Domain's canonical form, minus the
160
+ # punycode step: trimmed, downcased, no trailing root dot, fullwidth and
161
+ # ideographic dots read as dots (`foo.tk` ends in `tk`, exactly like
162
+ # `foo.tk`). Unicode TLDs stay unicode, so #valid? still declines to
163
+ # judge them.
161
164
  def normalize(email_or_domain)
162
- email_or_domain.to_s.split('@').last.to_s.strip.downcase.delete_suffix('.')
165
+ value = email_or_domain.to_s
166
+ value = value.b unless value.valid_encoding? # unreadable bytes must not raise
167
+ Nondisposable::Domain.unicode(value.split('@').last.to_s)
163
168
  end
164
169
 
165
170
  # Skips IANA's comment header and any blank lines.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nondisposable
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/nondisposable.rb CHANGED
@@ -2,11 +2,20 @@
2
2
 
3
3
  require_relative "nondisposable/version"
4
4
  require_relative "nondisposable/engine"
5
+ require_relative "nondisposable/domain"
6
+ require_relative "nondisposable/public_suffix"
7
+ require_relative "nondisposable/precedence"
8
+ require_relative "nondisposable/source"
9
+ require_relative "nondisposable/source_merge"
10
+ require_relative "nondisposable/source_fetcher"
5
11
  require_relative "nondisposable/tld"
12
+ require_relative "nondisposable/known_typos"
6
13
  require_relative "nondisposable/suggestion"
14
+ require_relative "nondisposable/confirmable_email"
7
15
  require_relative "nondisposable/email_validator"
8
16
  require_relative "nondisposable/domain_list_updater"
9
17
  require_relative "nondisposable/tld_list_updater"
18
+ require_relative "nondisposable/public_suffix_list_updater"
10
19
 
11
20
  module Nondisposable
12
21
  class Error < StandardError; end
@@ -27,10 +36,12 @@ module Nondisposable
27
36
  end
28
37
 
29
38
  def self.disposable?(email)
30
- return false if email.nil? || !email.include?('@')
31
- domain = email.to_s.split('@').last
32
- return false if domain.nil? || domain.empty?
33
- DisposableDomain.disposable?(domain.downcase)
39
+ return false if email.nil? || !email.to_s.include?('@')
40
+ # Unicode form: DisposableDomain canonicalises it itself, and needs the
41
+ # unencoded spelling to match rows stored before 0.5.0.
42
+ domain = Domain.from_email(email, unicode: true)
43
+ return false if domain.empty?
44
+ DisposableDomain.disposable?(domain)
34
45
  end
35
46
 
36
47
  # Does this address end in a TLD that actually exists?
@@ -58,14 +69,35 @@ module Nondisposable
58
69
 
59
70
  class Configuration
60
71
  ON_CHECK_FAILURE_MODES = [:allow, :reject].freeze
72
+ LOOKALIKE_MODES = [false, true, :confirmable].freeze
61
73
 
62
74
  attr_accessor :error_message, :additional_domains, :excluded_domains
63
75
  # Whether to also match parent domains: with the default true, an email at
64
- # x.tempmail.com is blocked when tempmail.com is on the list (checks up to
65
- # DisposableDomain::PARENT_MATCH_DEPTH parent labels, never a bare TLD).
76
+ # x.tempmail.com is blocked when tempmail.com is on the list (checks
77
+ # every parent down to the registrable domain, never a public suffix).
66
78
  attr_accessor :check_parent_domains
67
79
  attr_reader :on_check_failure
68
80
 
81
+ # ---- Blocklist sources (Nondisposable::Source) --------------------------
82
+
83
+ # Where the list of disposable domains comes from: an ordered list of
84
+ # Nondisposable::Source objects (or Hashes of their attributes). The
85
+ # default is the single list this gem has always used, so upgrading changes
86
+ # nothing. Nondisposable::Sources::RECOMMENDED adds a second, independent
87
+ # primary. Needs the nondisposable_source_snapshots table
88
+ # (`rails g nondisposable:upgrade`); without it only the default is used.
89
+ attr_accessor :sources
90
+
91
+ # How many independent ORIGINS must list a domain before a :corroborating
92
+ # source's entry is blocked. Copies don't count: see Nondisposable::Source.
93
+ attr_accessor :corroboration_threshold
94
+
95
+ # Privacy relays (Apple Hide My Email, DuckDuckGo, Firefox Relay,
96
+ # SimpleLogin, addy.io — data/privacy_relays.txt) forward to a real,
97
+ # long-lived inbox. false (the default) ALLOWS them even when an upstream
98
+ # list carries them; true REFUSES them even when none does.
99
+ attr_accessor :block_privacy_relays
100
+
69
101
  # ---- TLD validity (Nondisposable::Tld) ----------------------------------
70
102
 
71
103
  # OFF by default, and it stays off: upgrading a gem must never silently
@@ -93,15 +125,21 @@ module Nondisposable
93
125
 
94
126
  # ---- Lookalike domains (Nondisposable::Suggestion) ----------------------
95
127
 
96
- # Reject addresses one edit away from a well-known provider — `gmail.co`,
128
+ # Flag addresses one edit away from a well-known provider — `gmail.co`,
97
129
  # `gmial.com`, `hotmial.com` — with a message naming the correction.
98
130
  #
99
- # OFF by default and worth leaving off unless you have thought about it: a
100
- # suggestion is a guess about intent, and a wrong guess here stops a real
101
- # person signing up with their real address. `Nondisposable.suggestion_for`
102
- # is always available and blocks nothing, which is the gentler way to use
103
- # this: show the hint, let the human decide.
104
- attr_accessor :reject_lookalike_domains
131
+ # false (default) never flag; Nondisposable.suggestion_for still works
132
+ # :confirmable flag them. Reviewed typos (data/known_typo_domains.txt) are
133
+ # refused outright; every other lookalike is a guess, and the
134
+ # user can confirm the address as typed and pass — see
135
+ # Nondisposable::ConfirmableEmail.
136
+ # true the same as :confirmable. Before 0.5.0 it refused every
137
+ # lookalike, including real organisations' domains (ge.com,
138
+ # bol.com, one.com…) one edit from a provider.
139
+ #
140
+ # Worth leaving off unless your form can show the confirmation: without it a
141
+ # user at a domain that merely looks like a typo has no way through.
142
+ attr_reader :reject_lookalike_domains
105
143
 
106
144
  # How many edits still count as "a typo". 1 (the default) is the distance at
107
145
  # which acting on a guess is safe; at 2, genuinely different domains start
@@ -126,6 +164,10 @@ module Nondisposable
126
164
  @on_check_failure = :allow
127
165
  @check_parent_domains = true
128
166
 
167
+ @sources = Nondisposable::Sources::DEFAULT
168
+ @corroboration_threshold = 2
169
+ @block_privacy_relays = false
170
+
129
171
  @check_tld = false
130
172
  @additional_tlds = []
131
173
  @blocked_tlds = []
@@ -152,5 +194,18 @@ module Nondisposable
152
194
 
153
195
  @on_check_failure = mode
154
196
  end
197
+
198
+ # Strings are accepted for config loaded from YAML/ENV ("confirmable",
199
+ # "true"); anything else raises, so a typo in an initializer fails at boot
200
+ # instead of silently meaning "on".
201
+ def reject_lookalike_domains=(mode)
202
+ mode = false if mode.nil?
203
+ mode = { 'true' => true, 'false' => false }.fetch(mode, mode.to_sym) if mode.is_a?(String)
204
+ unless LOOKALIKE_MODES.include?(mode)
205
+ raise ArgumentError, "reject_lookalike_domains must be one of #{LOOKALIKE_MODES.map(&:inspect).join(', ')} (got #{mode.inspect})"
206
+ end
207
+
208
+ @reject_lookalike_domains = mode
209
+ end
155
210
  end
156
211
  end
@@ -35,3 +35,32 @@ namespace :nondisposable do
35
35
  end
36
36
  end
37
37
  end
38
+
39
+ namespace :nondisposable do
40
+ namespace :psl do
41
+ desc "Refresh the vendored Public Suffix List snapshot (maintenance task — run in a gem checkout before a release)"
42
+ task :update do
43
+ # Same reasoning as tlds:update: no Rails here. The parser normalizes
44
+ # rules through Domain, so it comes along.
45
+ require_relative "../nondisposable/domain"
46
+ require_relative "../nondisposable/public_suffix"
47
+ require_relative "../nondisposable/public_suffix_list_updater"
48
+
49
+ before = begin
50
+ Nondisposable::PublicSuffix.rule_count
51
+ rescue StandardError
52
+ 0
53
+ end
54
+
55
+ count = Nondisposable::PublicSuffixListUpdater.update
56
+
57
+ if count.nil?
58
+ abort "[nondisposable] Public Suffix List unchanged. See the error above."
59
+ else
60
+ puts "[nondisposable] PSL snapshot now holds #{count} rules (was #{before})."
61
+ puts "[nondisposable] PSL version: #{Nondisposable::PublicSuffix.version}"
62
+ puts "[nondisposable] Commit data/public_suffix_list.dat if it changed."
63
+ end
64
+ end
65
+ end
66
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nondisposable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-27 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -41,21 +41,37 @@ files:
41
41
  - README.md
42
42
  - Rakefile
43
43
  - app/models/nondisposable/disposable_domain.rb
44
+ - app/models/nondisposable/source_snapshot.rb
44
45
  - context7.json
45
46
  - data/disposable_email_blocklist.conf
46
47
  - data/email_providers.txt
47
48
  - data/iana_tlds.txt
49
+ - data/known_typo_domains.txt
50
+ - data/never_disposable.txt
51
+ - data/privacy_relays.txt
52
+ - data/public_suffix_list.dat
48
53
  - gemfiles/rails_7.2.gemfile
49
54
  - gemfiles/rails_8.0.gemfile
50
55
  - gemfiles/rails_8.1.gemfile
51
56
  - lib/generators/nondisposable/install_generator.rb
52
57
  - lib/generators/nondisposable/templates/create_nondisposable_disposable_domains.rb.erb
58
+ - lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb
53
59
  - lib/generators/nondisposable/templates/disposable_email_domain_list_update_job.rb
54
60
  - lib/generators/nondisposable/templates/nondisposable.rb
61
+ - lib/generators/nondisposable/upgrade_generator.rb
55
62
  - lib/nondisposable.rb
63
+ - lib/nondisposable/confirmable_email.rb
64
+ - lib/nondisposable/domain.rb
56
65
  - lib/nondisposable/domain_list_updater.rb
57
66
  - lib/nondisposable/email_validator.rb
58
67
  - lib/nondisposable/engine.rb
68
+ - lib/nondisposable/known_typos.rb
69
+ - lib/nondisposable/precedence.rb
70
+ - lib/nondisposable/public_suffix.rb
71
+ - lib/nondisposable/public_suffix_list_updater.rb
72
+ - lib/nondisposable/source.rb
73
+ - lib/nondisposable/source_fetcher.rb
74
+ - lib/nondisposable/source_merge.rb
59
75
  - lib/nondisposable/suggestion.rb
60
76
  - lib/nondisposable/tld.rb
61
77
  - lib/nondisposable/tld_list_updater.rb