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
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Nondisposable
6
+ # One upstream blocklist, described well enough to decide how far to trust it.
7
+ #
8
+ # TRUST
9
+ #
10
+ # :primary every domain it lists is blocked (subject to the
11
+ # precedence table, see Nondisposable::Precedence)
12
+ # :corroborating a domain it lists is blocked only once
13
+ # config.corroboration_threshold independent ORIGINS list it
14
+ #
15
+ # LINEAGE, AND WHY TWO LISTS ARE OFTEN ONE OBSERVATION
16
+ #
17
+ # Public blocklists copy each other. `disposable/disposable-email-domains`
18
+ # declares that it imports mailchecker, fakefilter, wesbos, unkn0w and the
19
+ # original disposable-email-domains list; groundcat imports
20
+ # disposable/disposable among others. Counting "two repositories list it" as
21
+ # two votes would let one mistaken line acquire as many votes as it has
22
+ # copies. So each source declares what it is `derived_from`, and a source's
23
+ # vote for a domain only counts when none of its (transitive) upstreams that
24
+ # we also loaded carries the same domain. See Nondisposable::SourceMerge.
25
+ #
26
+ # Declared lineage is the only lineage we know about. Two lists that copy each
27
+ # other without saying so still look independent, which is one more reason
28
+ # the recommended preset leans on primaries and keeps corroboration strict.
29
+ class Source
30
+ TRUST_LEVELS = %i[primary corroborating].freeze
31
+
32
+ # Generous for the largest list we know of (disposable/disposable, ~1.4 MB),
33
+ # small enough that a runaway response cannot exhaust a worker's memory.
34
+ DEFAULT_MAX_BYTES = 8 * 1024 * 1024
35
+ # Wall-clock budget for the whole download of one source, in seconds.
36
+ DEFAULT_DEADLINE = 30
37
+
38
+ # Lines that fail to parse as a domain are dropped one by one. When more than
39
+ # this share of the payload is junk, the payload is not a blocklist at all —
40
+ # an HTML error page, a captive portal, a format change — and the whole
41
+ # snapshot is refused rather than half-ingested.
42
+ MAX_INVALID_RATIO = 0.01
43
+
44
+ NAME_FORMAT = %r{\A[a-z0-9][a-z0-9._/-]*\z}
45
+
46
+ ATTRIBUTES = %i[name url trust derived_from license homepage minimum_count max_bytes deadline bundled_path].freeze
47
+ attr_reader(*ATTRIBUTES)
48
+
49
+ # Accepts a Source, or a Hash of its attributes (what a host writes in an
50
+ # initializer). Raises ArgumentError on anything it cannot trust.
51
+ def self.build(value)
52
+ return value if value.is_a?(Source)
53
+ raise ArgumentError, "a source must be a Nondisposable::Source or a Hash (got #{value.inspect})" unless value.is_a?(Hash)
54
+
55
+ new(**value.transform_keys(&:to_sym))
56
+ end
57
+
58
+ def initialize(name:, url:, trust: :corroborating, derived_from: [], license: nil, homepage: nil,
59
+ minimum_count: 1, max_bytes: DEFAULT_MAX_BYTES, deadline: DEFAULT_DEADLINE, bundled_path: nil)
60
+ @name = name.to_s
61
+ @url = url.to_s
62
+ @trust = trust.respond_to?(:to_sym) ? trust.to_sym : trust
63
+ @derived_from = Array(derived_from).map(&:to_s).freeze
64
+ @license = license
65
+ @homepage = homepage
66
+ @minimum_count = Integer(minimum_count)
67
+ @max_bytes = Integer(max_bytes)
68
+ @deadline = deadline
69
+ @bundled_path = bundled_path
70
+
71
+ validate!
72
+ freeze
73
+ end
74
+
75
+ # A copy with some attributes replaced, e.g. promoting a preset source:
76
+ # Nondisposable::Sources::DISPOSABLE.with(trust: :primary)
77
+ def with(**overrides)
78
+ self.class.new(**to_h.merge(overrides))
79
+ end
80
+
81
+ def to_h
82
+ ATTRIBUTES.to_h { |attribute| [attribute, public_send(attribute)] }
83
+ end
84
+
85
+ def primary?
86
+ trust == :primary
87
+ end
88
+
89
+ def ==(other)
90
+ other.is_a?(Source) && other.to_h == to_h
91
+ end
92
+ alias eql? ==
93
+
94
+ def hash
95
+ to_h.hash
96
+ end
97
+
98
+ Parsed = Struct.new(:domains, :lines, :invalid, :invalid_sample, keyword_init: true) do
99
+ def invalid_ratio
100
+ lines.zero? ? 0.0 : invalid.fdiv(lines)
101
+ end
102
+ end
103
+
104
+ # Plain text, one domain per line. `#` starts a comment (whole-line or
105
+ # inline), blank lines are ignored, and every entry goes through
106
+ # Domain.normalize. Entries that are not a possible DNS name are counted
107
+ # and dropped; public suffixes are kept here (the snapshot records what the
108
+ # source said) and dropped by the precedence filter when the list is built.
109
+ #
110
+ # Returns sorted, unique, canonical domains: the count every guard is
111
+ # measured on, so duplicates and casing cannot inflate it.
112
+ def self.parse(body)
113
+ domains = Set.new
114
+ lines = 0
115
+ invalid = 0
116
+ sample = []
117
+
118
+ body.to_s.each_line do |line|
119
+ entry = line.sub(/#.*/, '').strip
120
+ next if entry.empty?
121
+
122
+ lines += 1
123
+ domain = Domain.normalize(entry)
124
+ if Precedence.hostname?(domain)
125
+ domains << domain
126
+ else
127
+ invalid += 1
128
+ sample << entry[0, 80] if sample.size < 5
129
+ end
130
+ end
131
+
132
+ Parsed.new(domains: domains.to_a.sort, lines: lines, invalid: invalid, invalid_sample: sample)
133
+ end
134
+
135
+ private
136
+
137
+ def validate!
138
+ raise ArgumentError, "source name #{name.inspect} must match #{NAME_FORMAT.inspect}" unless NAME_FORMAT.match?(name)
139
+ raise ArgumentError, "source #{name}: trust must be one of #{TRUST_LEVELS.inspect} (got #{trust.inspect})" unless TRUST_LEVELS.include?(trust)
140
+ # Plain http would let anybody on the path rewrite the blocklist.
141
+ raise ArgumentError, "source #{name}: url must be https (got #{url.inspect})" unless url.start_with?('https://')
142
+ raise ArgumentError, "source #{name}: max_bytes must be positive" unless max_bytes.positive?
143
+ raise ArgumentError, "source #{name}: minimum_count must be at least 1" unless minimum_count >= 1
144
+ raise ArgumentError, "source #{name}: deadline must be a positive number of seconds" unless deadline.is_a?(Numeric) && deadline.positive?
145
+ raise ArgumentError, "source #{name} cannot be derived from itself" if derived_from.include?(name)
146
+ end
147
+ end
148
+
149
+ # The sources we have measured, with their licences and declared lineage.
150
+ # Measurements (2026-10-17) and the reasoning behind every trust level:
151
+ # docs/evidence/2026-10-17-multi-source-measurement.md
152
+ module Sources
153
+ # The list this gem has always used, and the one bundled as the seed.
154
+ DISPOSABLE_EMAIL_DOMAINS = Source.new(
155
+ name: 'disposable-email-domains',
156
+ url: 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf',
157
+ homepage: 'https://github.com/disposable-email-domains/disposable-email-domains',
158
+ license: 'CC0-1.0',
159
+ trust: :primary,
160
+ minimum_count: 1_000,
161
+ bundled_path: File.expand_path('../../data/disposable_email_blocklist.conf', __dir__)
162
+ )
163
+
164
+ # Monitors temp-mail generators directly and publishes what they hand out.
165
+ # 0 known-real providers measured in it.
166
+ FAKEFILTER = Source.new(
167
+ name: 'fakefilter',
168
+ url: 'https://raw.githubusercontent.com/7c/fakefilter/main/txt/data.txt',
169
+ homepage: 'https://github.com/7c/fakefilter',
170
+ license: 'BSD-3-Clause',
171
+ trust: :primary,
172
+ minimum_count: 1_000
173
+ )
174
+
175
+ MAILCHECKER = Source.new(
176
+ name: 'mailchecker',
177
+ url: 'https://raw.githubusercontent.com/FGRibreau/mailchecker/master/list.txt',
178
+ homepage: 'https://github.com/FGRibreau/mailchecker',
179
+ license: 'MIT',
180
+ trust: :corroborating,
181
+ minimum_count: 1_000
182
+ )
183
+
184
+ WESBOS = Source.new(
185
+ name: 'wesbos',
186
+ url: 'https://raw.githubusercontent.com/wesbos/burner-email-providers/master/emails.txt',
187
+ homepage: 'https://github.com/wesbos/burner-email-providers',
188
+ license: 'MIT',
189
+ trust: :corroborating,
190
+ minimum_count: 1_000
191
+ )
192
+
193
+ # The broadest maintained list (~97k) and the only one that carried
194
+ # mail-burn.com on 2026-10-17 — but as a PRIMARY it also blocks real mail
195
+ # providers (China Unicom's wo.com.cn, the Japanese ISP odn.ad.jp, ...), so
196
+ # it is corroborating by default. Promote it knowingly:
197
+ # Nondisposable::Sources::DISPOSABLE.with(trust: :primary)
198
+ DISPOSABLE = Source.new(
199
+ name: 'disposable',
200
+ url: 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt',
201
+ homepage: 'https://github.com/disposable/disposable',
202
+ license: 'MIT',
203
+ trust: :corroborating,
204
+ derived_from: %w[disposable-email-domains fakefilter mailchecker wesbos unkn0w],
205
+ minimum_count: 1_000
206
+ )
207
+
208
+ GROUNDCAT = Source.new(
209
+ name: 'groundcat',
210
+ url: 'https://raw.githubusercontent.com/groundcat/disposable-email-domain-list/master/domains.txt',
211
+ homepage: 'https://github.com/groundcat/disposable-email-domain-list',
212
+ license: 'MIT',
213
+ trust: :corroborating,
214
+ derived_from: %w[disposable-email-domains disposable mailchecker ivolo emailondeck],
215
+ minimum_count: 1_000
216
+ )
217
+
218
+ # Today's behaviour: the one list this gem has always used.
219
+ DEFAULT = [DISPOSABLE_EMAIL_DOMAINS].freeze
220
+
221
+ # Two primaries that observe generators independently. +3,275 domains over
222
+ # DEFAULT on 2026-10-17, none of them a known real provider.
223
+ #
224
+ # Why no corroborating sources: with lineage declared, disposable,
225
+ # mailchecker and groundcat are each explained by an upstream already
226
+ # loaded, so together they added ZERO domains for three more downloads a
227
+ # day. Adding wesbos (no declared lineage, but 57% of it is also in
228
+ # mailchecker) "corroborated" 11,751 more, among them real providers such
229
+ # as tom.com and sdf.org. Add corroborating sources once a genuinely
230
+ # independent list exists; the machinery is ready.
231
+ RECOMMENDED = [DISPOSABLE_EMAIL_DOMAINS, FAKEFILTER].freeze
232
+ end
233
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'timeout'
5
+ require 'uri'
6
+
7
+ module Nondisposable
8
+ # Downloads one source's body, refusing anything that would not fit or not
9
+ # finish: the body is streamed, and the download is abandoned the moment it
10
+ # crosses the source's `max_bytes` or its `deadline`. A slow or bottomless
11
+ # response therefore costs at most one deadline, never a stuck job or a
12
+ # worker's memory.
13
+ #
14
+ # No redirects are followed: every preset URL is a direct raw file, and a
15
+ # redirect to somewhere else is not the list we decided to trust.
16
+ module SourceFetcher
17
+ class FetchError < StandardError; end
18
+
19
+ OPEN_TIMEOUT = 10
20
+ READ_TIMEOUT = 10
21
+
22
+ class << self
23
+ def fetch(source)
24
+ uri = URI(source.url)
25
+ started = monotonic
26
+ body = +''
27
+
28
+ # The per-chunk check below cannot fire while a server trickles headers
29
+ # (or nothing at all) within each read timeout, so the deadline is also
30
+ # one absolute timeout over the whole exchange.
31
+ Timeout.timeout(source.deadline, FetchError, "download exceeded its #{source.deadline}s deadline") do
32
+ Net::HTTP.start(
33
+ uri.host,
34
+ uri.port,
35
+ use_ssl: uri.scheme == 'https',
36
+ open_timeout: [OPEN_TIMEOUT, source.deadline].min,
37
+ read_timeout: [READ_TIMEOUT, source.deadline].min
38
+ ) do |http|
39
+ http.max_retries = 0 if http.respond_to?(:max_retries=)
40
+
41
+ http.request(Net::HTTP::Get.new(uri)) do |response|
42
+ raise FetchError, "HTTP status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
43
+
44
+ declared = response['Content-Length'].to_i
45
+ if declared > source.max_bytes
46
+ raise FetchError, "declared size #{declared} bytes exceeds max_bytes #{source.max_bytes}"
47
+ end
48
+
49
+ response.read_body do |chunk|
50
+ body << chunk
51
+ raise FetchError, "body exceeds max_bytes #{source.max_bytes}" if body.bytesize > source.max_bytes
52
+ raise FetchError, "download exceeded its #{source.deadline}s deadline" if monotonic - started > source.deadline
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ body.force_encoding(Encoding::UTF_8).scrub('')
59
+ end
60
+
61
+ private
62
+
63
+ # Separate so tests can move time forward.
64
+ def monotonic
65
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Nondisposable
6
+ # Turns each source's last good snapshot into one list of blocked domains.
7
+ #
8
+ # blocked = every domain in any PRIMARY source
9
+ # + every domain that at least `threshold` independent ORIGINS
10
+ # among the CORROBORATING sources list
11
+ #
12
+ # Votes are counted per INDEPENDENT ORIGIN. Two voters are RELATED when one
13
+ # declares the other as an upstream, or both declare a common upstream
14
+ # (transitively, including upstreams we did not load: two mirrors of the
15
+ # same unfetched list are one observation, not two). A domain's origin
16
+ # count is the largest set of pairwise-unrelated voters. A list that merely
17
+ # copies two independent lists does not make them related, and adds no vote.
18
+ #
19
+ # Known blind spot: lineage is only what sources DECLARE. Two lists that copy
20
+ # each other silently (wesbos and mailchecker overlap by 57%) still look
21
+ # independent; that is why the recommended preset uses no corroborating
22
+ # sources at all.
23
+ #
24
+ # Pure: no database, no configuration, no logging. The precedence table is
25
+ # applied afterwards (Nondisposable::Precedence.filter).
26
+ module SourceMerge
27
+ Result = Struct.new(:domains, :primary_count, :corroborated_count, keyword_init: true)
28
+
29
+ class << self
30
+ # `snapshots` is { Source => Array<String> (normalized domains) }.
31
+ # `lineage` is every configured source, so a declared link still counts
32
+ # when the source in the middle of it has no snapshot (failed fetch).
33
+ def call(snapshots, threshold:, lineage: snapshots.keys)
34
+ threshold = Integer(threshold)
35
+ raise ArgumentError, 'corroboration_threshold must be at least 1' if threshold < 1
36
+
37
+ primaries, corroborating = snapshots.partition { |source, _| source.primary? }
38
+
39
+ blocked = Set.new
40
+ primaries.each { |_, domains| blocked.merge(domains) }
41
+ primary_count = blocked.size
42
+
43
+ ancestors = ancestry(lineage | snapshots.keys)
44
+ verdicts = {} # voter set => enough independent origins? (lists share few sets)
45
+
46
+ votes = Hash.new { |hash, key| hash[key] = [] }
47
+ corroborating.each do |source, domains|
48
+ domains.each { |domain| votes[domain] << source.name unless blocked.include?(domain) }
49
+ end
50
+
51
+ corroborated = 0
52
+ votes.each do |domain, voters|
53
+ next if voters.size < threshold # cannot reach it even before lineage
54
+
55
+ enough = verdicts.fetch(voters) { verdicts[voters] = independent_subset?(voters, threshold, ancestors) }
56
+ next unless enough
57
+
58
+ blocked << domain
59
+ corroborated += 1
60
+ end
61
+
62
+ Result.new(domains: blocked, primary_count: primary_count, corroborated_count: corroborated)
63
+ end
64
+
65
+ # How many independent origins vote for `domain`. For explaining a
66
+ # verdict in a console, not used by #call's fast path.
67
+ # A largest set of pairwise-unrelated sources voting for `domain`
68
+ # (alphabetically first among ties). For explaining a verdict in a
69
+ # console; #call uses the same rule.
70
+ def origins_for(domain, snapshots, lineage: snapshots.keys)
71
+ voters = snapshots.select { |_, domains| domains.include?(domain) }.keys.map(&:name)
72
+ independent_origins(voters, ancestry(lineage | snapshots.keys))
73
+ end
74
+
75
+ private
76
+
77
+ # Is there a set of `size` pairwise-unrelated voters? Only subsets of
78
+ # exactly that size are tried, so the cost is C(voters, threshold), not
79
+ # 2^voters, and #call memoizes it per voter set.
80
+ def independent_subset?(voters, size, ancestors)
81
+ return false if voters.size < size
82
+
83
+ voters.sort.combination(size).any? do |subset|
84
+ subset.combination(2).none? { |a, b| related?(a, b, ancestors) }
85
+ end
86
+ end
87
+
88
+ def related?(a, b, ancestors)
89
+ ancestors[a].include?(b) || ancestors[b].include?(a) || ancestors[a].intersect?(ancestors[b])
90
+ end
91
+
92
+ # Brute force over subsets, largest first: there are only ever a handful
93
+ # of corroborating sources, and most domains have one or two voters.
94
+ def independent_origins(voters, ancestors)
95
+ voters = voters.sort
96
+ related = lambda do |a, b|
97
+ ancestors[a].include?(b) || ancestors[b].include?(a) || ancestors[a].intersect?(ancestors[b])
98
+ end
99
+
100
+ voters.size.downto(1) do |size|
101
+ voters.combination(size) do |subset|
102
+ return subset if subset.combination(2).none? { |a, b| related.call(a, b) }
103
+ end
104
+ end
105
+ []
106
+ end
107
+
108
+ # { source name => Set of every declared upstream, transitively,
109
+ # loaded or not }.
110
+ def ancestry(sources)
111
+ declared = sources.to_h { |source| [source.name, source.derived_from] }
112
+ declared.keys.to_h do |name|
113
+ seen = Set.new
114
+ stack = declared[name].dup
115
+ until stack.empty?
116
+ upstream = stack.pop
117
+ next if seen.include?(upstream)
118
+
119
+ seen << upstream
120
+ stack.concat(declared.fetch(upstream, []))
121
+ end
122
+ [name, seen]
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end