nondisposable 0.2.1 → 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 +42 -0
- data/README.md +145 -1
- data/Rakefile +5 -0
- data/app/models/nondisposable/disposable_domain.rb +42 -2
- data/context7.json +4 -0
- data/data/disposable_email_blocklist.conf +8201 -0
- data/data/email_providers.txt +276 -0
- data/data/iana_tlds.txt +1439 -0
- data/lib/generators/nondisposable/install_generator.rb +2 -2
- data/lib/generators/nondisposable/templates/create_nondisposable_disposable_domains.rb.erb +9 -0
- data/lib/generators/nondisposable/templates/nondisposable.rb +53 -0
- data/lib/nondisposable/domain_list_updater.rb +93 -16
- data/lib/nondisposable/email_validator.rb +58 -3
- 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 +122 -2
- data/lib/tasks/nondisposable.rake +37 -0
- metadata +10 -2
|
@@ -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
|
data/lib/nondisposable.rb
CHANGED
|
@@ -2,18 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "nondisposable/version"
|
|
4
4
|
require_relative "nondisposable/engine"
|
|
5
|
+
require_relative "nondisposable/tld"
|
|
6
|
+
require_relative "nondisposable/suggestion"
|
|
5
7
|
require_relative "nondisposable/email_validator"
|
|
6
8
|
require_relative "nondisposable/domain_list_updater"
|
|
9
|
+
require_relative "nondisposable/tld_list_updater"
|
|
7
10
|
|
|
8
11
|
module Nondisposable
|
|
9
12
|
class Error < StandardError; end
|
|
10
13
|
|
|
11
14
|
class << self
|
|
12
|
-
|
|
15
|
+
attr_writer :configuration
|
|
16
|
+
|
|
17
|
+
# Lazily initialized so the gem works safely even when the host app never
|
|
18
|
+
# runs an initializer (previously a nil configuration made every check
|
|
19
|
+
# raise, which the validator surfaced as a validation error on all emails).
|
|
20
|
+
def configuration
|
|
21
|
+
@configuration ||= Configuration.new
|
|
22
|
+
end
|
|
13
23
|
end
|
|
14
24
|
|
|
15
25
|
def self.configure
|
|
16
|
-
self.configuration ||= Configuration.new
|
|
17
26
|
yield(configuration)
|
|
18
27
|
end
|
|
19
28
|
|
|
@@ -24,13 +33,124 @@ module Nondisposable
|
|
|
24
33
|
DisposableDomain.disposable?(domain.downcase)
|
|
25
34
|
end
|
|
26
35
|
|
|
36
|
+
# Does this address end in a TLD that actually exists?
|
|
37
|
+
#
|
|
38
|
+
# A different question from #disposable?, catching a different kind of bad
|
|
39
|
+
# address: `user@gmail.con` is not a throwaway, it is a typo that produces an
|
|
40
|
+
# account nobody can ever reach. Answers true for anything we cannot judge —
|
|
41
|
+
# no domain, no dot, an IP literal — so it is safe to call on any input; pair
|
|
42
|
+
# it with a format validation if you also care about shape.
|
|
43
|
+
def self.valid_tld?(email)
|
|
44
|
+
return true unless Tld.judgeable?(email)
|
|
45
|
+
|
|
46
|
+
tld = Tld.extract(email)
|
|
47
|
+
return false if tld.nil? # A domain with no TLD can't end in a real one
|
|
48
|
+
|
|
49
|
+
Tld.valid?(tld) && !Tld.blocked?(tld)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# "someone@gmial.com" => "someone@gmail.com", or nil when we have nothing
|
|
53
|
+
# useful to say. Never blocks anything by itself — hand it to a user and let
|
|
54
|
+
# them decide. See Nondisposable::Suggestion for how it decides.
|
|
55
|
+
def self.suggestion_for(email)
|
|
56
|
+
Suggestion.for(email)
|
|
57
|
+
end
|
|
58
|
+
|
|
27
59
|
class Configuration
|
|
60
|
+
ON_CHECK_FAILURE_MODES = [:allow, :reject].freeze
|
|
61
|
+
|
|
28
62
|
attr_accessor :error_message, :additional_domains, :excluded_domains
|
|
63
|
+
# 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).
|
|
66
|
+
attr_accessor :check_parent_domains
|
|
67
|
+
attr_reader :on_check_failure
|
|
68
|
+
|
|
69
|
+
# ---- TLD validity (Nondisposable::Tld) ----------------------------------
|
|
70
|
+
|
|
71
|
+
# OFF by default, and it stays off: upgrading a gem must never silently
|
|
72
|
+
# start rejecting addresses that were fine yesterday. Turn it on to reject
|
|
73
|
+
# addresses whose TLD is not in the IANA root zone — `user@gmail.con` and
|
|
74
|
+
# friends, which are typos rather than throwaways but produce an account
|
|
75
|
+
# nobody can ever reach.
|
|
76
|
+
attr_accessor :check_tld
|
|
77
|
+
|
|
78
|
+
# Your escape hatch from a stale snapshot. A TLD delegated after this gem's
|
|
79
|
+
# release is unknown to the bundled list; naming it here accepts it
|
|
80
|
+
# immediately, with no release to wait for. Dots optional: "app" == ".app".
|
|
81
|
+
attr_accessor :additional_tlds
|
|
82
|
+
|
|
83
|
+
# TLDs to refuse even though they are perfectly real. The usual reason is
|
|
84
|
+
# abuse economics rather than validity — the historically free Freenom set
|
|
85
|
+
# (%w[tk ml ga cf gq]) is the classic example.
|
|
86
|
+
attr_accessor :blocked_tlds
|
|
87
|
+
|
|
88
|
+
# Allowlist mode: when set, ONLY these TLDs are accepted and every other one
|
|
89
|
+
# is refused. nil (the default) means "any TLD in the root zone". ⚠️ This is
|
|
90
|
+
# a blunt instrument — `%w[es]` turns away every customer who happens to use
|
|
91
|
+
# a .com address. Reach for blocked_tlds first.
|
|
92
|
+
attr_accessor :allowed_tlds
|
|
93
|
+
|
|
94
|
+
# ---- Lookalike domains (Nondisposable::Suggestion) ----------------------
|
|
95
|
+
|
|
96
|
+
# Reject addresses one edit away from a well-known provider — `gmail.co`,
|
|
97
|
+
# `gmial.com`, `hotmial.com` — with a message naming the correction.
|
|
98
|
+
#
|
|
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
|
|
105
|
+
|
|
106
|
+
# How many edits still count as "a typo". 1 (the default) is the distance at
|
|
107
|
+
# which acting on a guess is safe; at 2, genuinely different domains start
|
|
108
|
+
# colliding with each other. 0 disables suggestions entirely.
|
|
109
|
+
attr_accessor :lookalike_distance
|
|
110
|
+
|
|
111
|
+
# Providers to add to the bundled list — your own domain, a regional
|
|
112
|
+
# provider we missed. Adding one both makes it a suggestion candidate and,
|
|
113
|
+
# more importantly, stops its users being told they made a typo.
|
|
114
|
+
attr_accessor :additional_email_providers
|
|
115
|
+
|
|
116
|
+
# ---- Error messages -----------------------------------------------------
|
|
117
|
+
|
|
118
|
+
attr_accessor :invalid_tld_error_message, :blocked_tld_error_message
|
|
119
|
+
# %{suggestion} is replaced with the full corrected address.
|
|
120
|
+
attr_accessor :lookalike_error_message
|
|
29
121
|
|
|
30
122
|
def initialize
|
|
31
123
|
@error_message = "provider is not allowed"
|
|
32
124
|
@additional_domains = []
|
|
33
125
|
@excluded_domains = []
|
|
126
|
+
@on_check_failure = :allow
|
|
127
|
+
@check_parent_domains = true
|
|
128
|
+
|
|
129
|
+
@check_tld = false
|
|
130
|
+
@additional_tlds = []
|
|
131
|
+
@blocked_tlds = []
|
|
132
|
+
@allowed_tlds = nil
|
|
133
|
+
|
|
134
|
+
@reject_lookalike_domains = false
|
|
135
|
+
@lookalike_distance = 1
|
|
136
|
+
@additional_email_providers = []
|
|
137
|
+
|
|
138
|
+
@invalid_tld_error_message = "doesn't look like a real email address"
|
|
139
|
+
@blocked_tld_error_message = "domain ending is not allowed"
|
|
140
|
+
@lookalike_error_message = "looks like a typo. Did you mean %{suggestion}?"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# What the validator does when the disposable check itself raises
|
|
144
|
+
# (e.g. the database is unavailable):
|
|
145
|
+
# :allow - let the record through and log an error (availability-first, default)
|
|
146
|
+
# :reject - add a validation error, blocking the record (fail closed)
|
|
147
|
+
def on_check_failure=(mode)
|
|
148
|
+
mode = mode.to_sym if mode.respond_to?(:to_sym)
|
|
149
|
+
unless ON_CHECK_FAILURE_MODES.include?(mode)
|
|
150
|
+
raise ArgumentError, "on_check_failure must be one of #{ON_CHECK_FAILURE_MODES.map(&:inspect).join(', ')} (got #{mode.inspect})"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
@on_check_failure = mode
|
|
34
154
|
end
|
|
35
155
|
end
|
|
36
156
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :nondisposable do
|
|
4
|
+
namespace :tlds do
|
|
5
|
+
desc "Refresh the vendored IANA TLD snapshot (maintenance task — run in a gem checkout before a release)"
|
|
6
|
+
task :update do
|
|
7
|
+
# Deliberately NOT `require "nondisposable"`: that loads the Engine, which
|
|
8
|
+
# needs Rails, which a bare `rake` in a gem checkout doesn't have. Nothing
|
|
9
|
+
# this task touches reads configuration — only `Tld.valid?`/`blocked?` do,
|
|
10
|
+
# and it calls neither.
|
|
11
|
+
require_relative "../nondisposable/tld"
|
|
12
|
+
require_relative "../nondisposable/tld_list_updater"
|
|
13
|
+
|
|
14
|
+
before = begin
|
|
15
|
+
Nondisposable::Tld.all.size
|
|
16
|
+
rescue StandardError
|
|
17
|
+
0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
count = Nondisposable::TldListUpdater.update
|
|
21
|
+
|
|
22
|
+
if count.nil?
|
|
23
|
+
abort "[nondisposable] TLD list unchanged. See the error above."
|
|
24
|
+
else
|
|
25
|
+
added = count - before
|
|
26
|
+
change =
|
|
27
|
+
if added.positive? then "+#{added}"
|
|
28
|
+
elsif added.negative? then added.to_s
|
|
29
|
+
else "no change"
|
|
30
|
+
end
|
|
31
|
+
puts "[nondisposable] TLD snapshot now holds #{count} entries (#{change})."
|
|
32
|
+
puts "[nondisposable] IANA version: #{Nondisposable::Tld.version}"
|
|
33
|
+
puts "[nondisposable] Commit data/iana_tlds.txt if it changed."
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
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.4.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-08-27 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -41,6 +41,10 @@ files:
|
|
|
41
41
|
- README.md
|
|
42
42
|
- Rakefile
|
|
43
43
|
- app/models/nondisposable/disposable_domain.rb
|
|
44
|
+
- context7.json
|
|
45
|
+
- data/disposable_email_blocklist.conf
|
|
46
|
+
- data/email_providers.txt
|
|
47
|
+
- data/iana_tlds.txt
|
|
44
48
|
- gemfiles/rails_7.2.gemfile
|
|
45
49
|
- gemfiles/rails_8.0.gemfile
|
|
46
50
|
- gemfiles/rails_8.1.gemfile
|
|
@@ -52,7 +56,11 @@ files:
|
|
|
52
56
|
- lib/nondisposable/domain_list_updater.rb
|
|
53
57
|
- lib/nondisposable/email_validator.rb
|
|
54
58
|
- lib/nondisposable/engine.rb
|
|
59
|
+
- lib/nondisposable/suggestion.rb
|
|
60
|
+
- lib/nondisposable/tld.rb
|
|
61
|
+
- lib/nondisposable/tld_list_updater.rb
|
|
55
62
|
- lib/nondisposable/version.rb
|
|
63
|
+
- lib/tasks/nondisposable.rake
|
|
56
64
|
- sig/nondisposable.rbs
|
|
57
65
|
homepage: https://github.com/rameerez/nondisposable
|
|
58
66
|
licenses:
|