nondisposable 0.3.0 → 0.4.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: 691fa38ec02a652cd53aa77bd8fac7513553bfd5b0dac8ba0e224be41ff58fdd
4
- data.tar.gz: f145924d4cb664331fc215ae0e562f2e4110ff04bf501ea52fddf5f7089de0f9
3
+ metadata.gz: 9d2c949362fe7f9506247f3ae809ced6dca5a842086fa540346c8e89ea5dc5fd
4
+ data.tar.gz: a13fa7bec1531089fe68895529ecab5d9f05a2653292e2e39a1f4dfdeb818cf7
5
5
  SHA512:
6
- metadata.gz: 97ce0386481ec1e70fa828ce3a18b0b2dce4a19855b34660c2abc782042d14512da0c77a7f09b6687a4f602a52d5c5d045ea8f9d9fa8f0e68a5123dbe067b36a
7
- data.tar.gz: df2aaf590f850fe10fe9ee588861630721679f4bd2ae3bfb43a8cd6aab904bba2069cced44ce8fb60644e0f6ca21d679f3cf9593d1e90592a6f675029fcf8e0e
6
+ metadata.gz: 696ce476987cdd1a90008b8065a9f8af9d6c19035e42cbd3cb0b03cf9b36ec0bf3fd9158d474552a8d2729e4d454ec19ac7ee31e24af844f17a42e27346532bc
7
+ data.tar.gz: 1a220333975bbd3ba6229a29172e42b4e9c91ddc6cc4ee97aca417ab743c3756c5ff024f70d57963f0c7643378d4d1a4153135632d7f65cd4a96068bb3bb3dc0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [0.4.0] - 2026-08-25
2
+
3
+ Catching the other kind of bad address. A disposable address is someone hiding from you; a typo is someone who wanted to reach you and can't. `user@gmail.con` isn't a throwaway — it's a real person whose account nobody will ever be able to reach, because `.con` has never existed. Both new checks are **opt-in**: upgrading this gem will not start rejecting addresses that were fine yesterday.
4
+
5
+ ### Added
6
+
7
+ - **TLD validation** (`config.check_tld`, default `false`): rejects addresses whose TLD isn't in the IANA root zone. The gem now bundles a snapshot of [data.iana.org/TLD/tlds-alpha-by-domain.txt](https://data.iana.org/TLD/tlds-alpha-by-domain.txt) (~1,438 entries, ~9 KB) loaded once into a frozen `Set` — no migration, no table, no query per signup, and it can't be empty on a fresh install. A domain with no dot at all (`example@gmailmcom`) has no TLD and is rejected too; IP literals and unicode TLDs are deliberately left alone.
8
+ - **`config.additional_tlds`**: accept a TLD delegated after your installed version shipped, without waiting for a release. This is the escape hatch that keeps a stale snapshot from ever locking anyone out.
9
+ - **`Nondisposable::Tld::SPECIAL_USE`**: the RFC 6761 / RFC 2606 reserved names (`test`, `example`, `invalid`, `localhost`, `local`, `onion`). ⚠️ **Heads up when you switch `check_tld` on**: these are reserved so they can never be delegated, so they aren't in the root zone and are rejected — which is correct for a signup form, and will also turn your fixtures at `user@example.test` red. One line, scoped to where it belongs: `config.additional_tlds = Nondisposable::Tld::SPECIAL_USE if Rails.env.local?`
10
+ - **`config.blocked_tlds`** and **`config.allowed_tlds`**: refuse real TLDs you don't want (the free Freenom set `tk ml ga cf gq` is the usual reason), or invert it into an allowlist.
11
+ - **Lookalike detection**: `Nondisposable.suggestion_for("someone@gmial.com") # => "someone@gmail.com"`, matching against a bundled list of ~240 well-known providers using optimal string alignment distance, so an adjacent swap counts as the one mistake a human actually made. This catches what a TLD check structurally cannot — `.co`, `.cm` and `.om` are Colombia, Cameroon and Oman, all real. Always available, never blocks anything.
12
+ - **`config.reject_lookalike_domains`** (default `false`): turns that suggestion into a validation error naming the correction. Off by default on purpose — a suggestion is a guess about intent, and a wrong guess stops a real person signing up with their real address. `config.lookalike_distance` (default `1`) and `config.additional_email_providers` tune it.
13
+ - **`Nondisposable.valid_tld?(email)`** and **`Nondisposable.suggestion_for(email)`** as direct checks, alongside the existing `disposable?`.
14
+ - **`rake nondisposable:tlds:update`**: refreshes the bundled snapshot from IANA. A maintenance task for a checkout of this gem, not something host apps run — it refuses to overwrite a good list with an implausibly short one, writes atomically, and preserves IANA's version header (readable via `Nondisposable::Tld.version`).
15
+ - New error messages: `invalid_tld_error_message`, `blocked_tld_error_message`, `lookalike_error_message` (which interpolates `%{suggestion}`).
16
+
17
+ ### Changed
18
+
19
+ - The validator now adds **at most one error** however many checks fire, and prefers the most useful message available: whenever the gem can name a correction, that phrasing wins over the generic "doesn't look like a real email address". The disposable check still takes precedence over both — a correct spelling wouldn't help a throwaway provider.
20
+
1
21
  ## [0.3.0] - 2026-08-09
