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.
@@ -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 populate the initial list of disposable domains."
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
- uri = URI(url)
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
- domains = (downloaded_domains + Nondisposable.configuration.additional_domains).uniq
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
- Rails.logger.error "[nondisposable] Nondisposable validation error: #{e.message}"
18
- record.errors.add(attribute, "is an invalid email address, cannot check if it's disposable")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nondisposable
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  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
- attr_accessor :configuration
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.2.1
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-01-17 00:00:00.000000000 Z
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