nondisposable 0.3.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +305 -3
  4. data/Rakefile +5 -0
  5. data/app/models/nondisposable/disposable_domain.rb +53 -34
  6. data/app/models/nondisposable/source_snapshot.rb +66 -0
  7. data/data/email_providers.txt +276 -0
  8. data/data/iana_tlds.txt +1439 -0
  9. data/data/known_typo_domains.txt +99 -0
  10. data/data/never_disposable.txt +480 -0
  11. data/data/privacy_relays.txt +42 -0
  12. data/data/public_suffix_list.dat +16571 -0
  13. data/gemfiles/rails_7.2.gemfile +1 -0
  14. data/gemfiles/rails_8.0.gemfile +1 -0
  15. data/gemfiles/rails_8.1.gemfile +1 -0
  16. data/lib/generators/nondisposable/install_generator.rb +1 -0
  17. data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
  18. data/lib/generators/nondisposable/templates/nondisposable.rb +67 -1
  19. data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
  20. data/lib/nondisposable/confirmable_email.rb +75 -0
  21. data/lib/nondisposable/domain.rb +238 -0
  22. data/lib/nondisposable/domain_list_updater.rb +510 -88
  23. data/lib/nondisposable/email_validator.rb +101 -3
  24. data/lib/nondisposable/known_typos.rb +64 -0
  25. data/lib/nondisposable/precedence.rb +209 -0
  26. data/lib/nondisposable/public_suffix.rb +147 -0
  27. data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
  28. data/lib/nondisposable/source.rb +233 -0
  29. data/lib/nondisposable/source_fetcher.rb +69 -0
  30. data/lib/nondisposable/source_merge.rb +127 -0
  31. data/lib/nondisposable/suggestion.rb +336 -0
  32. data/lib/nondisposable/tld.rb +190 -0
  33. data/lib/nondisposable/tld_list_updater.rb +101 -0
  34. data/lib/nondisposable/version.rb +1 -1
  35. data/lib/nondisposable.rb +153 -6
  36. data/lib/tasks/nondisposable.rake +66 -0
  37. metadata +24 -2
data/lib/nondisposable.rb CHANGED
@@ -2,8 +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"
11
+ require_relative "nondisposable/tld"
12
+ require_relative "nondisposable/known_typos"
13
+ require_relative "nondisposable/suggestion"
14
+ require_relative "nondisposable/confirmable_email"
5
15
  require_relative "nondisposable/email_validator"
6
16
  require_relative "nondisposable/domain_list_updater"
17
+ require_relative "nondisposable/tld_list_updater"
18
+ require_relative "nondisposable/public_suffix_list_updater"
7
19
 
8
20
  module Nondisposable
9
21
  class Error < StandardError; end
@@ -24,28 +36,150 @@ module Nondisposable
24
36
  end
25
37
 
26
38
  def self.disposable?(email)
27
- return false if email.nil? || !email.include?('@')
28
- domain = email.to_s.split('@').last
29
- return false if domain.nil? || domain.empty?
30
- 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)
45
+ end
46
+
47
+ # Does this address end in a TLD that actually exists?
48
+ #
49
+ # A different question from #disposable?, catching a different kind of bad
50
+ # address: `user@gmail.con` is not a throwaway, it is a typo that produces an
51
+ # account nobody can ever reach. Answers true for anything we cannot judge —
52
+ # no domain, no dot, an IP literal — so it is safe to call on any input; pair
53
+ # it with a format validation if you also care about shape.
54
+ def self.valid_tld?(email)
55
+ return true unless Tld.judgeable?(email)
56
+
57
+ tld = Tld.extract(email)
58
+ return false if tld.nil? # A domain with no TLD can't end in a real one
59
+
60
+ Tld.valid?(tld) && !Tld.blocked?(tld)
61
+ end
62
+
63
+ # "someone@gmial.com" => "someone@gmail.com", or nil when we have nothing
64
+ # useful to say. Never blocks anything by itself — hand it to a user and let
65
+ # them decide. See Nondisposable::Suggestion for how it decides.
66
+ def self.suggestion_for(email)
67
+ Suggestion.for(email)
31
68
  end
32
69
 
33
70
  class Configuration
34
71
  ON_CHECK_FAILURE_MODES = [:allow, :reject].freeze
72
+ LOOKALIKE_MODES = [false, true, :confirmable].freeze
35
73
 
36
74
  attr_accessor :error_message, :additional_domains, :excluded_domains
37
75
  # Whether to also match parent domains: with the default true, an email at
38
- # x.tempmail.com is blocked when tempmail.com is on the list (checks up to
39
- # 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).
40
78
  attr_accessor :check_parent_domains
41
79
  attr_reader :on_check_failure
