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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d6da844eae687a1d72201694a880a5ec5f16dcbd5e498de792a19d421027e6b
4
- data.tar.gz: ea77c8f6ea5ddf4726f95b3438fadb7c7f0dedaed83bf7b674fdba78f95cbbed
3
+ metadata.gz: 691fa38ec02a652cd53aa77bd8fac7513553bfd5b0dac8ba0e224be41ff58fdd
4
+ data.tar.gz: f145924d4cb664331fc215ae0e562f2e4110ff04bf501ea52fddf5f7089de0f9
5
5
  SHA512:
6
- metadata.gz: af761f9347d7e630239973256600cb52996f166f8479908c2ee8d379cae8b46d2035455c4bc2dc3c305941d7fe393dcac634e8d6b287e95eeee4b8bf9271db64
7
- data.tar.gz: f58d7598f288515f684aaffc55a9399e01398fc9644b5744fd8e136510e4e51d358db12b4b33f99a58c7d06b5a05bd06a5313ff8e5f9f35e2d8ce95166791d38
6
+ metadata.gz: 97ce0386481ec1e70fa828ce3a18b0b2dce4a19855b34660c2abc782042d14512da0c77a7f09b6687a4f602a52d5c5d045ea8f9d9fa8f0e68a5123dbe067b36a
7
+ data.tar.gz: df2aaf590f850fe10fe9ee588861630721679f4bd2ae3bfb43a8cd6aab904bba2069cced44ce8fb60644e0f6ca21d679f3cf9593d1e90592a6f675029fcf8e0e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
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
+
1
23
  ## [0.2.1] - 2026-01-17
2
24
 
3
25
  - Add `[nondisposable]` prefix to all logger calls for better log identification
data/README.md CHANGED
@@ -43,7 +43,7 @@ This will create the necessary migration file, initializer, and a job for schedu
43
43
  rails db:migrate
44
44
  ```
45
45
 
46
- 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:
47
47
 
48
48
  ```ruby
49
49
  Nondisposable::DomainListUpdater.update
@@ -94,9 +94,28 @@ Nondisposable.configure do |config|
94
94
  config.error_message = "provider is not allowed. Please use a non-disposable email address."
95
95
  config.additional_domains = ['custom-disposable-domain.com']
96
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
97
106
  end
98
107
  ```
99
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
+
100
119
  ### Direct Check
101
120
 
102
121
  You can also check if an email is disposable directly:
@@ -114,6 +133,8 @@ To manually update the list of disposable domains, run:
114
133
  Nondisposable::DomainListUpdater.update
115
134
  ```
116
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
+
117
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.
118
139
 
119
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.
@@ -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
+ }