nondisposable 0.3.0 → 0.4.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 +20 -0
- data/README.md +123 -0
- data/Rakefile +5 -0
- data/data/email_providers.txt +276 -0
- data/data/iana_tlds.txt +1439 -0
- data/lib/generators/nondisposable/templates/nondisposable.rb +44 -0
- data/lib/nondisposable/email_validator.rb +52 -1
- data/lib/nondisposable/suggestion.rb +166 -0
- data/lib/nondisposable/tld.rb +185 -0
- data/lib/nondisposable/tld_list_updater.rb +101 -0
- data/lib/nondisposable/version.rb +1 -1
- data/lib/nondisposable.rb +92 -0
- data/lib/tasks/nondisposable.rake +37 -0
- metadata +8 -2
|
@@ -16,4 +16,48 @@ Nondisposable.configure do |config|
|
|
|
16
16
|
# Also match parent domains: an email at x.tempmail.com is blocked when
|
|
17
17
|
# tempmail.com is on the list (checks up to 3 parent labels). Default: true
|
|
18
18
|
# config.check_parent_domains = true
|
|
19
|
+
|
|
20
|
+
# ---- Catching typos ----
|
|
21
|
+
#
|
|
22
|
+
# A disposable address is someone hiding from you. A typo is someone who
|
|
23
|
+
# wanted to reach you and won't be able to: user@gmail.con is a real person
|
|
24
|
+
# whose account nobody can ever reach, because .con does not exist.
|
|
25
|
+
# Both checks below are OFF by default.
|
|
26
|
+
|
|
27
|
+
# Reject addresses whose TLD isn't in the IANA root zone (gmail.con,
|
|
28
|
+
# outlook.ed, and domains with no dot at all like example@gmailmcom).
|
|
29
|
+
# config.check_tld = true
|
|
30
|
+
#
|
|
31
|
+
# Accept a TLD delegated after your installed gem version shipped — so a
|
|
32
|
+
# stale snapshot can never leave a real customer stuck.
|
|
33
|
+
# config.additional_tlds = ['newtld']
|
|
34
|
+
#
|
|
35
|
+
# Refuse TLDs that are real but unwelcome (the historically free Freenom set):
|
|
36
|
+
# config.blocked_tlds = %w[tk ml ga cf gq]
|
|
37
|
+
#
|
|
38
|
+
# Or invert it into an allowlist. Careful: %w[es] turns away every .com
|
|
39
|
+
# customer you have.
|
|
40
|
+
# config.allowed_tlds = nil
|
|
41
|
+
|
|
42
|
+
# Reject addresses one keystroke from a well-known provider (gmail.co,
|
|
43
|
+
# gmial.com). A TLD check CANNOT catch these — .co, .cm and .om are Colombia,
|
|
44
|
+
# Cameroon and Oman, all real.
|
|
45
|
+
#
|
|
46
|
+
# Think before switching this on: a suggestion is a guess about intent, and a
|
|
47
|
+
# wrong guess stops a real person signing up with their real address.
|
|
48
|
+
# Nondisposable.suggestion_for(email) is always available and blocks nothing,
|
|
49
|
+
# which is the gentler way to use this — show the hint, let the human decide.
|
|
50
|
+
# config.reject_lookalike_domains = true
|
|
51
|
+
#
|
|
52
|
+
# How many edits still count as a typo. 1 is safe; 2 starts colliding with
|
|
53
|
+
# genuinely different domains. 0 disables suggestions entirely.
|
|
54
|
+
# config.lookalike_distance = 1
|
|
55
|
+
#
|
|
56
|
+
# Providers we missed, or your own domain. Adding one both makes it a
|
|
57
|
+
# suggestion candidate AND stops its users being told they made a typo.
|
|
58
|
+
# config.additional_email_providers = ['yourcompany.com']
|
|
59
|
+
|
|
60
|
+
# config.invalid_tld_error_message = "doesn't look like a real email address"
|
|
61
|
+
# config.blocked_tld_error_message = "domain ending is not allowed"
|
|
62
|
+
# config.lookalike_error_message = "looks like a typo. Did you mean %{suggestion}?"
|
|
19
63
|
end
|
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
module ActiveModel
|
|
4
4
|
module Validations
|
|
5
5
|
class NondisposableValidator < EachValidator
|
|
6
|
+
# Three independent questions about one address, in the order a human
|
|
7
|
+
# would ask them, and AT MOST ONE error however many of them fire —
|
|
8
|
+
# stacking "provider is not allowed" on top of "did you mean gmail.com?"
|
|
9
|
+
# helps nobody:
|
|
10
|
+
#
|
|
11
|
+
# 1. Is this a throwaway provider? (always on — the gem's whole job)
|
|
12
|
+
# 2. Can we name the address they meant? (config.reject_lookalike_domains,
|
|
13
|
+
# or any invalid TLD we can correct — see below)
|
|
14
|
+
# 3. Is the TLD real, and allowed? (config.check_tld)
|
|
15
|
+
#
|
|
16
|
+
# WHY A NAMEABLE CORRECTION OUTRANKS THE GENERIC TLD ERROR
|
|
17
|
+
#
|
|
18
|
+
# `user@gmail.con` fails both 2 and 3. "Doesn't look like a real email
|
|
19
|
+
# address" is true but useless; "Did you mean user@gmail.com?" is the same
|
|
20
|
+
# verdict with the fix attached. So whenever we can name a correction we
|
|
21
|
+
# use that message, whether the TLD was invalid or merely lookalike. The
|
|
22
|
+
# rule is: a suggestion, if we have one, always phrases the rejection.
|
|
6
23
|
def validate_each(record, attribute, value)
|
|
7
24
|
return if value.blank?
|
|
8
25
|
|
|
@@ -10,9 +27,36 @@ module ActiveModel
|
|
|
10
27
|
domain = value.to_s.split('@').last&.downcase
|
|
11
28
|
return if domain.nil? # Invalid email format
|
|
12
29
|
|
|
30
|
+
config = Nondisposable.configuration
|
|
31
|
+
|
|
13
32
|
if Nondisposable::DisposableDomain.disposable?(domain)
|
|
14
|
-
record
|
|
33
|
+
return reject(record, attribute, options[:message] || config.error_message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
return unless config.check_tld || config.reject_lookalike_domains
|
|
37
|
+
# No "@" at all is a malformed value, and this validator has never
|
|
38
|
+
# judged shape — that is what `format:` is for (see the README). Note
|
|
39
|
+
# `example@gmailmcom` DOES have one: a domain missing its dot is a real
|
|
40
|
+
# address at an impossible domain, which is very much our business.
|
|
41
|
+
return unless value.to_s.include?('@')
|
|
42
|
+
return unless Nondisposable::Tld.judgeable?(value)
|
|
43
|
+
|
|
44
|
+
# nil here means the domain has NO TLD (`example@gmailmcom` — a
|
|
45
|
+
# production-shaped typo that missed the dot), which fails "must end in a real TLD"
|
|
46
|
+
# just as surely as `.con` does.
|
|
47
|
+
tld = Nondisposable::Tld.extract(value)
|
|
48
|
+
invalid = config.check_tld && (tld.nil? || !Nondisposable::Tld.valid?(tld))
|
|
49
|
+
blocked = config.check_tld && !invalid && Nondisposable::Tld.blocked?(tld)
|
|
50
|
+
|
|
51
|
+
if invalid || config.reject_lookalike_domains
|
|
52
|
+
suggestion = Nondisposable::Suggestion.for(value)
|
|
53
|
+
if suggestion
|
|
54
|
+
return reject(record, attribute, config.lookalike_error_message.sub('%{suggestion}', suggestion))
|
|
55
|
+
end
|
|
15
56
|
end
|
|
57
|
+
|
|
58
|
+
return reject(record, attribute, config.invalid_tld_error_message) if invalid
|
|
59
|
+
return reject(record, attribute, config.blocked_tld_error_message) if blocked
|
|
16
60
|
rescue StandardError => e
|
|
17
61
|
if Nondisposable.configuration.on_check_failure == :reject
|
|
18
62
|
Rails.logger.error "[nondisposable] Nondisposable validation error: #{e.message} — rejecting #{attribute} (on_check_failure = :reject)"
|
|
@@ -22,6 +66,13 @@ module ActiveModel
|
|
|
22
66
|
end
|
|
23
67
|
end
|
|
24
68
|
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def reject(record, attribute, message)
|
|
73
|
+
record.errors.add(attribute, message)
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
25
76
|
end
|
|
26
77
|
|
|
27
78
|
module HelperMethods
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nondisposable
|
|
4
|
+
# "Did you mean gmail.com?"
|
|
5
|
+
#
|
|
6
|
+
# A TLD check catches the typos that produce a domain ending which cannot
|
|
7
|
+
# exist — `.con`, `.cpm`, `.ocm`. It structurally cannot catch the far more
|
|
8
|
+
# common ones, because `.co` (Colombia), `.cm` (Cameroon) and `.om` (Oman) are
|
|
9
|
+
# all real, delegated TLDs, as are `.se` and `.es`. `user@gmail.co` is a
|
|
10
|
+
# perfectly well-formed address at a perfectly real TLD, and it is still
|
|
11
|
+
# almost always a finger that slipped off the `m`.
|
|
12
|
+
#
|
|
13
|
+
# So this layer asks a different question: is this domain one keystroke away
|
|
14
|
+
# from a well-known email provider, while not being one itself?
|
|
15
|
+
#
|
|
16
|
+
# HOW IT DECIDES
|
|
17
|
+
#
|
|
18
|
+
# Optimal string alignment distance (Damerau-Levenshtein restricted to
|
|
19
|
+
# adjacent transpositions) against data/email_providers.txt. Transposition
|
|
20
|
+
# matters more than it looks: `gmial.com` is the single most common Gmail
|
|
21
|
+
# misspelling, and plain Levenshtein scores it 2 while a human sees one
|
|
22
|
+
# mistake. Under OSA it scores 1, alongside `gmai.com`, `gmail.co` and
|
|
23
|
+
# `gmail.con`.
|
|
24
|
+
#
|
|
25
|
+
# WHY THE DEFAULT THRESHOLD IS 1, AND WHY THIS IS OFF BY DEFAULT
|
|
26
|
+
#
|
|
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.
|
|
33
|
+
#
|
|
34
|
+
# Suggesting is always available and never blocks:
|
|
35
|
+
#
|
|
36
|
+
# Nondisposable.suggestion_for("someone@gmial.com") # => "someone@gmail.com"
|
|
37
|
+
# 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
|
+
module Suggestion
|
|
43
|
+
LIST_PATH = File.expand_path('../../data/email_providers.txt', __dir__)
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# The full corrected address, or nil when we have nothing useful to say.
|
|
47
|
+
def for(email)
|
|
48
|
+
email = email.to_s.strip
|
|
49
|
+
local, _, domain = email.rpartition('@')
|
|
50
|
+
return nil if local.empty? || domain.empty?
|
|
51
|
+
|
|
52
|
+
corrected = correct_domain(domain.downcase)
|
|
53
|
+
return nil if corrected.nil?
|
|
54
|
+
|
|
55
|
+
"#{local}@#{corrected}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The corrected DOMAIN alone, or nil. Split out so a host can offer
|
|
59
|
+
# "did you mean …?" next to a domain field, not just an email field.
|
|
60
|
+
def correct_domain(domain)
|
|
61
|
+
domain = domain.to_s.strip.downcase.delete_suffix('.')
|
|
62
|
+
return nil if domain.empty?
|
|
63
|
+
# A real provider is never a typo of another real provider.
|
|
64
|
+
return nil if providers.include?(domain)
|
|
65
|
+
|
|
66
|
+
threshold = Nondisposable.configuration.lookalike_distance.to_i
|
|
67
|
+
return nil if threshold < 1
|
|
68
|
+
|
|
69
|
+
best = nil
|
|
70
|
+
best_distance = threshold + 1
|
|
71
|
+
|
|
72
|
+
providers.each do |candidate|
|
|
73
|
+
# Cheap rejection before the O(n*m) walk: an edit changes the length
|
|
74
|
+
# by at most 1 per operation, so anything further apart than the
|
|
75
|
+
# threshold in length alone cannot be within it.
|
|
76
|
+
next if (candidate.length - domain.length).abs > threshold
|
|
77
|
+
|
|
78
|
+
distance = osa_distance(domain, candidate, best_distance)
|
|
79
|
+
next if distance >= best_distance
|
|
80
|
+
|
|
81
|
+
best = candidate
|
|
82
|
+
best_distance = distance
|
|
83
|
+
break if distance == 1 # nothing can beat one edit; stop early
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
best
|
|
87
|
+
end
|
|
88
|
+
|
|
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.
|
|
92
|
+
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
|
+
|
|
102
|
+
@providers
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def reload!
|
|
106
|
+
@providers = nil
|
|
107
|
+
@bundled_providers = nil
|
|
108
|
+
@cache_key = nil
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def bundled_providers
|
|
115
|
+
@bundled_providers ||= File.readlines(LIST_PATH, chomp: true).filter_map do |line|
|
|
116
|
+
value = line.strip.downcase
|
|
117
|
+
value unless value.empty? || value.start_with?('#')
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Optimal string alignment distance, with an early bail-out.
|
|
122
|
+
#
|
|
123
|
+
# Two rows instead of a full matrix (we only ever need the previous two),
|
|
124
|
+
# and if the best cell in a row already exceeds `cutoff` no later row can
|
|
125
|
+
# come back under it — the distance only grows — so we stop. With ~250
|
|
126
|
+
# candidates per lookup and a threshold of 1, almost every candidate exits
|
|
127
|
+
# on the length check above or within the first row or two.
|
|
128
|
+
# (Empty inputs need no special case: with an empty `a` the loop never
|
|
129
|
+
# runs and the seeded row already holds the right answer, b.length.)
|
|
130
|
+
def osa_distance(a, b, cutoff)
|
|
131
|
+
prev_prev = nil
|
|
132
|
+
prev = (0..b.length).to_a
|
|
133
|
+
current = Array.new(b.length + 1)
|
|
134
|
+
|
|
135
|
+
a.each_char.with_index do |a_char, i|
|
|
136
|
+
current[0] = i + 1
|
|
137
|
+
row_min = current[0]
|
|
138
|
+
|
|
139
|
+
b.each_char.with_index do |b_char, j|
|
|
140
|
+
cost = a_char == b_char ? 0 : 1
|
|
141
|
+
value = [
|
|
142
|
+
current[j] + 1, # insertion
|
|
143
|
+
prev[j + 1] + 1, # deletion
|
|
144
|
+
prev[j] + cost # substitution
|
|
145
|
+
].min
|
|
146
|
+
|
|
147
|
+
# Transposition: the one that makes `gmial` a single mistake.
|
|
148
|
+
if i.positive? && j.positive? && a_char == b[j - 1] && a[i - 1] == b_char
|
|
149
|
+
value = [value, prev_prev[j - 1] + cost].min
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
current[j + 1] = value
|
|
153
|
+
row_min = value if value < row_min
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
return cutoff if row_min >= cutoff
|
|
157
|
+
|
|
158
|
+
prev_prev = prev
|
|
159
|
+
prev = current.dup
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
prev[b.length]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Nondisposable
|
|
6
|
+
# Is the bit after the last dot a real top-level domain?
|
|
7
|
+
#
|
|
8
|
+
# This is a different question from "is this a disposable provider", and it
|
|
9
|
+
# catches a different kind of bad address: the typo. `user@gmail.con` is not a
|
|
10
|
+
# throwaway — it is a real person who will never receive their confirmation
|
|
11
|
+
# email, because `.con` does not exist and never has. Those accounts are born
|
|
12
|
+
# dead: nobody can reach the user, and the user cannot recover the account.
|
|
13
|
+
#
|
|
14
|
+
# WHY A BUNDLED LIST AND NOT A DEPENDENCY
|
|
15
|
+
#
|
|
16
|
+
# The list is the IANA root zone database — the authoritative register of every
|
|
17
|
+
# delegated TLD on the internet, ~1,438 entries in about 9 KB:
|
|
18
|
+
#
|
|
19
|
+
# https://data.iana.org/TLD/tlds-alpha-by-domain.txt
|
|
20
|
+
#
|
|
21
|
+
# Every "TLD list" repository on GitHub is a scrape of that file, so we go to
|
|
22
|
+
# the source. The `tld` gem last shipped in 2014 and its list predates roughly
|
|
23
|
+
# 1,200 of today's TLDs. `public_suffix` is excellent and alive, but it answers
|
|
24
|
+
# "is this a valid public suffix" (which includes private entries like
|
|
25
|
+
# `github.io`) and it would pull the whole Public Suffix List in as a runtime
|
|
26
|
+
# dependency — the same trade-off DisposableDomain::PARENT_MATCH_DEPTH already
|
|
27
|
+
# declined, for the same reason.
|
|
28
|
+
#
|
|
29
|
+
# WHY IN MEMORY AND NOT IN THE DATABASE
|
|
30
|
+
#
|
|
31
|
+
# Disposable domains live in a table because there are 8,000+ of them and they
|
|
32
|
+
# change every few days. TLDs are 1,438 strings that change a handful of times
|
|
33
|
+
# a year. A frozen Set costs ~100 KB of process memory, answers in O(1) with no
|
|
34
|
+
# query per signup, needs no migration, and — unlike a table — cannot be empty
|
|
35
|
+
# on a fresh install. Refresh it with `rake nondisposable:tlds:update` (or
|
|
36
|
+
# `Nondisposable::TldListUpdater.update`) when cutting a release.
|
|
37
|
+
#
|
|
38
|
+
# STALENESS, AND WHY IT CANNOT LOCK ANYONE OUT
|
|
39
|
+
#
|
|
40
|
+
# The failure mode of a stale list is rejecting somebody whose TLD was
|
|
41
|
+
# delegated after the snapshot. `Nondisposable.configuration.additional_tlds`
|
|
42
|
+
# is the escape hatch: a host can accept a brand-new TLD immediately, without
|
|
43
|
+
# waiting for a gem release. And the check is opt-in (`config.check_tld`), so
|
|
44
|
+
# upgrading the gem never silently starts rejecting anybody.
|
|
45
|
+
module Tld
|
|
46
|
+
# The vendored IANA snapshot. Its first line is IANA's own version header,
|
|
47
|
+
# kept verbatim so the provenance and date of the data ship with it.
|
|
48
|
+
LIST_PATH = File.expand_path('../../data/iana_tlds.txt', __dir__)
|
|
49
|
+
|
|
50
|
+
# The names RFC 6761 and RFC 2606 reserve so they can NEVER be delegated.
|
|
51
|
+
# They are therefore absent from the root zone, and `check_tld` rejects
|
|
52
|
+
# them — which is right for a signup form, because no human types
|
|
53
|
+
# `me@home.test`, and an address there could never receive the confirmation
|
|
54
|
+
# email anyway.
|
|
55
|
+
#
|
|
56
|
+
# It is also, on the day you switch `check_tld` on, why half your test suite
|
|
57
|
+
# goes red: fixtures live at `user@example.test` for exactly the same reason
|
|
58
|
+
# the names are reserved. That is a configuration question, not a bug, and
|
|
59
|
+
# this constant is here so the answer reads like a sentence:
|
|
60
|
+
#
|
|
61
|
+
# config.additional_tlds = Nondisposable::Tld::SPECIAL_USE if Rails.env.local?
|
|
62
|
+
#
|
|
63
|
+
# Scope it to your non-production environments. Somewhere in the world
|
|
64
|
+
# somebody is running an app that really does deliver mail inside `.local`;
|
|
65
|
+
# if that is you, add it in production too and you are the exception that
|
|
66
|
+
# proves why this is configuration.
|
|
67
|
+
SPECIAL_USE = %w[test example invalid localhost local onion].freeze
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Every TLD IANA has delegated, downcased, as a frozen Set.
|
|
71
|
+
# Memoized: read once per process, never re-read.
|
|
72
|
+
def all
|
|
73
|
+
@all ||= parse(File.readlines(LIST_PATH, chomp: true)).freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# IANA's own version string for the bundled snapshot, e.g.
|
|
77
|
+
# "2026082301, Last Updated Mon Aug 24 07:07:01 2026 UTC". Useful in a
|
|
78
|
+
# health check to see how old your list is.
|
|
79
|
+
def version
|
|
80
|
+
@version ||= begin
|
|
81
|
+
header = File.open(LIST_PATH, &:readline).to_s.strip
|
|
82
|
+
header.start_with?('#') ? header.sub(/\A#\s*Version\s*/i, '') : nil
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The TLD of an email address or a bare domain, downcased.
|
|
89
|
+
#
|
|
90
|
+
# nil means THERE IS NO TLD HERE — which is not the same as "we can't
|
|
91
|
+
# tell". `example@gmailmcom` (the dot missed entirely) and
|
|
92
|
+
# `you@localhost` both land here, and under check_tld both are rejected:
|
|
93
|
+
# a domain with no TLD cannot end in a real one. Ask #judgeable? first if
|
|
94
|
+
# you need to tell that apart from an address this gem has no opinion on.
|
|
95
|
+
def extract(email_or_domain)
|
|
96
|
+
domain = normalize(email_or_domain)
|
|
97
|
+
return nil if domain.empty?
|
|
98
|
+
|
|
99
|
+
labels = domain.split('.')
|
|
100
|
+
return nil if labels.size < 2
|
|
101
|
+
|
|
102
|
+
tld = labels.last
|
|
103
|
+
tld.empty? ? nil : tld
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Is this an address the TLD rules have anything to say about?
|
|
107
|
+
#
|
|
108
|
+
# False for the two shapes where "what is the TLD" is the wrong question
|
|
109
|
+
# rather than a question with a bad answer: an empty domain, and an IP
|
|
110
|
+
# literal (`user@192.168.0.1`, `user@[10.0.0.1]`). Both are left alone —
|
|
111
|
+
# deciding whether to accept an IP-literal address is a format policy, and
|
|
112
|
+
# a validator called `check_tld` has no business making it.
|
|
113
|
+
def judgeable?(email_or_domain)
|
|
114
|
+
domain = normalize(email_or_domain)
|
|
115
|
+
return false if domain.empty?
|
|
116
|
+
return false if domain.start_with?('[') || domain.match?(/\A[\d.]+\z/)
|
|
117
|
+
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Is this TLD in the root zone (or in the host's additional_tlds)?
|
|
122
|
+
#
|
|
123
|
+
# ⚠️ Non-ASCII TLDs always answer true. IANA lists internationalised TLDs
|
|
124
|
+
# in punycode (`XN--FIQS8S`), and converting `中国` to that form needs an
|
|
125
|
+
# IDN library we deliberately do not depend on. Rather than reject every
|
|
126
|
+
# unicode address, we decline to judge them: a false accept is a nuisance,
|
|
127
|
+
# a false reject is a locked-out human. Punycode-form addresses, which is
|
|
128
|
+
# what mail clients actually send, are checked normally.
|
|
129
|
+
def valid?(tld)
|
|
130
|
+
tld = tld.to_s.downcase
|
|
131
|
+
return false if tld.empty?
|
|
132
|
+
return true unless tld.ascii_only?
|
|
133
|
+
|
|
134
|
+
all.include?(tld) || configured(:additional_tlds).include?(tld)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Has the host explicitly blocked this TLD — either by naming it in
|
|
138
|
+
# `blocked_tlds`, or by naming everything else in `allowed_tlds`?
|
|
139
|
+
def blocked?(tld)
|
|
140
|
+
tld = tld.to_s.downcase
|
|
141
|
+
return false if tld.empty?
|
|
142
|
+
|
|
143
|
+
allowed = configured(:allowed_tlds)
|
|
144
|
+
return true if allowed && !allowed.include?(tld)
|
|
145
|
+
|
|
146
|
+
configured(:blocked_tlds).include?(tld)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Reset the memoized list. Called by the updater after it rewrites the
|
|
150
|
+
# file, and by tests.
|
|
151
|
+
def reload!
|
|
152
|
+
@all = nil
|
|
153
|
+
@version = nil
|
|
154
|
+
self
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
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).
|
|
161
|
+
def normalize(email_or_domain)
|
|
162
|
+
email_or_domain.to_s.split('@').last.to_s.strip.downcase.delete_suffix('.')
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Skips IANA's comment header and any blank lines.
|
|
166
|
+
def parse(lines)
|
|
167
|
+
lines.each_with_object(Set.new) do |line, set|
|
|
168
|
+
value = line.to_s.strip.downcase
|
|
169
|
+
next if value.empty? || value.start_with?('#')
|
|
170
|
+
|
|
171
|
+
set << value
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Config lists are host-supplied, so normalise on every read rather than
|
|
176
|
+
# trusting them to be lowercase, dotless strings. `.tk` and `TK` both work.
|
|
177
|
+
def configured(key)
|
|
178
|
+
value = Nondisposable.configuration.public_send(key)
|
|
179
|
+
return nil if value.nil?
|
|
180
|
+
|
|
181
|
+
Set.new(Array(value).map { |tld| tld.to_s.strip.downcase.delete_prefix('.') })
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Nondisposable
|
|
7
|
+
# Refreshes the vendored IANA TLD snapshot from the root zone database.
|
|
8
|
+
#
|
|
9
|
+
# ⚠️ This is a MAINTENANCE task, not a runtime one — the deliberate opposite of
|
|
10
|
+
# DomainListUpdater. That one runs in your app, on a schedule, because
|
|
11
|
+
# disposable domains change every few days and live in your database. This one
|
|
12
|
+
# rewrites a file inside the gem, which is read-only once the gem is installed.
|
|
13
|
+
# Run it in a checkout of the gem before cutting a release:
|
|
14
|
+
#
|
|
15
|
+
# rake nondisposable:tlds:update
|
|
16
|
+
#
|
|
17
|
+
# If you need a TLD that is newer than your installed gem, do NOT reach for
|
|
18
|
+
# this. Add it to `config.additional_tlds` and you are unblocked immediately,
|
|
19
|
+
# with no release and no writable-gem-directory problem:
|
|
20
|
+
#
|
|
21
|
+
# config.additional_tlds = %w[newtld]
|
|
22
|
+
#
|
|
23
|
+
# New TLDs are delegated in batches a few times a year, so a snapshot ages
|
|
24
|
+
# slowly. ICANN's next application round is the first real test of that.
|
|
25
|
+
class TldListUpdater
|
|
26
|
+
LIST_URL = 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt'
|
|
27
|
+
|
|
28
|
+
OPEN_TIMEOUT = 10
|
|
29
|
+
READ_TIMEOUT = 10
|
|
30
|
+
|
|
31
|
+
# A root zone that suddenly contains a handful of entries means the fetch
|
|
32
|
+
# went wrong (a captive portal, an error page, a truncated body), not that
|
|
33
|
+
# ICANN deleted the internet. Refuse to overwrite a good list with junk.
|
|
34
|
+
MINIMUM_PLAUSIBLE_TLD_COUNT = 1_000
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Returns the number of TLDs written, or nil if nothing was written.
|
|
38
|
+
def update(path: Nondisposable::Tld::LIST_PATH)
|
|
39
|
+
log :info, "Fetching the IANA root zone TLD list from #{LIST_URL}..."
|
|
40
|
+
|
|
41
|
+
response = fetch
|
|
42
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
43
|
+
log :error, "Failed to download the TLD list. HTTP status: #{response.code}. Keeping the existing snapshot."
|
|
44
|
+
return nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
body = response.body.to_s
|
|
48
|
+
count = body.lines.count { |line| meaningful?(line) }
|
|
49
|
+
|
|
50
|
+
if count < MINIMUM_PLAUSIBLE_TLD_COUNT
|
|
51
|
+
log :error, "Refusing to write a TLD list with only #{count} entries (expected at least #{MINIMUM_PLAUSIBLE_TLD_COUNT}). Keeping the existing snapshot."
|
|
52
|
+
return nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Written whole, then moved into place, so an interrupted run can never
|
|
56
|
+
# leave a half-written root zone behind.
|
|
57
|
+
tmp = "#{path}.tmp"
|
|
58
|
+
File.write(tmp, body)
|
|
59
|
+
File.rename(tmp, path)
|
|
60
|
+
Nondisposable::Tld.reload!
|
|
61
|
+
|
|
62
|
+
log :info, "Wrote #{count} TLDs to #{path} (#{Nondisposable::Tld.version})."
|
|
63
|
+
count
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
log :error, "Could not update the TLD list: #{e.class}: #{e.message}. Keeping the existing snapshot."
|
|
66
|
+
nil
|
|
67
|
+
ensure
|
|
68
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def meaningful?(line)
|
|
74
|
+
value = line.to_s.strip
|
|
75
|
+
!value.empty? && !value.start_with?('#')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fetch
|
|
79
|
+
uri = URI(LIST_URL)
|
|
80
|
+
Net::HTTP.start(
|
|
81
|
+
uri.host,
|
|
82
|
+
uri.port,
|
|
83
|
+
use_ssl: uri.scheme == 'https',
|
|
84
|
+
open_timeout: OPEN_TIMEOUT,
|
|
85
|
+
read_timeout: READ_TIMEOUT
|
|
86
|
+
) { |http| http.get(uri.request_uri) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Usable from a bare `rake` run in a gem checkout, where there is no Rails
|
|
90
|
+
# logger to talk to.
|
|
91
|
+
def log(level, message)
|
|
92
|
+
message = "[nondisposable] #{message}"
|
|
93
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
94
|
+
Rails.logger.public_send(level, message)
|
|
95
|
+
else
|
|
96
|
+
warn message
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|