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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b423ab4d910c50439d17398fcb84d95013ecd20be9c281f540c7b4540f79cb22
4
- data.tar.gz: b09ff53a1ac0cf5d47f4b597e89f30e6522836d6fbdcd0bd3de4ed564edfa69f
3
+ metadata.gz: 691fa38ec02a652cd53aa77bd8fac7513553bfd5b0dac8ba0e224be41ff58fdd
4
+ data.tar.gz: f145924d4cb664331fc215ae0e562f2e4110ff04bf501ea52fddf5f7089de0f9
5
5
  SHA512:
6
- metadata.gz: 6506a82600a139b2ac20b2abb8e84e2215f08af55eacbb43fac4d4524b7feb3b338dfd4cc73e7808957bbc9243f9d72c90fa3c28bc4b248ece56ea6f7d240ba2
7
- data.tar.gz: d840467aa4befe4c934883cc18bfc33ba847889d4808dd94e633f84ef6a5c690b5c9b93e5d1fbc0a5e45daa5d1524f3534fce9db52aea3939a9ef01a490c18ef
6
+ metadata.gz: 97ce0386481ec1e70fa828ce3a18b0b2dce4a19855b34660c2abc782042d14512da0c77a7f09b6687a4f602a52d5c5d045ea8f9d9fa8f0e68a5123dbe067b36a
7
+ data.tar.gz: df2aaf590f850fe10fe9ee588861630721679f4bd2ae3bfb43a8cd6aab904bba2069cced44ce8fb60644e0f6ca21d679f3cf9593d1e90592a6f675029fcf8e0e
data/.simplecov ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SimpleCov configuration file (auto-loaded before test suite)
4
+ # This keeps test_helper.rb clean and follows best practices
5
+
6
+ SimpleCov.start do
7
+ # Use SimpleFormatter for terminal-only output (no HTML generation)
8
+ formatter SimpleCov::Formatter::SimpleFormatter
9
+
10
+ # Track coverage for the lib directory (gem source code)
11
+ add_filter "/test/"
12
+
13
+ # Track the lib and app directories
14
+ track_files "{lib,app}/**/*.rb"
15
+
16
+ # Enable branch coverage for more detailed metrics
17
+ enable_coverage :branch
18
+
19
+ # Set minimum coverage threshold to prevent coverage regression
20
+ minimum_coverage line: 90, branch: 90
21
+
22
+ # Disambiguate parallel test runs
23
+ command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
24
+ end
25
+
26
+ # Print coverage summary to terminal after tests complete
27
+ SimpleCov.at_exit do
28
+ SimpleCov.result.format!
29
+ puts "\n" + "=" * 60
30
+ puts "COVERAGE SUMMARY"
31
+ puts "=" * 60
32
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
33
+ puts "Branch Coverage: #{SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || 'N/A'}%"
34
+ puts "=" * 60
35
+ end
data/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
data/Appraisals ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Note: Rails < 7.2 is not compatible with Ruby 3.4
4
+ # (Logger became a bundled gem in Ruby 3.4, and only Rails 7.2+ handles this)
5
+ # See: https://stdgems.org/logger/
6
+
7
+ # Test against Rails 7.2 (minimum version compatible with Ruby 3.4)
8
+ appraise "rails-7.2" do
9
+ gem "rails", "~> 7.2.0"
10
+ end
11
+
12
+ # Test against Rails 8.0
13
+ appraise "rails-8.0" do
14
+ gem "rails", "~> 8.0.0"
15
+ end
16
+
17
+ # Test against Rails 8.1 (latest)
18
+ appraise "rails-8.1" do
19
+ gem "rails", "~> 8.1.2"
20
+ end
data/CHANGELOG.md CHANGED
@@ -1,4 +1,35 @@
1
- ## [Unreleased]
1
+ ## [0.3.0] - 2026-08-09
2
+
3
+ ### Added
4
+
5
+ - **Bundled seed list**: the gem now ships a snapshot of the upstream blocklist (8,201 domains) and `DomainListUpdater.seed` populates the table from it when (and only when) the table is empty. The install generator's migration seeds automatically, and `DomainListUpdater.update` falls back to seeding when the remote fetch fails against an empty table. This closes the fresh-install fail-open window where every signup passed until the first successful remote update.
6
+ - **Parent-domain matching** (`config.check_parent_domains`, default `true`): an email at `x.tempmail.com` is now blocked when `tempmail.com` is on the list. The check walks up to 3 parent labels and never matches bare TLDs. Set to `false` to restore exact-only matching.
7
+ - **Configurable failure mode** (`config.on_check_failure`, default `:allow`): controls what happens when the disposable check itself raises (e.g. database hiccup). `:allow` lets the record through with a loud error log (availability-first); `:reject` preserves the previous behavior and error message. Invalid values raise `ArgumentError`.
8
+
9
+ ### Changed
10
+
11
+ - **Behavior change**: a broken check no longer rejects signups by default (previously any `StandardError` during validation added an error to the record). Set `config.on_check_failure = :reject` to keep the old fail-closed behavior.
12
+ - **Behavior change**: subdomains of listed domains are now blocked by default (see parent-domain matching above). Set `config.check_parent_domains = false` for the old exact-only behavior.
13
+ - `DomainListUpdater` now uses 10s open/read timeouts on the fetch (previously Net::HTTP's 60s defaults) so a hung connection can't block the update job.
14
+ - Domain list updates use a single bulk `insert_all` instead of per-row `create`, and report any rows skipped by the unique index instead of silently swallowing them.
15
+ - Downloaded domains are normalized before insert: whitespace/CR stripped, blank lines dropped, case-insensitive dedupe. `additional_domains` and `excluded_domains` now match case-insensitively everywhere.
16
+ - An exact entry in `excluded_domains` always wins, so a specific subdomain can be allowlisted under a listed parent domain.
17
+ - `Nondisposable.configuration` is lazily initialized: apps without an initializer no longer fail every validation with "cannot check if it's disposable".
18
+
19
+ ### Removed
20
+
21
+ - Dead `require 'open-uri'` in the domain list updater.
22
+
23
+ ## [0.2.1] - 2026-01-17
24
+
25
+ - Add `[nondisposable]` prefix to all logger calls for better log identification
26
+
27
+ ## [0.2.0] - 2026-01-16
28
+
29
+ - Fixed `NoMethodError` when email is `"@"` or malformed (empty domain after split)
30
+ - Removed non-existent asset references from engine (`nondisposable/application.css` and `.js`)
31
+ - Removed buggy `railtie.rb` file that attempted to include a class instead of a module
32
+ - Added comprehensive Minitest 6 test suite with 256 tests and 90%+ line / branch coverage
2
33
 
3
34
  ## [0.1.0] - 2024-09-25
4
35
 
data/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # 🗑️ `nondisposable` - Block disposable email addresses from signing up to your Rails app
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/nondisposable.svg)](https://badge.fury.io/rb/nondisposable) [![Build Status](https://github.com/rameerez/nondisposable/workflows/Tests/badge.svg)](https://github.com/rameerez/nondisposable/actions)
4
+
5
+ > [!TIP]
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=nondisposable)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=nondisposable)!
7
+
3
8
  `nondisposable` is a Ruby gem that prevents users from signing up to your Rails app with disposable email addresses.
