nondisposable 0.1.0 → 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.
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.2.0"
7
+
8
+ group :test do
9
+ gem "appraisal"
10
+ gem "minitest", "~> 6.0"
11
+ gem "minitest-mock"
12
+ gem "minitest-reporters", "~> 1.6"
13
+ gem "webmock", "~> 3.19"
14
+ gem "sqlite3", "~> 2.1"
15
+ gem "simplecov", require: false
16
+ end
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.0.0"
7
+
8
+ group :test do
9
+ gem "appraisal"
10
+ gem "minitest", "~> 6.0"
11
+ gem "minitest-mock"
12
+ gem "minitest-reporters", "~> 1.6"
13
+ gem "webmock", "~> 3.19"
14
+ gem "sqlite3", "~> 2.1"
15
+ gem "simplecov", require: false
16
+ end
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.1.2"
7
+
8
+ group :test do
9
+ gem "appraisal"
10
+ gem "minitest", "~> 6.0"
11
+ gem "minitest-mock"
12
+ gem "minitest-reporters", "~> 1.6"
13
+ gem "webmock", "~> 3.19"
14
+ gem "sqlite3", "~> 2.1"
15
+ gem "simplecov", require: false
16
+ end
17
+
18
+ gemspec path: "../"
@@ -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,49 +1,126 @@
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
- def self.update
10
- Rails.logger.info "Refreshing list of disposable domains..."
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__)
11
14
 
12
- url = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf'
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
+
20
+ def self.update
21
+ Rails.logger.info "[nondisposable] Refreshing list of disposable domains..."
13
22
 
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")
20
28
  raise "The list is empty. This might indicate a problem with the format." if downloaded_domains.empty?
21
29
 
22
- Rails.logger.info "Downloaded list of disposable domains..."
23
-
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 "Updating disposable domains..."
29
- Nondisposable::DisposableDomain.delete_all
30
-
31
- domains.each { |domain| Nondisposable::DisposableDomain.create(name: domain.downcase) }
32
- end
30
+ Rails.logger.info "[nondisposable] Downloaded list of disposable domains..."
33
31
 
34
- Rails.logger.info "Finished updating disposable domains. Total domains: #{domains.count}"
32
+ replace_domains(downloaded_domains)
35
33
  true
36
34
  else
37
- Rails.logger.error "Failed to download the list. HTTP Status: #{response.code}"
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
- Rails.logger.error "Network error occurred: #{e.message}"
40
+ Rails.logger.error "[nondisposable] Network error occurred: #{e.message}"
41
+ seed
42
42
  false
43
43
  rescue StandardError => e
44
- Rails.logger.error "An error occurred when trying to update the list of disposable domains: #{e.message}"
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 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
@@ -4,10 +4,6 @@ module Nondisposable
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Nondisposable
6
6
 
7
- initializer "nondisposable.assets.precompile" do |app|
8
- app.config.assets.precompile += %w( nondisposable/application.css nondisposable/application.js )
9
- end
10
-
11
7
  config.generators do |g|
12
8
  g.test_framework :rspec
13
9
  g.fixture_replacement :factory_bot
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nondisposable
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/nondisposable.rb CHANGED
@@ -4,31 +4,61 @@ require_relative "nondisposable/version"
4
4
  require_relative "nondisposable/engine"
5
5
  require_relative "nondisposable/email_validator"
6
6
  require_relative "nondisposable/domain_list_updater"
7
+
7
8
  module Nondisposable
8
9
  class Error < StandardError; end
9
10
 
10
11
  class << self
11
- 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
12
20
  end
13
21
 
14
22
  def self.configure
15
- self.configuration ||= Configuration.new
16
23
  yield(configuration)
17
24
  end
18
25
 
19
26
  def self.disposable?(email)
20
27
  return false if email.nil? || !email.include?('@')
21
- domain = email.to_s.split('@').last.downcase
22
- DisposableDomain.disposable?(domain)
28
+ domain = email.to_s.split('@').last
29
+ return false if domain.nil? || domain.empty?
30
+ DisposableDomain.disposable?(domain.downcase)
23
31
  end
24
32
 
25
33
  class Configuration
34
+ ON_CHECK_FAILURE_MODES = [:allow, :reject].freeze
35
+
26
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
27
42
 
28
43
  def initialize
29
44
  @error_message = "provider is not allowed"
30
45
  @additional_domains = []
31
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
32
62
  end
33
63
  end
34
64
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nondisposable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-10-31 00:00:00.000000000 Z
10
+ date: 2026-08-09 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -33,11 +32,20 @@ executables: []
33
32
  extensions: []
34
33
  extra_rdoc_files: []
35
34
  files:
35
+ - ".simplecov"
36
+ - AGENTS.md
37
+ - Appraisals
36
38
  - CHANGELOG.md
39
+ - CLAUDE.md
37
40
  - LICENSE.txt
38
41
  - README.md
39
42
  - Rakefile
40
43
  - app/models/nondisposable/disposable_domain.rb
44
+ - context7.json
45
+ - data/disposable_email_blocklist.conf
46
+ - gemfiles/rails_7.2.gemfile
47
+ - gemfiles/rails_8.0.gemfile
48
+ - gemfiles/rails_8.1.gemfile
41
49
  - lib/generators/nondisposable/install_generator.rb
42
50
  - lib/generators/nondisposable/templates/create_nondisposable_disposable_domains.rb.erb
43
51
  - lib/generators/nondisposable/templates/disposable_email_domain_list_update_job.rb
@@ -46,7 +54,6 @@ files:
46
54
  - lib/nondisposable/domain_list_updater.rb
47
55
  - lib/nondisposable/email_validator.rb
48
56
  - lib/nondisposable/engine.rb
49
- - lib/nondisposable/railtie.rb
50
57
  - lib/nondisposable/version.rb
51
58
  - sig/nondisposable.rbs
52
59
  homepage: https://github.com/rameerez/nondisposable
@@ -57,7 +64,6 @@ metadata:
57
64
  homepage_uri: https://github.com/rameerez/nondisposable
58
65
  source_code_uri: https://github.com/rameerez/nondisposable
59
66
  changelog_uri: https://github.com/rameerez/nondisposable/blob/main/CHANGELOG.md
60
- post_install_message:
61
67
  rdoc_options: []
62
68
  require_paths:
63
69
  - lib
@@ -72,8 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
78
  - !ruby/object:Gem::Version
73
79
  version: '0'
74
80
  requirements: []
75
- rubygems_version: 3.5.22
76
- signing_key:
81
+ rubygems_version: 3.6.2
77
82
  specification_version: 4
78
83
  summary: Block disposable emails from signing up to your Rails app
79
84
  test_files: []
@@ -1,9 +0,0 @@
1
- module Nondisposable
2
- class Railtie < Rails::Railtie
3
- initializer 'nondisposable.add_validator' do
4
- ActiveSupport.on_load(:active_record) do
5
- include ActiveModel::Validations::NondisposableValidator
6
- end
7
- end
8
- end
9
- end