2
22
 
3
23
  ### Added
data/README.md CHANGED
@@ -17,6 +17,17 @@ That's it! You're done.
17
17
 
18
18
  The gem also provides a job you can run daily to keep your disposable domain list up to date.
19
19
 
20
+ It can also catch the other kind of bad address — the typo. `user@gmail.con` isn't a throwaway, it's a real person whose account nobody will ever be able to reach, because `.con` doesn't exist. Two opt-in checks:
21
+
22
+ ```ruby
23
+ Nondisposable.configure do |config|
24
+ config.check_tld = true # reject TLDs that aren't in the IANA root zone
25
+ config.reject_lookalike_domains = true # and addresses one keystroke from a real provider
26
+ end
27
+ ```
28
+
29
+ See [Catching typos](#catching-typos).
30
+
20
31
  ## Installation
21
32
 
22
33
  Add this line to your application's Gemfile:
@@ -103,6 +114,23 @@ Nondisposable.configure do |config|
103
114
  # Also match parent domains: an email at x.tempmail.com is blocked when
104
115
  # tempmail.com is on the list. Set to false for exact matches only.
105
116
  config.check_parent_domains = true
117
+
118
+ # --- Catching typos (both OFF by default) ---
119
+
120
+ # Reject addresses whose TLD isn't in the IANA root zone: gmail.con, outlook.ed
121
+ config.check_tld = true
122
+ config.additional_tlds = [] # accept a TLD newer than this gem
123
+ config.blocked_tlds = %w[tk ml ga cf gq] # refuse real TLDs you don't want
124
+ config.allowed_tlds = nil # or allowlist: %w[es com] rejects everything else
125
+
126
+ # Reject addresses one keystroke from a well-known provider: gmail.co, gmial.com
127
+ config.reject_lookalike_domains = false
128
+ config.lookalike_distance = 1 # edits that still count as a typo
129
+ config.additional_email_providers = [] # your own domains / regional providers
130
+
131
+ config.invalid_tld_error_message = "doesn't look like a real email address"
132
+ config.blocked_tld_error_message = "domain ending is not allowed"
133
+ config.lookalike_error_message = "looks like a typo. Did you mean %{suggestion}?"
106
134
  end
107
135
  ```
108
136
 
@@ -116,6 +144,81 @@ This is a deliberately minimal, dependency-free approximation of "registrable do
116
144
 
117
145
  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
146
 
147
+ ### Catching typos
148
+
149
+ A disposable address is someone hiding from you. A typo is someone who wanted to reach you and won't be able to. Both leave you with a useless row in the users table, so `nondisposable` can catch both — but the typo checks are **opt-in**, because upgrading a gem should never start rejecting addresses that were fine yesterday.
150
+
151
+ #### `config.check_tld` — is that a real domain ending?
152
+
153
+ Every TLD that exists is in the [IANA root zone database](https://data.iana.org/TLD/tlds-alpha-by-domain.txt), and the gem ships a snapshot of it (~1,438 entries, about 9 KB, loaded once into a frozen `Set`). `.con` has never been in it, and never will be.
154
+
155
+ ```ruby
156
+ config.check_tld = true
157
+
158
+ Nondisposable.valid_tld?("user@gmail.com") # => true
159
+ Nondisposable.valid_tld?("user@gmail.con") # => false
160
+ Nondisposable.valid_tld?("example@gmailmcom") # => false (no TLD at all)
161
+ ```
162
+
163
+ Details worth knowing:
164
+
165
+ - **No dot, no TLD.** `example@gmailmcom` models a typo observed in production: the dot was missed entirely. A domain with no TLD can't end in a real one, so it's rejected. If your app accepts single-label intranet addresses like `you@localhost`, leave this off for that model.
166
+ - **IP literals are left alone.** `user@192.168.0.1` and `user@[10.0.0.1]` are a format question, and a TLD check has no business answering it. Pair with `format:` if you care.
167
+ - **Unicode TLDs are never rejected.** IANA lists internationalised TLDs in punycode (`XN--FIQS8S`), and converting `例え.テスト` to that form needs an IDN library this gem deliberately doesn't depend on. Rather than reject every unicode address, it declines to judge them. Punycode-form addresses — what mail clients actually send — are checked normally.
168
+ - **A stale snapshot can't trap you.** If ICANN delegates a TLD after your gem version shipped, `config.additional_tlds = %w[newtld]` accepts it immediately, with no release to wait for.
169
+
170
+ > [!IMPORTANT]
171
+ > **`.test` and `.example` are rejected, and that will turn your test suite red.**
172
+ >
173
+ > RFC 6761 and RFC 2606 reserve `test`, `example`, `invalid` and `localhost` precisely so they can never be delegated — which is why they aren't in the root zone, and why your fixtures live at `user@example.test` in the first place. Rejecting them is right for a signup form (no human types `me@home.test`, and mail could never be delivered there), but it's a configuration question, not a bug:
174
+ >
175
+ > ```ruby
176
+ > # config/initializers/nondisposable.rb
177
+ > config.additional_tlds = Nondisposable::Tld::SPECIAL_USE if Rails.env.local?
178
+ > ```
179
+ >
180
+ > Scope it to your non-production environments, so production still refuses an address nobody could ever answer.
181
+
182
+ `blocked_tlds` refuses TLDs that are perfectly real but that you'd rather not see — the historically free Freenom set (`tk`, `ml`, `ga`, `cf`, `gq`) is the usual suspect. `allowed_tlds` inverts it into an allowlist; be careful, `%w[es]` turns away every `.com` customer you have.
183
+
184
+ #### `config.reject_lookalike_domains` — did they mean gmail.com?
185
+
186
+ A TLD check structurally **cannot** catch the most common typos, because `.co` (Colombia), `.cm` (Cameroon) and `.om` (Oman) are all real, delegated TLDs. `user@gmail.co` is a well-formed address at a real TLD and still almost always a finger that slipped off the `m`.
187
+
188
+ So the second check asks a different question: is this domain one edit away from a well-known provider, while not being one itself?
189
+
190
+ ```ruby
191
+ Nondisposable.suggestion_for("someone@gmial.com") # => "someone@gmail.com"
192
+ Nondisposable.suggestion_for("someone@gmail.co") # => "someone@gmail.com"
193
+ Nondisposable.suggestion_for("someone@gmail.com") # => nil
194
+ Nondisposable.suggestion_for("someone@mail.com") # => nil (a real provider)
195
+ ```
196
+
197
+ `suggestion_for` is always available and **never blocks anything** — show it as a hint and let the human decide. That's the gentler way to use this, and the one to reach for first:
198
+
199
+ ```erb
200
+ <% if (did_you_mean = Nondisposable.suggestion_for(@user.email)) %>
201
+ <p>Did you mean <%= did_you_mean %>?</p>
202
+ <% end %>
203
+ ```
204
+
205
+ Setting `reject_lookalike_domains = true` turns the same guess into a validation error naming the correction. Do that deliberately: a suggestion is a guess about intent, and a wrong guess stops a real person signing up with their real address. Two things keep that rare — matching stops at **one** edit by default (`lookalike_distance`), and an exact match against the bundled provider list always wins, which is why that list includes awkward pairs like `mail.com` (one insertion from `gmail.com`). Add anything we've missed with `config.additional_email_providers`; doing so both makes it a suggestion candidate and stops its users being told they mistyped.
206
+
207
+ The matching uses optimal string alignment distance rather than plain Levenshtein, so an adjacent swap counts as the one mistake a human actually made: `gmial.com` is distance 1, not 2.
208
+
209
+ #### How the two combine
210
+
211
+ At most one error is added, however many checks fire — and whenever the gem can name a correction, that phrasing wins:
212
+
213
+ | Address | `check_tld` only | with `reject_lookalike_domains` |
214
+ |---|---|---|
215
+ | `user@gmail.con` | "looks like a typo. Did you mean user@gmail.com?" | same |
216
+ | `user@zzz.con` | "doesn't look like a real email address" | same |
217
+ | `user@gmail.co` | accepted | "looks like a typo. Did you mean user@gmail.com?" |
218
+ | `user@tempmail.com` | "provider is not allowed" | same |
219
+
220
+ "Doesn't look like a real email address" is true but useless when we know what they meant, so a nameable correction always outranks the generic message.
221
+
119
222
  ### Direct Check
120
223
 
121
224
  You can also check if an email is disposable directly:
@@ -123,6 +226,8 @@ You can also check if an email is disposable directly:
123
226
  ```ruby
124
227
  Nondisposable.disposable?('user@example.com') # => false
125
228
  Nondisposable.disposable?('user@disposable-email.com') # => true
229
+ Nondisposable.valid_tld?('user@example.con') # => false
230
+ Nondisposable.suggestion_for('user@gmial.com') # => "user@gmail.com"
126
231
  ```
127
232
 
128
233
  ## Updating disposable domains
@@ -148,6 +253,24 @@ production:
148
253
  schedule: every day at 3am US/Pacific
149
254
  ```
150
255
 
256
+ ## Updating the TLD list
257
+
258
+ The TLD snapshot is the deliberate opposite of the disposable list: it ships **inside the gem**, not in your database, and there is nothing for your app to schedule. Disposable domains number in the thousands and change every few days; TLDs are ~1,438 strings that change a handful of times a year, so a frozen `Set` costs one file read at boot, answers in O(1) with no query per signup, needs no migration, and can't be empty on a fresh install.
259
+
260
+ If ICANN delegates a TLD your installed version doesn't know about, don't wait for a release — that's what `config.additional_tlds` is for.
261
+
262
+ Maintainers refresh the snapshot from a checkout of this gem:
263
+
264
+ ```bash
265
+ rake nondisposable:tlds:update # rewrites data/iana_tlds.txt from data.iana.org
266
+ ```
267
+
268
+ It refuses to overwrite a good list with an implausibly short one (a captive portal or a truncated body served with a `200`), writes to a temp file and moves it into place so an interrupted run can't leave half a root zone behind, and keeps IANA's own version header so you can always see which root zone you're shipping:
269
+
270
+ ```ruby
271
+ Nondisposable::Tld.version # => "2026082301, Last Updated Mon Aug 24 07:07:01 2026 UTC"
272
+ ```
273
+
151
274
  ## Troubleshooting
152
275
 
153
276
  ### SSL certificate verify failed (unable to get certificate CRL)
data/Rakefile CHANGED
@@ -3,6 +3,11 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
5
 
6
+ # nondisposable:tlds:update — refreshes data/iana_tlds.txt from the IANA root
7
+ # zone. A maintenance task for this checkout, not something host apps run; see
8
+ # the header of lib/nondisposable/tld_list_updater.rb.
9
+ Dir.glob("lib/tasks/*.rake").each { |task| load task }
10
+
6
11
  Rake::TestTask.new(:test) do |t|
7
12
  t.libs << "lib"
8
13
  t.libs << "test"
@@ -0,0 +1,276 @@
1
+ # Well-known consumer email domains, used by Nondisposable::Suggestion.
2
+ #
3
+ # This list does TWO jobs, and the second one is the reason it needs to be
4
+ # generous rather than short:
5
+ #
6
+ # 1. It supplies the candidates a typo can be corrected TO. A domain one edit
7
+ # away from an entry here is very probably a slip of the finger.
8
+ # 2. It is the false-positive guard. An EXACT match here is never flagged, so
9
+ # every real provider that happens to sit one edit from another real one
10
+ # must be present. `mail.com` is the canonical example: it is one insertion
11
+ # away from `gmail.com`, and leaving it out would tell every one of its
12
+ # users they had made a typo.
13
+ #
14
+ # Hosts add their own with `config.additional_email_providers`. Order is
15
+ # irrelevant; comments and blank lines are ignored.
16
+
17
+ # --- global ---
18
+ gmail.com
19
+ googlemail.com
20
+ outlook.com
21
+ hotmail.com
22
+ live.com
23
+ msn.com
24
+ passport.com
25
+ yahoo.com
26
+ ymail.com
27
+ rocketmail.com
28
+ icloud.com
29
+ me.com
30
+ mac.com
31
+ aol.com
32
+ mail.com
33
+ email.com
34
+ usa.com
35
+ gmx.com
36
+ gmx.net
37
+ gmx.de
38
+ gmx.at
39
+ gmx.ch
40
+ zoho.com
41
+ zohomail.com
42
+ fastmail.com
43
+ fastmail.fm
44
+ hushmail.com
45
+ protonmail.com
46
+ protonmail.ch
47
+ proton.me
48
+ pm.me
49
+ tutanota.com
50
+ tutanota.de
51
+ tuta.com
52
+ tuta.io
53
+ hey.com
54
+ mailfence.com
55
+ posteo.de
56
+ disroot.org
57
+ riseup.net
58
+ runbox.com
59
+ migadu.com
60
+ purelymail.com
61
+ skiff.com
62
+ duck.com
63
+
64
+ # --- Spain ---
65
+ hotmail.es
66
+ outlook.es
67
+ yahoo.es
68
+ live.com.es
69
+ telefonica.net
70
+ movistar.es
71
+ terra.es
72
+ terra.com
73
+ ya.com
74
+ wanadoo.es
75
+ orange.es
76
+ jazzfree.com
77
+ jazztel.es
78
+ euskaltel.net
79
+ euskalnet.net
80
+ ono.com
81
+ vodafone.es
82
+ telecable.es
83
+ mundo-r.com
84
+ airtel.net
85
+ navegalia.com
86
+ eresmas.com
87
+ inicia.es
88
+ teleline.es
89
+
90
+ # --- Portugal / Latin America ---
91
+ sapo.pt
92
+ netcabo.pt
93
+ clix.pt
94
+ uol.com.br
95
+ bol.com.br
96
+ ig.com.br
97
+ terra.com.br
98
+ globo.com
99
+ globomail.com
100
+ prodigy.net.mx
101
+ hotmail.com.ar
102
+ hotmail.com.mx
103
+ yahoo.com.ar
104
+ yahoo.com.mx
105
+ yahoo.com.br
106
+
107
+ # --- France ---
108
+ orange.fr
109
+ wanadoo.fr
110
+ free.fr
111
+ sfr.fr
112
+ laposte.net
113
+ bbox.fr
114
+ neuf.fr
115
+ aliceadsl.fr
116
+ club-internet.fr
117
+ numericable.fr
118
+ hotmail.fr
119
+ outlook.fr
120
+ yahoo.fr
121
+ live.fr
122
+
123
+ # --- Germany / Austria / Switzerland ---
124
+ web.de
125
+ t-online.de
126
+ freenet.de
127
+ arcor.de
128
+ 1und1.de
129
+ online.de
130
+ gmx.info
131
+ hotmail.de
132
+ outlook.de
133
+ yahoo.de
134
+ live.de
135
+ bluewin.ch
136
+ sunrise.ch
137
+ gmail.ch
138
+
139
+ # --- United Kingdom / Ireland ---
140
+ btinternet.com
141
+ sky.com
142
+ virginmedia.com
143
+ talktalk.net
144
+ ntlworld.com
145
+ blueyonder.co.uk
146
+ hotmail.co.uk
147
+ outlook.co.uk
148
+ yahoo.co.uk
149
+ live.co.uk
150
+ googlemail.co.uk
151
+ eircom.net
152
+
153
+ # --- Italy ---
154
+ libero.it
155
+ virgilio.it
156
+ alice.it
157
+ tiscali.it
158
+ tin.it
159
+ fastwebnet.it
160
+ hotmail.it
161
+ outlook.it
162
+ yahoo.it
163
+ live.it
164
+ pec.it
165
+
166
+ # --- Benelux / Nordics / other Europe ---
167
+ telenet.be
168
+ skynet.be
169
+ proximus.be
170
+ ziggo.nl
171
+ kpnmail.nl
172
+ home.nl
173
+ planet.nl
174
+ xs4all.nl
175
+ telia.com
176
+ telia.se
177
+ bredband.net
178
+ spray.se
179
+ hotmail.se
180
+ online.no
181
+ broadpark.no
182
+ tele2.no
183
+ elisanet.fi
184
+ luukku.com
185
+ suomi24.fi
186
+ seznam.cz
187
+ centrum.cz
188
+ wp.pl
189
+ o2.pl
190
+ onet.pl
191
+ interia.pl
192
+ gazeta.pl
193
+ abv.bg
194
+ mail.bg
195
+ freemail.hu
196
+ citromail.hu
197
+ t-online.hu
198
+
199
+ # --- Russia / Eastern Europe ---
200
+ mail.ru
201
+ inbox.ru
202
+ list.ru
203
+ bk.ru
204
+ internet.ru
205
+ yandex.ru
206
+ yandex.com
207
+ ya.ru
208
+ rambler.ru
209
+ ukr.net
210
+ i.ua
211
+ meta.ua
212
+
213
+ # --- Asia ---
214
+ qq.com
215
+ foxmail.com
216
+ 163.com
217
+ 126.com
218
+ yeah.net
219
+ sina.com
220
+ sina.cn
221
+ sohu.com
222
+ aliyun.com
223
+ naver.com
224
+ daum.net
225
+ hanmail.net
226
+ nate.com
227
+ kakao.com
228
+ docomo.ne.jp
229
+ ezweb.ne.jp
230
+ softbank.ne.jp
231
+ yahoo.co.jp
232
+ ybb.ne.jp
233
+ nifty.com
234
+ biglobe.ne.jp
235
+ so-net.ne.jp
236
+ rediffmail.com
237
+ sify.com
238
+
239
+ # --- North America ISPs ---
240
+ comcast.net
241
+ xfinity.com
242
+ verizon.net
243
+ att.net
244
+ sbcglobal.net
245
+ bellsouth.net
246
+ ameritech.net
247
+ pacbell.net
248
+ cox.net
249
+ charter.net
250
+ spectrum.net
251
+ earthlink.net
252
+ juno.com
253
+ netzero.net
254
+ optonline.net
255
+ roadrunner.com
256
+ rr.com
257
+ windstream.net
258
+ frontier.com
259
+ centurylink.net
260
+ shaw.ca
261
+ rogers.com
262
+ sympatico.ca
263
+ telus.net
264
+ videotron.ca
265
+ bell.net
266
+
267
+ # --- Oceania / Africa ---
268
+ bigpond.com
269
+ bigpond.net.au
270
+ optusnet.com.au
271
+ iinet.net.au
272
+ tpg.com.au
273
+ xtra.co.nz
274
+ webmail.co.za
275
+ telkomsa.net
276
+ mweb.co.za