4
9
 
5
10
  Simply add to your User model:
@@ -38,7 +43,7 @@ This will create the necessary migration file, initializer, and a job for schedu
38
43
  rails db:migrate
39
44
  ```
40
45
 
41
- Finally, populate the initial list of disposable domains:
46
+ The migration also seeds the table from a snapshot of the disposable-domain list bundled with the gem, so your app is protected immediately. To fetch the very latest list, run:
42
47
 
43
48
  ```ruby
44
49
  Nondisposable::DomainListUpdater.update
@@ -89,9 +94,28 @@ Nondisposable.configure do |config|
89
94
  config.error_message = "provider is not allowed. Please use a non-disposable email address."
90
95
  config.additional_domains = ['custom-disposable-domain.com']
91
96
  config.excluded_domains = ['false-positive-domain.com']
97
+
98
+ # What to do when the disposable check itself fails (e.g. database hiccup):
99
+ # :allow - let the signup through and log an error (availability-first, default)
100
+ # :reject - block the signup with a validation error (fail closed)
101
+ config.on_check_failure = :allow
102
+
103
+ # Also match parent domains: an email at x.tempmail.com is blocked when
104
+ # tempmail.com is on the list. Set to false for exact matches only.
105
+ config.check_parent_domains = true
92
106
  end
