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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 691fa38ec02a652cd53aa77bd8fac7513553bfd5b0dac8ba0e224be41ff58fdd
|
|
4
|
+
data.tar.gz: f145924d4cb664331fc215ae0e562f2e4110ff04bf501ea52fddf5f7089de0f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
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