42
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
+
101
+ # ---- TLD validity (Nondisposable::Tld) ----------------------------------
102
+
103
+ # OFF by default, and it stays off: upgrading a gem must never silently
104
+ # start rejecting addresses that were fine yesterday. Turn it on to reject
105
+ # addresses whose TLD is not in the IANA root zone — `user@gmail.con` and
106
+ # friends, which are typos rather than throwaways but produce an account
107
+ # nobody can ever reach.
108
+ attr_accessor :check_tld
109
+
110
+ # Your escape hatch from a stale snapshot. A TLD delegated after this gem's
111
+ # release is unknown to the bundled list; naming it here accepts it
112
+ # immediately, with no release to wait for. Dots optional: "app" == ".app".
113
+ attr_accessor :additional_tlds
114
+
115
+ # TLDs to refuse even though they are perfectly real. The usual reason is
116
+ # abuse economics rather than validity — the historically free Freenom set
117
+ # (%w[tk ml ga cf gq]) is the classic example.
118
+ attr_accessor :blocked_tlds
119
+
120
+ # Allowlist mode: when set, ONLY these TLDs are accepted and every other one
121
+ # is refused. nil (the default) means "any TLD in the root zone". ⚠️ This is
122
+ # a blunt instrument — `%w[es]` turns away every customer who happens to use
123
+ # a .com address. Reach for blocked_tlds first.
124
+ attr_accessor :allowed_tlds
125
+
126
+ # ---- Lookalike domains (Nondisposable::Suggestion) ----------------------
127
+
128
+ # Flag addresses one edit away from a well-known provider — `gmail.co`,
129
+ # `gmial.com`, `hotmial.com` — with a message naming the correction.
130
+ #
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
143
+
144
+ # How many edits still count as "a typo". 1 (the default) is the distance at
145
+ # which acting on a guess is safe; at 2, genuinely different domains start
146
+ # colliding with each other. 0 disables suggestions entirely.
147
+ attr_accessor :lookalike_distance
148
+
149
+ # Providers to add to the bundled list — your own domain, a regional
150
+ # provider we missed. Adding one both makes it a suggestion candidate and,
151
+ # more importantly, stops its users being told they made a typo.
152
+ attr_accessor :additional_email_providers
153
+
154
+ # ---- Error messages -----------------------------------------------------
155
+
156
+ attr_accessor :invalid_tld_error_message, :blocked_tld_error_message
157
+ # %{suggestion} is replaced with the full corrected address.
158
+ attr_accessor :lookalike_error_message
159
+
43
160
  def initialize
44
161
  @error_message = "provider is not allowed"
45
162
  @additional_domains = []
46
163
  @excluded_domains = []
47
164
  @on_check_failure = :allow
48
165
  @check_parent_domains = true
166
+
167
+ @sources = Nondisposable::Sources::DEFAULT
168
+ @corroboration_threshold = 2
169
+ @block_privacy_relays = false
170
+
171
+ @check_tld = false
172
+ @additional_tlds = []
173
+ @blocked_tlds = []
174
+ @allowed_tlds = nil
175
+
176
+ @reject_lookalike_domains = false
177
+ @lookalike_distance = 1
178
+ @additional_email_providers = []
179
+
180
+ @invalid_tld_error_message = "doesn't look like a real email address"
181
+ @blocked_tld_error_message = "domain ending is not allowed"
182
+ @lookalike_error_message = "looks like a typo. Did you mean %{suggestion}?"
49
183
  end
50
184
 
51
185
  # What the validator does when the disposable check itself raises
@@ -60,5 +194,18 @@ module Nondisposable
60
194
 
61
195
  @on_check_failure = mode
62
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
63
210
  end
64
211
  end
@@ -0,0 +1,66 @@
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
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.3.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-09 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,20 +41,42 @@ 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
47
+ - data/email_providers.txt
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
46
53
  - gemfiles/rails_7.2.gemfile
47
54
  - gemfiles/rails_8.0.gemfile
48
55
  - gemfiles/rails_8.1.gemfile
49
56
  - lib/generators/nondisposable/install_generator.rb
50
57
  - lib/generators/nondisposable/templates/create_nondisposable_disposable_domains.rb.erb
58
+ - lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb
51
59
  - lib/generators/nondisposable/templates/disposable_email_domain_list_update_job.rb
52
60
  - lib/generators/nondisposable/templates/nondisposable.rb
61
+ - lib/generators/nondisposable/upgrade_generator.rb
53
62
  - lib/nondisposable.rb
63
+ - lib/nondisposable/confirmable_email.rb
64
+ - lib/nondisposable/domain.rb
54
65
  - lib/nondisposable/domain_list_updater.rb
55
66
  - lib/nondisposable/email_validator.rb
56
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
75
+ - lib/nondisposable/suggestion.rb
76
+ - lib/nondisposable/tld.rb
77
+ - lib/nondisposable/tld_list_updater.rb
57
78
  - lib/nondisposable/version.rb
79
+ - lib/tasks/nondisposable.rake
58
80
  - sig/nondisposable.rbs
59
81
  homepage: https://github.com/rameerez/nondisposable
60
82
  licenses: