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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nondisposable
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
5
5
  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
- attr_accessor :configuration
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.2.1
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-01-17 00:00:00.000000000 Z
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: