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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +185 -6
- data/app/models/nondisposable/disposable_domain.rb +53 -34
- data/app/models/nondisposable/source_snapshot.rb +66 -0
- data/data/known_typo_domains.txt +99 -0
- data/data/never_disposable.txt +480 -0
- data/data/privacy_relays.txt +42 -0
- data/data/public_suffix_list.dat +16571 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/generators/nondisposable/install_generator.rb +1 -0
- data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
- data/lib/generators/nondisposable/templates/nondisposable.rb +29 -7
- data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
- data/lib/nondisposable/confirmable_email.rb +75 -0
- data/lib/nondisposable/domain.rb +238 -0
- data/lib/nondisposable/domain_list_updater.rb +510 -88
- data/lib/nondisposable/email_validator.rb +51 -4
- data/lib/nondisposable/known_typos.rb +64 -0
- data/lib/nondisposable/precedence.rb +209 -0
- data/lib/nondisposable/public_suffix.rb +147 -0
- data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
- data/lib/nondisposable/source.rb +233 -0
- data/lib/nondisposable/source_fetcher.rb +69 -0
- data/lib/nondisposable/source_merge.rb +127 -0
- data/lib/nondisposable/suggestion.rb +201 -31
- data/lib/nondisposable/tld.rb +8 -3
- data/lib/nondisposable/version.rb +1 -1
- data/lib/nondisposable.rb +68 -13
- data/lib/tasks/nondisposable.rake +29 -0
- 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
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
return nil if
|
|
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?(
|
|
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
|
-
|
|
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 -
|
|
149
|
+
next if (candidate.length - typed.length).abs > threshold
|
|
77
150
|
|
|
78
|
-
distance = osa_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
|
|
90
|
-
# anything the host added.
|
|
91
|
-
#
|
|
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
|
-
|
|
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),
|
data/lib/nondisposable/tld.rb
CHANGED
|
@@ -156,10 +156,15 @@ module Nondisposable
|
|
|
156
156
|
|
|
157
157
|
private
|
|
158
158
|
|
|
159
|
-
# The domain half
|
|
160
|
-
#
|
|
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
|
|
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.
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
65
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
|
|
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
|
+
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-
|
|
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
|