93
107
  ```
94
108
 
109
+ #### Parent domain matching
110
+
111
+ With `check_parent_domains` enabled (the default since 0.3.0), `user@x.tempmail.com` is blocked when `tempmail.com` is on the list. The check walks up to 3 parent labels (`a.b.c.tempmail.com` → `b.c.tempmail.com` → `c.tempmail.com` → `tempmail.com`) and never matches against bare TLDs like `com`. All candidates are checked in a single indexed query.
112
+
113
+ This is a deliberately minimal, dependency-free approximation of "registrable domain" matching: the gem doesn't ship a full [public suffix list](https://publicsuffix.org), so it can't tell that `co.uk` is a public suffix — which is harmless in practice, because public suffixes don't appear on the blocklist. If you need to allowlist a specific subdomain under a listed parent, add the exact subdomain to `excluded_domains`; an exact exclusion always wins. Excluding a parent domain also unblocks its subdomains.
114
+
115
+ #### Failure mode
116
+
117
+ By default (`on_check_failure = :allow`), if the disposable check raises — say, the database is briefly unavailable — the signup goes through and an error is logged. This is availability-first: a broken check should not lock everyone out of signup. If you'd rather fail closed (reject signups whenever the check cannot run, as versions before 0.3.0 did), set `config.on_check_failure = :reject`.
118
+
95
119
  ### Direct Check
96
120
 
97
121
  You can also check if an email is disposable directly:
@@ -109,6 +133,8 @@ To manually update the list of disposable domains, run:
109
133
  Nondisposable::DomainListUpdater.update
110
134
  ```
111
135
 
136
+ The fetch uses 10-second open/read timeouts and replaces the whole table atomically. If the remote fetch fails and your table is empty (e.g. a fresh install without network access), the updater automatically seeds from the blocklist snapshot bundled with the gem so you're never left unprotected; you can also trigger that explicitly with `Nondisposable::DomainListUpdater.seed` (it only seeds an empty table, and never overwrites existing data).
137
+
112
138
  It's important you keep your disposable domain list up to date. `nondisposable` will read from the latest version of the [`disposable-email-domains`](https://github.com/disposable-email-domains/disposable-email-domains) list, which is typically updated every few days.
113
139
 
114
140
  For this, `nondisposable` provides you with an Active Job (`DisposableEmailDomainListUpdateJob`) that you can use to schedule daily updates. How you do that, exactly, depends on the queueing system you're using.
@@ -122,6 +148,38 @@ production:
122
148
  schedule: every day at 3am US/Pacific
