nondisposable 0.2.1 → 0.3.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 +22 -0
- data/README.md +22 -1
- data/app/models/nondisposable/disposable_domain.rb +42 -2
- data/context7.json +4 -0
- data/data/disposable_email_blocklist.conf +8201 -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 +9 -0
- data/lib/nondisposable/domain_list_updater.rb +93 -16
- data/lib/nondisposable/email_validator.rb +6 -2
- data/lib/nondisposable/version.rb +1 -1
- data/lib/nondisposable.rb +30 -2
- metadata +4 -2
|
@@ -29,8 +29,8 @@ module Nondisposable
|
|
|
29
29
|
def display_post_install_message
|
|
30
30
|
say "\tThe `nondisposable` gem has been successfully installed!", :green
|
|
31
31
|
say "\nTo complete the setup:"
|
|
32
|
-
say " 1. Run 'rails db:migrate' to create the necessary tables."
|
|
33
|
-
say " 2. Run 'Nondisposable::DomainListUpdater.update' to
|
|
32
|
+
say " 1. Run 'rails db:migrate' to create the necessary tables (this also seeds an initial list of disposable domains bundled with the gem)."
|
|
33
|
+
say " 2. Run 'Nondisposable::DomainListUpdater.update' to fetch the latest list of disposable domains."
|
|
34
34
|
say " 3. Add 'validates :email, nondisposable: true' to your User model (or any model with an email field)."
|
|
35
35
|
say " 4. Configure your recurrent job according to the README, and make sure you have a functional queuing system (like solid_queue) that can run jobs properly so the disposable emails list is updated regularly."
|
|
36
36
|
say "\nEnjoy your new `nondisposable` users!", :green
|
|
@@ -7,6 +7,15 @@ class CreateNondisposableDisposableDomains < ActiveRecord::Migration<%= migratio
|
|
|
7
7
|
|
|
8
8
|
t.timestamps
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
reversible do |dir|
|
|
12
|
+
dir.up do
|
|
13
|
+
# Seed from the blocklist snapshot bundled with the gem so the app is
|
|
14
|
+
# protected immediately, before the first remote update runs.
|
|
15
|
+
# Guarded so this migration still runs if the gem is ever removed.
|
|
16
|
+
Nondisposable::DomainListUpdater.seed if defined?(Nondisposable::DomainListUpdater)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
10
19
|
end
|
|
11
20
|
|
|
12
21
|
private
|
|
@@ -7,4 +7,13 @@ Nondisposable.configure do |config|
|
|
|
7
7
|
#
|
|
8
8
|
# Exclude domains that are considered disposable but you want to allow anyways
|
|
9
9
|
# config.excluded_domains = ['false-positive-domain.com']
|
|
10
|
+
#
|
|
11
|
+
# What to do when the disposable check itself fails (e.g. database hiccup):
|
|
12
|
+
# :allow - let the signup through and log an error (availability-first, default)
|
|
13
|
+
# :reject - block the signup with a validation error (fail closed)
|
|
14
|
+
# config.on_check_failure = :allow
|
|
15
|
+
#
|
|
16
|
+
# Also match parent domains: an email at x.tempmail.com is blocked when
|
|
17
|
+
# tempmail.com is on the list (checks up to 3 parent labels). Default: true
|
|
18
|
+
# config.check_parent_domains = true
|
|
10
19
|
end
|
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'open-uri'
|
|
4
3
|
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
5
|
|
|
6
6
|
module Nondisposable
|
|
7
7
|
class DomainListUpdater
|
|
8
8
|
|
|
9
|
+
LIST_URL = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf'
|
|
10
|
+
|
|
11
|
+
# Snapshot of the upstream blocklist vendored into the gem, used to seed
|
|
12
|
+
# fresh installs so they are protected before the first remote update.
|
|
13
|
+
BUNDLED_LIST_PATH = File.expand_path('../../data/disposable_email_blocklist.conf', __dir__)
|
|
14
|
+
|
|
15
|
+
# Network timeouts (seconds). Without these, a hung connection would block
|
|
16
|
+
# the calling job/thread indefinitely (Net::HTTP defaults to 60s open / 60s read).
|
|
17
|
+
OPEN_TIMEOUT = 10
|
|
18
|
+
READ_TIMEOUT = 10
|
|
19
|
+
|
|
9
20
|
def self.update
|
|
10
21
|
Rails.logger.info "[nondisposable] Refreshing list of disposable domains..."
|
|
11
22
|
|
|
12
|
-
url = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf'
|
|
13
|
-
|
|
14
23
|
begin
|
|
15
|
-
|
|
16
|
-
response = Net::HTTP.get_response(uri)
|
|
24
|
+
response = fetch_list
|
|
17
25
|
|
|
18
26
|
if response.is_a?(Net::HTTPSuccess)
|
|
19
27
|
downloaded_domains = response.body.split("\n")
|
|
@@ -21,29 +29,98 @@ module Nondisposable
|
|
|
21
29
|
|
|
22
30
|
Rails.logger.info "[nondisposable] Downloaded list of disposable domains..."
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
domains -= Nondisposable.configuration.excluded_domains
|
|
26
|
-
|
|
27
|
-
ActiveRecord::Base.transaction do
|
|
28
|
-
Rails.logger.info "[nondisposable] Updating disposable domains..."
|
|
29
|
-
Nondisposable::DisposableDomain.delete_all
|
|
30
|
-
|
|
31
|
-
domains.each { |domain| Nondisposable::DisposableDomain.create(name: domain.downcase) }
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
Rails.logger.info "[nondisposable] Finished updating disposable domains. Total domains: #{domains.count}"
|
|
32
|
+
replace_domains(downloaded_domains)
|
|
35
33
|
true
|
|
36
34
|
else
|
|
37
35
|
Rails.logger.error "[nondisposable] Failed to download the list. HTTP Status: #{response.code}"
|
|
36
|
+
seed
|
|
38
37
|
false
|
|
39
38
|
end
|
|
40
39
|
rescue SocketError => e
|
|
41
40
|
Rails.logger.error "[nondisposable] Network error occurred: #{e.message}"
|
|
41
|
+
seed
|
|
42
42
|
false
|
|
43
43
|
rescue StandardError => e
|
|
44
44
|
Rails.logger.error "[nondisposable] An error occurred when trying to update the list of disposable domains: #{e.message}"
|
|
45
|
+
seed
|
|
45
46
|
false
|
|
46
47
|
end
|
|
47
48
|
end
|
|
49
|
+
|
|
50
|
+
# Seeds the domain table from the blocklist snapshot bundled with the gem,
|
|
51
|
+
# but ONLY when the table is empty. This closes the fresh-install fail-open
|
|
52
|
+
# window where every email passed validation until the first successful
|
|
53
|
+
# remote update. Returns true if it seeded, false otherwise.
|
|
54
|
+
#
|
|
55
|
+
# The bundled list is a snapshot from the gem's release date — always run
|
|
56
|
+
# DomainListUpdater.update (e.g. via the provided job) to stay current.
|
|
57
|
+
def self.seed
|
|
58
|
+
return false unless Nondisposable::DisposableDomain.none?
|
|
59
|
+
|
|
60
|
+
Rails.logger.info "[nondisposable] Domain table is empty; seeding from the blocklist bundled with the gem..."
|
|
61
|
+
count = replace_domains(bundled_domains)
|
|
62
|
+
Rails.logger.info "[nondisposable] Seeded #{count} disposable domains from the bundled snapshot. Run Nondisposable::DomainListUpdater.update to fetch the latest list."
|
|
63
|
+
true
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
Rails.logger.error "[nondisposable] Could not seed from the bundled list: #{e.message}"
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.bundled_domains
|
|
70
|
+
File.readlines(BUNDLED_LIST_PATH, chomp: true)
|
|
71
|
+
end
|
|
72
|
+
private_class_method :bundled_domains
|
|
73
|
+
|
|
74
|
+
def self.fetch_list
|
|
75
|
+
uri = URI(LIST_URL)
|
|
76
|
+
Net::HTTP.start(
|
|
77
|
+
uri.host,
|
|
78
|
+
uri.port,
|
|
79
|
+
use_ssl: uri.scheme == 'https',
|
|
80
|
+
open_timeout: OPEN_TIMEOUT,
|
|
81
|
+
read_timeout: READ_TIMEOUT
|
|
82
|
+
) do |http|
|
|
83
|
+
http.get(uri.request_uri)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
private_class_method :fetch_list
|
|
87
|
+
|
|
88
|
+
# Atomically replaces the stored domain list with the given raw list,
|
|
89
|
+
# merged with `additional_domains` and minus `excluded_domains`.
|
|
90
|
+
# Reports (instead of silently swallowing) any rows the bulk insert skipped.
|
|
91
|
+
def self.replace_domains(raw_domains)
|
|
92
|
+
domains = normalize(raw_domains + Nondisposable.configuration.additional_domains)
|
|
93
|
+
domains -= normalize(Nondisposable.configuration.excluded_domains)
|
|
94
|
+
|
|
95
|
+
inserted_count = 0
|
|
96
|
+
|
|
97
|
+
ActiveRecord::Base.transaction do
|
|
98
|
+
Rails.logger.info "[nondisposable] Updating disposable domains..."
|
|
99
|
+
Nondisposable::DisposableDomain.delete_all
|
|
100
|
+
|
|
101
|
+
records = domains.map { |domain| { name: domain } }
|
|
102
|
+
Nondisposable::DisposableDomain.insert_all(records, record_timestamps: true) if records.any?
|
|
103
|
+
inserted_count = Nondisposable::DisposableDomain.count
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
skipped_count = domains.size - inserted_count
|
|
107
|
+
if skipped_count.positive?
|
|
108
|
+
skipped = domains - Nondisposable::DisposableDomain.where(name: domains).pluck(:name)
|
|
109
|
+
preview = skipped.take(20).join(', ')
|
|
110
|
+
preview += ", … (#{skipped.size - 20} more)" if skipped.size > 20
|
|
111
|
+
Rails.logger.warn "[nondisposable] Skipped #{skipped_count} row(s) during bulk insert (duplicate or conflicting values): #{preview}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Rails.logger.info "[nondisposable] Finished updating disposable domains. Total domains: #{inserted_count}"
|
|
115
|
+
inserted_count
|
|
116
|
+
end
|
|
117
|
+
private_class_method :replace_domains
|
|
118
|
+
|
|
119
|
+
# Downcases, strips whitespace (including stray \r from CRLF payloads),
|
|
120
|
+
# drops blank lines, and dedupes.
|
|
121
|
+
def self.normalize(domains)
|
|
122
|
+
domains.map { |domain| domain.to_s.strip.downcase }.reject(&:empty?).uniq
|
|
123
|
+
end
|
|
124
|
+
private_class_method :normalize
|
|
48
125
|
end
|
|
49
126
|
end
|
|
@@ -14,8 +14,12 @@ module ActiveModel
|
|
|
14
14
|
record.errors.add(attribute, options[:message] || Nondisposable.configuration.error_message)
|
|
15
15
|
end
|
|
16
16
|
rescue StandardError => e
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
if Nondisposable.configuration.on_check_failure == :reject
|
|
18
|
+
Rails.logger.error "[nondisposable] Nondisposable validation error: #{e.message} — rejecting #{attribute} (on_check_failure = :reject)"
|
|
19
|
+
record.errors.add(attribute, "is an invalid email address, cannot check if it's disposable")
|
|
20
|
+
else
|
|
21
|
+
Rails.logger.error "[nondisposable] Nondisposable validation error: #{e.message} — ALLOWING #{attribute} through without a disposable check (on_check_failure = :allow). Set config.on_check_failure = :reject to fail closed instead."
|
|
22
|
+
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
21
25
|
end
|
data/lib/nondisposable.rb
CHANGED
|
@@ -9,11 +9,17 @@ module Nondisposable
|
|
|
9
9
|
class Error < StandardError; end
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
|
-
|
|
12
|
+
attr_writer :configuration
|
|
13
|
+
|
|
14
|
+
# Lazily initialized so the gem works safely even when the host app never
|
|
15
|
+
# runs an initializer (previously a nil configuration made every check
|
|
16
|
+
# raise, which the validator surfaced as a validation error on all emails).
|
|
17
|
+
def configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
def self.configure
|
|
16
|
-
self.configuration ||= Configuration.new
|
|
17
23
|
yield(configuration)
|
|
18
24
|
end
|
|
19
25
|
|
|
@@ -25,12 +31,34 @@ module Nondisposable
|
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
class Configuration
|
|
34
|
+
ON_CHECK_FAILURE_MODES = [:allow, :reject].freeze
|
|
35
|
+
|
|
28
36
|
attr_accessor :error_message, :additional_domains, :excluded_domains
|
|
37
|
+
# 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).
|
|
40
|
+
attr_accessor :check_parent_domains
|
|
41
|
+
attr_reader :on_check_failure
|
|
29
42
|
|
|
30
43
|
def initialize
|
|
31
44
|
@error_message = "provider is not allowed"
|
|
32
45
|
@additional_domains = []
|
|
33
46
|
@excluded_domains = []
|
|
47
|
+
@on_check_failure = :allow
|
|
48
|
+
@check_parent_domains = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# What the validator does when the disposable check itself raises
|
|
52
|
+
# (e.g. the database is unavailable):
|
|
53
|
+
# :allow - let the record through and log an error (availability-first, default)
|
|
54
|
+
# :reject - add a validation error, blocking the record (fail closed)
|
|
55
|
+
def on_check_failure=(mode)
|
|
56
|
+
mode = mode.to_sym if mode.respond_to?(:to_sym)
|
|
57
|
+
unless ON_CHECK_FAILURE_MODES.include?(mode)
|
|
58
|
+
raise ArgumentError, "on_check_failure must be one of #{ON_CHECK_FAILURE_MODES.map(&:inspect).join(', ')} (got #{mode.inspect})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@on_check_failure = mode
|
|
34
62
|
end
|
|
35
63
|
end
|
|
36
64
|
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.3.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-09 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -41,6 +41,8 @@ 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
|
|
44
46
|
- gemfiles/rails_7.2.gemfile
|
|
45
47
|
- gemfiles/rails_8.0.gemfile
|
|
46
48
|
- gemfiles/rails_8.1.gemfile
|