123
149
  ```
124
150
 
151
+ ## Troubleshooting
152
+
153
+ ### SSL certificate verify failed (unable to get certificate CRL)
154
+
155
+ If you see this error when running `Nondisposable::DomainListUpdater.update`:
156
+
157
+ ```
158
+ SSL_connect returned=1 errno=0 peeraddr=[::1]:10011 state=error: certificate verify failed (unable to get certificate CRL) (OpenSSL::SSL::SSLError)
159
+ ```
160
+
161
+ This is **not** a bug in `nondisposable`. It's a known incompatibility between OpenSSL 3.6.0 and older versions of Ruby's `openssl` gem (3.3.0 and earlier).
162
+
163
+ The fix is to update the `openssl` gem to version 3.3.1 or later **in your Rails project**.
164
+
165
+ Add this to your Rails' project `Gemfile`:
166
+
167
+ ```ruby
168
+ gem "openssl", "~> 3.3.2"
169
+ ```
170
+
171
+ Then run:
172
+
173
+ ```bash
174
+ bundle install
175
+ ```
176
+
177
+ This issue is unlikely to occur in production, it's mostly a development-only issue. It's likely that the exact same codebase fails in development but works fine in production. It only occurs when you have OpenSSL 3.6.0 system-wide AND something intercepting HTTPS traffic (like Cursor's proxy). Users in production or using a regular terminal won't experience it.
178
+
179
+ This issue is more likely to occur if you're running your Rails console from within certain IDEs (like Cursor) that intercept HTTPS traffic through a local proxy. The updated `openssl` gem properly handles certificate verification in these environments.
180
+
181
+ For more details, see the [Ruby openssl gem issue](https://github.com/ruby/openssl/issues/949).
182
+
125
183
  ## Development
126
184
 
127
185
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -1,4 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "lib"
8
+ t.libs << "test"
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ t.warning = false
11
+ end
12
+
13
+ task default: %i[test]
@@ -4,12 +4,52 @@ module Nondisposable
4
4
  class DisposableDomain < ApplicationRecord
5
5
  validates :name, presence: true, uniqueness: { case_sensitive: false }
6
6
 
7
+ # How many leading labels the parent-domain walk strips when
8
+ # check_parent_domains is enabled: user@a.b.c.tempmail.com is checked as
9
+ # a.b.c.tempmail.com, b.c.tempmail.com, c.tempmail.com and tempmail.com.
10
+ #
11
+ # Trade-off (documented instead of pulling in a full public-suffix-list
12
+ # dependency): the walk never queries single-label suffixes (bare TLDs like
13
+ # "com"), but without a PSL it cannot tell that multi-label suffixes like
14
+ # "co.uk" are public — those candidates are harmless unless a public suffix
15
+ # is deliberately added to the list. Conversely, a throwaway address nested
16
+ # more than 3 labels below a listed domain escapes the walk; blocklisted
17
+ # domains are registrable domains, so 3 levels covers practical abuse.
18
+ PARENT_MATCH_DEPTH = 3
19
+
7
20
  class << self
8
21
  def disposable?(domain)
9
22
  return false if domain.blank?
23
+
10
24
  domain = domain.to_s.downcase
11
- Nondisposable.configuration.additional_domains.include?(domain) ||
12
- (where(name: domain).exists? && !Nondisposable.configuration.excluded_domains.include?(domain))
25
+ config = Nondisposable.configuration
26
+ candidates = match_candidates(domain)
27
+
28
+ additional = config.additional_domains.map { |d| d.to_s.downcase }
29
+ return true if (candidates & additional).any? # rubocop compat: Array#intersect? needs Ruby >= 3.1, gem supports 3.0
30
+
31
+ excluded = config.excluded_domains.map { |d| d.to_s.downcase }
32
+ # An exact exclusion always wins, so a specific subdomain can be
33
+ # allowlisted even when a parent domain is on the list.
34
+ return false if excluded.include?(domain)
35
+
36
+ where(name: candidates - excluded).exists?
37
+ end
38
+
39
+ # The exact domain plus up to PARENT_MATCH_DEPTH parent domains,
40
+ # never descending below two labels (bare TLDs are never candidates).
41
+ def match_candidates(domain)
42
+ return [domain] unless Nondisposable.configuration.check_parent_domains
43
+
44
+ labels = domain.split('.')
45
+ candidates = [domain]
46
+ 1.upto(PARENT_MATCH_DEPTH) do |depth|
47
+ parent = labels.drop(depth)
48
+ break if parent.size < 2
49
+
50
+ candidates << parent.join('.')
51
+ end
52
+ candidates
13
53
  end
14
54
  end
15
55
 
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/nondisposable",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }