nondisposable 0.4.0 → 0.6.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +185 -6
  4. data/app/models/nondisposable/disposable_domain.rb +53 -34
  5. data/app/models/nondisposable/source_snapshot.rb +66 -0
  6. data/data/known_typo_domains.txt +99 -0
  7. data/data/never_disposable.txt +480 -0
  8. data/data/privacy_relays.txt +42 -0
  9. data/data/public_suffix_list.dat +16571 -0
  10. data/gemfiles/rails_7.2.gemfile +1 -0
  11. data/gemfiles/rails_8.0.gemfile +1 -0
  12. data/gemfiles/rails_8.1.gemfile +1 -0
  13. data/lib/generators/nondisposable/install_generator.rb +1 -0
  14. data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
  15. data/lib/generators/nondisposable/templates/nondisposable.rb +29 -7
  16. data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
  17. data/lib/nondisposable/confirmable_email.rb +75 -0
  18. data/lib/nondisposable/domain.rb +238 -0
  19. data/lib/nondisposable/domain_list_updater.rb +510 -88
  20. data/lib/nondisposable/email_validator.rb +51 -4
  21. data/lib/nondisposable/known_typos.rb +64 -0
  22. data/lib/nondisposable/precedence.rb +209 -0
  23. data/lib/nondisposable/public_suffix.rb +147 -0
  24. data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
  25. data/lib/nondisposable/source.rb +233 -0
  26. data/lib/nondisposable/source_fetcher.rb +69 -0
  27. data/lib/nondisposable/source_merge.rb +127 -0
  28. data/lib/nondisposable/suggestion.rb +201 -31
  29. data/lib/nondisposable/tld.rb +8 -3
  30. data/lib/nondisposable/version.rb +1 -1
  31. data/lib/nondisposable.rb +68 -13
  32. data/lib/tasks/nondisposable.rake +29 -0
  33. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d2c949362fe7f9506247f3ae809ced6dca5a842086fa540346c8e89ea5dc5fd
4
- data.tar.gz: a13fa7bec1531089fe68895529ecab5d9f05a2653292e2e39a1f4dfdeb818cf7
3
+ metadata.gz: 9d0c0a8ad776c00c83c1e8b1e3001c652c5e5b414bb8f79216d3544192843e42
4
+ data.tar.gz: f2ab66119b84f495ce62d2104ab1c05701441796a969ae793e0fe033c69e6447
5
5
  SHA512:
6
- metadata.gz: 696ce476987cdd1a90008b8065a9f8af9d6c19035e42cbd3cb0b03cf9b36ec0bf3fd9158d474552a8d2729e4d454ec19ac7ee31e24af844f17a42e27346532bc
7
- data.tar.gz: 1a220333975bbd3ba6229a29172e42b4e9c91ddc6cc4ee97aca417ab743c3756c5ff024f70d57963f0c7643378d4d1a4153135632d7f65cd4a96068bb3bb3dc0
6
+ metadata.gz: 5d2c0d50d9296c92465713fcdf8c97ddfcb3f9b4536ff299b80687f4d0ea12c877a718511bd340d3f1bba897c3e1cc38bdd65ee10c9a3c6f37272cffd3a376cb
7
+ data.tar.gz: bd6983052fe4a8980a041c99f8aa7a4c41b35c947b5de246d39b10cdbf20c2ad36b2114dd1dad6b481388bcff02a4712977d73bf90c7d132bd0cbd594a5dc245
data/CHANGELOG.md CHANGED
@@ -1,3 +1,56 @@
1
+ ## [0.6.0] - 2026-10-17
2
+
3
+ Ships everything listed under 0.5.0 too: 0.5.0 was never released on its own.
4
+
5
+ More than one blocklist, without trusting any of them more than it has earned. Triggered by a real incident: signups from `mail-burn.com`, a live 10-minute inbox the default list did not carry. The measurement behind every default is in `docs/evidence/2026-10-17-multi-source-measurement.md`. With the default configuration you still get the one list you had before.
6
+
7
+ ### Added
8
+
9
+ - **Multiple blocklist sources** (`config.sources`, default `Nondisposable::Sources::DEFAULT`, which is today's single list). Each `Nondisposable::Source` has a `trust` (`:primary` blocks everything it lists; `:corroborating` needs `config.corroboration_threshold`, default 2, independent origins) and declares its `derived_from` lineage, so a list that copies another never counts as a second vote. Measured sources ship as constants (`DISPOSABLE_EMAIL_DOMAINS`, `FAKEFILTER`, `DISPOSABLE`, `MAILCHECKER`, `GROUNDCAT`, `WESBOS`) with licences and lineage.
10
+ - **`Nondisposable::Sources::RECOMMENDED`**: the default list plus fakefilter, both primary. +3,275 domains on 2026-10-17, none of them a known real provider. It does **not** include disposable/disposable as a primary; see the README for the 84,341 domains, real mail providers among them, that would add.
11
+ - **Source snapshots** (`nondisposable_source_snapshots`): every fetch of every source is recorded (status, time, sha256, count, list), and the stored list is rebuilt from each source's last good snapshot in one transaction, under a database advisory lock on PostgreSQL/MySQL (process lock on SQLite). Seeding takes the same lock.
12
+ - **`rails generate nondisposable:upgrade`**: an additive migration for existing installs. Until it is migrated (or after rolling it back), the updater keeps the single-source behaviour, so deploying before migrating is safe. Fresh installs get the table from `nondisposable:install`.
13
+ - **Update guards**: streamed size cap (`max_bytes`, 8 MB) and wall-clock `deadline` (30 s) per source; no redirects followed; https only; a snapshot is refused when over 1% of its lines are not domains, when it is below `minimum_count`, when it is under half the largest snapshot still retained or over three times the last good one, or when it equals a rolled-back snapshot. Counts are of unique canonical domains. Additions and removals are logged per source. Sizes count storable domains only (no junk, public suffixes or real providers). The list is never published empty; a never-fetched default source stands in its bundled list, and any other never-fetched primary blocks publishing. Rolled-back payloads stay refused permanently; failures never prune the last good snapshots.
14
+ - **Concurrency limits (documented):** on SQLite the publish lock is per process, so run the refresh job from a single runner. During a rolling deploy from 0.5, pause the refresh job until every process runs 0.6 (old processes replace the table without the lock).
15
+ - **`DomainListUpdater.reset_baseline(source_name)`**: accept a genuine upstream shrink below half of the source's high-water mark.
16
+ - **`DomainListUpdater.publish`** (rebuild from snapshots without downloading, e.g. after a config change) and **`DomainListUpdater.rollback(source_name)`** (withdraw a source's newest snapshot and republish).
17
+ - **Public Suffix List guard**: a bundled snapshot of publicsuffix.org's list (MPL 2.0, unmodified) and `Nondisposable::PublicSuffix`. Public suffixes (`com.br`, `co.uk`, `it.com`, `ddns.net`) are never stored, never accepted from `additional_domains`, and never used as a parent-match candidate, so one bad upstream line cannot block a whole namespace. Refresh with `rake nondisposable:psl:update`.
18
+ - **`data/never_disposable.txt`**: real providers and ISPs that upstream lists were found to carry, plus paid mailbox providers; unioned at runtime with `data/email_providers.txt`. Each entry protects its subdomains (`bak.rr.com` under `rr.com`). An upstream source listing one is logged as possible poisoning.
19
+ - **Privacy relays as a policy** (`config.block_privacy_relays`, default `false`, list in `data/privacy_relays.txt`): Hide My Email, DuckDuckGo, Firefox Relay, SimpleLogin/Proton Pass and addy.io are allowed even when a source lists them, or refused even when none does.
20
+ - **`Nondisposable::Domain.normalize`**: one canonical form (stripped, downcased, no trailing dot, IDN to punycode) used by the checks and by list ingestion. `Bücher.de.` and `xn--bcher-kva.de` are the same domain.
21
+
22
+ ### Changed
23
+
24
+ - ⚠️ **`DisposableDomain.disposable?` / `Nondisposable.disposable?` answer `true` for a name longer than DNS allows** (`Domain.too_long?`: over 253 characters or a label over 63 once canonical, or over 1,024 code points of raw input after stripping invisible characters). Such a name can never be deliverable, and a check that keeps junk out must not call it fine. Names exactly at the limits are judged normally. The validator rejects them earlier with `invalid_tld_error_message`.
25
+ - ⚠️ **Precedence**: one table, applied both when checking and when building the list: `excluded_domains` > `never_disposable` > `additional_domains` > privacy-relay policy > sources. Before, `additional_domains` was checked first, so a domain in both `additional_domains` and `excluded_domains` was **blocked**; it is now **allowed**. `additional_domains` can no longer block a real provider listed in `never_disposable` (ignored with a warning).
26
+ - ⚠️ Two domains the default upstream list carries are **no longer blocked**: `sify.com` (Indian ISP) and `21cn.com` (China Telecom webmail).
27
+ - Entries that cannot be DNS names (labels over 63 characters, names over 253, IP addresses, HTML) are dropped from downloaded lists. The single-source path also refuses a payload with more than 1% such lines instead of storing it.
28
+ - ⚠️ **The parent-domain walk has no depth cap any more**: it checks every parent down to the registrable domain, so `a.b.c.d.tempmail.com` is blocked when `tempmail.com` is listed (or in `additional_domains`). It used to stop after 3 parent labels, which let whoever controls a listed domain escape with a fourth. Still a single `WHERE name IN (...)` query; DNS caps a name at 253 characters. It never climbs above the registrable domain, so a public suffix (`co.uk`), or a name above a wildcard suffix (`sch.uk`), can never match. `DisposableDomain::PARENT_MATCH_DEPTH` is kept but unused.
29
+ - The table is updated by writing only the difference (still in one transaction), instead of deleting and reinserting every row.
30
+
31
+ ## [0.5.0] - Not released separately (shipped in 0.6.0)
32
+
33
+ Lookalike detection you can actually switch on. Replaying the 0.4.0 rule ("one edit from a provider") against the Tranco top 1M flagged 707 domains, and many are large real organisations whose staff would have been refused at signup: `ge.com`, `ml.com`, `mi.com`, `se.com` → `me.com`; `ea.com`, `aa.com` → `ya.com`; `bol.com`, `aon.com`, `uol.com` → `aol.com`; `one.com` → `ono.com`; `hive.com`, `life.com` → `live.com`; `grail.com`, `pmail.com` → `gmail.com`; `web.dev` → `web.de`; `yahoo.com.au` → `yahoo.com.ar`. Offline, nothing can tell those apart from `gmial.com` with certainty, so a guess no longer refuses anybody on its own.
34
+
35
+ ### Changed
36
+
37
+ - **Behavior change for `reject_lookalike_domains = true`: it is now softer, never stricter.** Precedence, first match wins: an exact provider is accepted → an impossible TLD with a nameable correction (`gmail.con`, `gmailmcom`) is **refused**, whether or not `check_tld` is on → a reviewed typo from the new bundled `data/known_typo_domains.txt` (41 entries, e.g. `gmial.com`, `hotmial.com`, `yaho.com`) is **refused** → any other lookalike gets the same "Did you mean …?" error, but **confirmable**: it is skipped when the record says the user confirmed the address as typed. Disposable providers are still refused first, blocked TLDs are still refused after a confirmation, and a host's `additional_email_providers` outranks the known-typo list.
38
+ - **`Nondisposable.suggestion_for` suggests less, and better.** Provider names of four characters or fewer (`me`, `ya`, `aol`, `mail`, `live`…) only take edits after the name, and every edit must look like a finger slip on QWERTY (swap, dropped letter other than the first, doubled letter, extra vowel, neighbouring key, accented same key). On a retrospective 30% holdout of the labelled corpus (split chosen before tuning but committed with the tuned filters, so not pre-registered): precision 8% → 45%, recall 81% of reviewed typos. Reviewed typos are always corrected, even where the ranker alone would stay silent (`jmail.com`).
39
+ - `lookalike_distance = 0` now switches off the whole lookalike layer, known-typo list included.
40
+ - The confirmable error is added with type `:lookalike_domain` (and `suggestion:` in its details) so a form can tell it apart: `errors.of_kind?(:email, :lookalike_domain)`. The message is unchanged and still names the correction; hard lookalike errors keep their plain-message form.
41
+ - Domains are normalised the same way on every path (`Nondisposable.disposable?`, `additional_domains` / `excluded_domains`, the validator, the TLD check, suggestions, the provider and known-typo lists): stripped, lowercased, trailing dot removed, fullwidth and ideographic dots read as dots, NFKC plus the invisible characters UTS #46 drops (soft hyphen, zero-width space), unicode labels converted to punycode. `x@mailinator.com` and `x@foo.tk` no longer slip past their list entries. `user@tempmail.com.` and `user@bücher.de` (listed as `xn--bcher-kva.de`) are now caught. The local part is never touched.
42
+ - **Stored rows and 0.4.0 compatibility.** List rows are stored in the canonical (punycode) form, and lookups also match a unicode row written by 0.4.0, under the same exclusion rules (a canonical `excluded_domains` entry still wins over a legacy `bücher.de` row and its subdomains). So that a mixed-version deploy, or a rollback to 0.4.0, never silently unblocks anything, a domain added in unicode through `additional_domains` is stored **twice**: `xn--bcher-kva.de` and `bücher.de`. The row count includes these twins. ⚠️ **Rollback to 0.4.0: unicode entries from downloaded lists are stored only in punycode, so a 0.4.0 reader would not match them; no current source ships raw unicode** (all six measured lists send IDNs as `xn--`, 2026-10-17). (A 0.4.0 reader also matches `additional_domains` in memory, so the twin row matters mostly for a rollback in which the config changed too.)
43
+ - Normalisation is bounded on hostile input. NFKC runs only on names of up to 1,024 code points, and not on a run of more than 30 combining marks (not stream-safe, UAX #15). The DNS limits (253 per name, 63 per label) apply to the **normalised** result, so a valid label typed in decomposed form (`e` + combining acute) still matches.
44
+ - **Behavior change: a domain longer than DNS allows is refused** by the validator, with `invalid_tld_error_message`, whatever is switched on: over 253 characters, a label over 63 (judged on the punycode form), or over 1,024 code points before normalisation. No mail can reach such an address and no list can hold it, so it can never be "not disposable". Invisible padding (soft hyphens and the like) is stripped first, so it can neither trigger this nor hide a listed domain. Values without an `@` are still left to `format:`. `Nondisposable::Domain.too_long?(domain)` exposes the check.
45
+
46
+ ### Added
47
+
48
+ - **`config.reject_lookalike_domains = :confirmable`**, the explicit name for the mode above. Strings (`"confirmable"`, `"true"`, `"false"`) are accepted for YAML/ENV config; any other value raises `ArgumentError` at boot instead of silently meaning "on".
49
+ - **`Nondisposable::ConfirmableEmail`**: `include` it and call `confirmable_email :email` to get a virtual, boolean-cast `email_lookalike_confirmed` attribute for a "Yes, my email is right" checkbox. It is reset after every save, so a later lookalike is asked about again. Without the concern, any `<attribute>_lookalike_confirmed` method returning exactly `true` works.
50
+ - **`Nondisposable::KnownTypos`** and `data/known_typo_domains.txt`: the only lookalikes refused outright, each with direct evidence that nobody has a mailbox there (redirect to the provider or the provider's own DNS, parking/for-sale network, typosquat redirect or catch-all, or registered with no MX, A or AAAA record, all lookups answered NOERROR). The evidence is in `test/fixtures/lookalike_corpus.csv` (713 rows: 44 typo, 559 real, 110 uncertain), built by `script/collect_lookalike_evidence.rb` and `script/label_lookalike_corpus.rb`; the suite fails if an entry loses its evidence row or if any non-typo row can be refused.
51
+ - **`Nondisposable::Domain.normalize` / `.from_email`**: the shared canonical form, including a zero-dependency RFC 3492 punycode encoder.
52
+ - `Nondisposable::Suggestion.guess(domain)`: the ranker alone, without the reviewed list.
53
+
1
54
  ## [0.4.0] - 2026-08-25
2
55
 
3
56
  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.
data/README.md CHANGED
@@ -115,6 +115,11 @@ Nondisposable.configure do |config|
115
115
  # tempmail.com is on the list. Set to false for exact matches only.
116
116
  config.check_parent_domains = true
117
117
 
118
+ # --- Where the blocklist comes from (see "Blocklist sources") ---
119
+ config.sources = Nondisposable::Sources::DEFAULT # or ::RECOMMENDED
120
+ config.corroboration_threshold = 2 # independent lists a :corroborating entry needs
121
+ config.block_privacy_relays = false # Hide My Email, DuckDuckGo, SimpleLogin, addy.io…
122
+
118
123
  # --- Catching typos (both OFF by default) ---
119
124
 
120
125
  # Reject addresses whose TLD isn't in the IANA root zone: gmail.con, outlook.ed
@@ -136,9 +141,28 @@ end
136
141
 
137
142
  #### Parent domain matching
138
143
 
139
- 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.
144
+ 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 every parent down to the registrable domain (`a.b.c.d.tempmail.com` → … → `d.tempmail.com` → `tempmail.com`), however deep, and never matches against a public suffix like `com` or `co.uk`. All candidates are checked in a single indexed query. (Until 0.6.0 the walk stopped after 3 parent labels, so whoever controlled a listed domain could escape it with a fourth.)
145
+
146
+ Since 0.6.0 the walk also never matches a **public suffix** — `co.uk`, `com.br`, `github.io` — using a snapshot of the [Public Suffix List](https://publicsuffix.org) bundled with the gem (no runtime dependency, no network). Upstream lists really do carry entries like `it.com` and `ddns.net`, under which anybody can register a name; one such line would otherwise refuse every unrelated company below it. Public suffixes are also never stored and never accepted from `additional_domains`.
147
+
148
+ 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 (unless a more specific subdomain is itself listed).
140
149
 
141
- 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.
150
+ #### Who wins when lists disagree
151
+
152
+ One precedence table, applied both when an address is checked and when the stored list is built. Top wins:
153
+
154
+ | # | List | Effect |
155
+ |---|---|---|
156
+ | 1 | `config.excluded_domains` (yours) | allowed |
157
+ | 2 | `data/never_disposable.txt` (bundled: real providers and ISPs, plus `data/email_providers.txt`) | allowed, including subdomains |
158
+ | 3 | `config.additional_domains` (yours) | blocked |
159
+ | 4 | `data/privacy_relays.txt` (bundled) | `config.block_privacy_relays` decides |
160
+ | 5 | the upstream sources | blocked |
161
+
162
+ > [!IMPORTANT]
163
+ > **Changed in 0.6.0:** before, `additional_domains` was checked first, so a domain in both `additional_domains` and `excluded_domains` was blocked. Now your exclusion wins. And because row 2 outranks row 3, `additional_domains` can no longer block a real provider such as `gmail.com` or `mail.ru` (it is ignored with a warning in the log): that list is for throwaway domains the upstream lists missed, and refusing a whole real provider is a business rule that belongs in your own validation, where it can tell the user why.
164
+
165
+ Row 2 is why upgrading also **unblocks** two domains the default upstream list carried on 2026-10-17: `sify.com` (an Indian ISP) and `21cn.com` (China Telecom's webmail). A false positive is a real person who cannot sign up; if you find one, it belongs in `never_disposable.txt` (open a PR) or in your `excluded_domains`.
142
166
 
143
167
  #### Failure mode
144
168
 
@@ -202,10 +226,64 @@ Nondisposable.suggestion_for("someone@mail.com") # => nil (a real provider)
202
226
  <% end %>
203
227
  ```
204
228
 
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
229
  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
230
 
231
+ One edit is not enough to tell a typo from a real domain, though. Replayed against the [Tranco](https://tranco-list.eu) top 1M, "one edit from a provider" flags **707** domains, and many of them are large real organisations:
232
+
233
+ - `ge.com` (General Electric), `ml.com` (Merrill Lynch), `mi.com` (Xiaomi), `se.com` (Schneider Electric) → `me.com`
234
+ - `ea.com` (Electronic Arts), `aa.com` (American Airlines) → `ya.com`
235
+ - `bol.com`, `aon.com`, `uol.com` (a major Brazilian mail provider) → `aol.com`
236
+ - `one.com` → `ono.com`; `hive.com`, `life.com` → `live.com`; `grail.com`, `pmail.com` → `gmail.com`
237
+ - `web.dev` → `web.de`; `yahoo.com.au` → `yahoo.com.ar`
238
+
239
+ So since 0.5.0 suggestions are filtered before anyone sees them: a provider name of four characters or fewer (`me`, `ya`, `aol`, `mail`, `live`…) only takes edits after the name (`me.cmo` yes, `ge.com` no), and every edit has to look like a finger — a swap, a dropped letter (not the first), a doubled letter, an extra vowel, a neighbouring key on QWERTY, or the same key with an accent. A letter from across the keyboard (`grail`, `pmail`, `.au` for `.ar`) is a different word, not a slip. On a 30% holdout of the labelled corpus that keeps 13 of 16 reviewed typos and raises precision from 8% to 45% — measured retrospectively: the split was chosen before tuning but only committed with the tuned filters, so treat it as an estimate, not a pre-registered result. Better, still a guess — which is why a guess never refuses anyone on its own.
240
+
241
+ #### `reject_lookalike_domains`: hard for known typos, confirmable for everything else
242
+
243
+ ```ruby
244
+ config.reject_lookalike_domains = :confirmable # `true` means the same thing
245
+ ```
246
+
247
+ | The domain is… | Example | Result |
248
+ |---|---|---|
249
+ | an exact provider (bundled or `additional_email_providers`) | `gmail.com`, `mail.com` | accepted |
250
+ | an impossible TLD with a nameable correction (`check_tld` on or not) | `gmail.con`, `gmailmcom` | **refused**: "Did you mean …gmail.com?" |
251
+ | a reviewed typo in [`data/known_typo_domains.txt`](data/known_typo_domains.txt) | `gmial.com`, `hotmial.com`, `yaho.com` | **refused**: "Did you mean …?" |
252
+ | any other lookalike the ranker suggests | `gnail.com` | **confirmable**: same message, unless the user confirmed the address |
253
+ | anything else | `ge.com`, `yahoo.com.au` | accepted |
254
+
255
+ A disposable provider is still refused first, and a blocked TLD is still refused after a confirmation. A host's `additional_email_providers` outranks the known-typo list.
256
+
257
+ The known-typo list is short (41 entries) on purpose. An entry needs direct evidence that nobody has a mailbox there — a redirect to the provider or the provider's own DNS (`yaho.com`, `homail.com`), a parking or for-sale network (`gamail.com`, `hotmial.com`), a typosquat redirect or catch-all (`gmial.com`, `gmali.com`), or a registered domain with no MX, A or AAAA record (`gmal.com`). "No MX" alone is not enough: SMTP falls back to the A record. The evidence for every entry is in `test/fixtures/lookalike_corpus.csv`, and the suite fails if an entry loses it.
258
+
259
+ **Confirmable means the user can say "yes, this address is right".** Give the model somewhere to put that answer:
260
+
261
+ ```ruby
262
+ class User < ApplicationRecord
263
+ include Nondisposable::ConfirmableEmail
264
+ confirmable_email :email # adds a virtual email_lookalike_confirmed attribute
265
+
266
+ validates :email, nondisposable: true
267
+ end
268
+ ```
269
+
270
+ ```erb
271
+ <%# Only when the confirmable error is showing: %>
272
+ <% if @user.errors.of_kind?(:email, :lookalike_domain) %>
273
+ <%= f.check_box :email_lookalike_confirmed %>
274
+ <%= f.label :email_lookalike_confirmed, "Yes, my email address is right" %>
275
+ <% end %>
276
+ ```
277
+
278
+ Permit `:email_lookalike_confirmed` in your strong parameters. It's virtual — never persisted, and reset after every save — so it answers for one save; the error also carries the suggestion in `errors.details[:email]` (`suggestion:`). No concern needed if you'd rather wire it yourself: the validator only looks for a method named `<attribute>_lookalike_confirmed` that returns exactly `true`.
279
+
280
+ > [!WARNING]
281
+ > Without the checkbox, a confirmable error has no way past it — a real `gnail.com` user would be stuck. Wire the confirmation before you switch this on, or keep using `suggestion_for` as a hint only.
282
+
283
+ Before 0.5.0, `true` refused every lookalike outright. If you set it, upgrading makes it **softer**, never stricter: reviewed typos and impossible TLDs are still refused, every other lookalike becomes confirmable, and many of the old matches (`ge.com`, `bol.com`) are no longer suggested at all.
284
+
285
+ Matching stops at **one** edit by default (`lookalike_distance`; 2 is coarser and documented as risky, 0 switches the whole layer off, list included). 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.
286
+
209
287
  #### How the two combine
210
288
 
211
289
  At most one error is added, however many checks fire — and whenever the gem can name a correction, that phrasing wins:
@@ -214,7 +292,9 @@ At most one error is added, however many checks fire — and whenever the gem ca
214
292
  |---|---|---|
215
293
  | `user@gmail.con` | "looks like a typo. Did you mean user@gmail.com?" | same |
216
294
  | `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?" |
295
+ | `user@gmail.co` | accepted | "looks like a typo. Did you mean user@gmail.com?" (a reviewed typo: refused) |
296
+ | `user@gnail.com` | accepted | same message, confirmable |
297
+ | `user@ge.com` | accepted | accepted |
218
298
  | `user@tempmail.com` | "provider is not allowed" | same |
219
299
 
220
300
  "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.
@@ -230,6 +310,8 @@ Nondisposable.valid_tld?('user@example.con') # => false
230
310
  Nondisposable.suggestion_for('user@gmial.com') # => "user@gmail.com"
231
311
  ```
232
312
 
313
+ Every check compares domains in one canonical form — lowercased, without a trailing dot, unicode labels converted to punycode, so `user@Bücher.DE.` and `user@xn--bcher-kva.de` get the same verdict. The local part is never touched (case and `+tags` are the user's business). The same normalisation is available as `Nondisposable::Domain.normalize(domain)` and `Nondisposable::Domain.from_email(email)`.
314
+
233
315
  ## Updating disposable domains
234
316
 
235
317
  To manually update the list of disposable domains, run:
@@ -238,7 +320,7 @@ To manually update the list of disposable domains, run:
238
320
  Nondisposable::DomainListUpdater.update
239
321
  ```
240
322
 
241
- 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).
323
+ The fetch uses 10-second open/read timeouts and replaces the whole table atomically (see [Blocklist sources](#blocklist-sources) for how several lists are merged and guarded). 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).
242
324
 
243
325
  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.
244
326
 
@@ -253,6 +335,95 @@ production:
253
335
  schedule: every day at 3am US/Pacific
254
336
  ```
255
337
 
338
+ ## Blocklist sources
339
+
340
+ Out of the box the gem uses the one list it always has, [`disposable-email-domains`](https://github.com/disposable-email-domains/disposable-email-domains). Upgrading changes nothing. You can add more:
341
+
342
+ ```ruby
343
+ config.sources = Nondisposable::Sources::RECOMMENDED
344
+ ```
345
+
346
+ `RECOMMENDED` adds [fakefilter](https://github.com/7c/fakefilter), which watches temp-mail generators directly. On 2026-10-17 it added **3,275 domains** (13,168 in total, against 9,893), and none of them was a known real provider.
347
+
348
+ > [!NOTE]
349
+ > **More lists do not catch fresh burners.** `mail-burn.com`, the domain that prompted this feature, was live and handing out inboxes on 2026-10-17. Of the measured lists only disposable/disposable carried it, from its own crawl. groundcat had it too, but only because it copies disposable/disposable. That is **one origin**, and corroboration rightly refuses to count copies, so no precision-first combination of public lists blocks it. A fresh burner shows up in one crawler's list first. Until a list exists that publishes *evidence* for each fresh domain, block a domain you have verified yourself with `config.additional_domains`, and send it upstream.
350
+
351
+ ### Trust and lineage
352
+
353
+ Every source is a `Nondisposable::Source` with a `trust`:
354
+
355
+ - `:primary`: every domain it lists is blocked (subject to the precedence table).
356
+ - `:corroborating`: a domain it lists is blocked only once `config.corroboration_threshold` (default 2) **independent origins** list it.
357
+
358
+ "Independent" is the key word. Public blocklists copy each other: [`disposable/disposable`](https://github.com/disposable/disposable) declares that it imports mailchecker, fakefilter, wesbos and unkn0w, and groundcat imports disposable/disposable. Two copies of one mistake are one observation, so each source declares what it is `derived_from`, and a vote only counts when none of its upstreams that you also load carries the same domain.
359
+
360
+ Measured on 2026-10-17, that rule has teeth. With lineage declared, adding disposable/disposable, mailchecker and groundcat as corroborating sources to `RECOMMENDED` adds **zero** domains: everything they agree on is already explained by an upstream you load. Ignoring lineage instead would have "corroborated" 68,692 more, 64 of them in the [Tranco](https://tranco-list.eu) top 100k (`compaq.com`, `cafe24.com`, `24hourfitness.com`, `asana.biz`…). That is why `RECOMMENDED` is two primaries and nothing else.
361
+
362
+ The other measured sources ship as constants, so you can opt in knowingly:
363
+
364
+ | Constant | List | Licence | Trust | Notes |
365
+ |---|---|---|---|---|
366
+ | `DISPOSABLE_EMAIL_DOMAINS` | disposable-email-domains/disposable-email-domains | CC0 | primary | the default; also the bundled seed |
367
+ | `FAKEFILTER` | 7c/fakefilter | BSD-3 | primary | in `RECOMMENDED` |
368
+ | `DISPOSABLE` | disposable/disposable-email-domains | MIT | corroborating | ~97k domains, aggregates most others |
369
+ | `MAILCHECKER` | FGRibreau/mailchecker | MIT | corroborating | lists privacy relays |
370
+ | `GROUNDCAT` | groundcat/disposable-email-domain-list | MIT | corroborating | aggregator |
371
+ | `WESBOS` | wesbos/burner-email-providers | MIT | corroborating | no declared lineage, but 57% of it is also in mailchecker |
372
+
373
+ > [!WARNING]
374
+ > **About promoting `DISPOSABLE` to primary.** It is the broadest list, and the only one that carried `mail-burn.com` on 2026-10-17. On top of `RECOMMENDED` it adds 84,341 domains (70 of them in the Tranco top 100k, listed in `docs/evidence/2026-10-17-disposable-as-primary-tranco-top100k.csv`), and among them are real mail domains: China Unicom (`wo.com.cn`), the Japanese ISP ODN (`odn.ad.jp`), UQ mobile (`uqmobile.jp`), the Moscow ISP MTU (`mtu-net.ru`), plus `cafe24.com`, `24hourfitness.com`, `foxwoods.com` and more. The ones we verified are now in `never_disposable.txt`, but the rest of the list is unknown. If you still want it, `Nondisposable::Sources::DISPOSABLE.with(trust: :primary)` does it. For one specific burner domain, `config.additional_domains` is the precise tool.
375
+
376
+ Your own source is a Hash or a `Source`:
377
+
378
+ ```ruby
379
+ config.sources = Nondisposable::Sources::RECOMMENDED + [
380
+ { name: "ours", url: "https://example.com/burners.txt", trust: :corroborating, derived_from: %w[mailchecker] }
381
+ ]
382
+ ```
383
+
384
+ Sources must be `https`, plain text, one domain per line (`#` comments allowed). Names and URLs must be unique, and at least one source must be `:primary`.
385
+
386
+ ### Privacy relays
387
+
388
+ Apple Hide My Email, DuckDuckGo, Firefox Relay, SimpleLogin/Proton Pass and addy.io forward to a real, long-lived inbox. They are not throwaways, but they do let one person hold many addresses. That is a policy decision, so it is a setting: `config.block_privacy_relays = false` (the default) **allows** them even when an upstream list carries them (mailchecker does), and `true` **refuses** them even when none does. The list is `data/privacy_relays.txt`; each domain covers its subdomains.
389
+
390
+ ### Upgrading an existing install
391
+
392
+ Multi-source lists need one new table. The migration is additive, so you can deploy first and migrate later:
393
+
394
+ ```bash
395
+ rails generate nondisposable:upgrade
396
+ rails db:migrate
397
+ ```
398
+
399
+ Until the table exists (and again if you roll the migration back), the updater uses the default list exactly as before and logs a warning if you have configured other sources. During a rolling deploy an old process may still publish the single-source list once; the next update from a new process rebuilds the full one.
400
+
401
+ ### How an update is published
402
+
403
+ Each source is fetched into its own row in `nondisposable_source_snapshots` (status, timestamp, sha256, count, the list itself). The stored list is then **rebuilt** from each source's last good snapshot, in one transaction, under a database advisory lock on PostgreSQL and MySQL (a process lock on SQLite, which already allows only one writer). Seeding takes the same lock. A new snapshot is refused, and the source's last good one kept, when:
404
+
405
+ - the download fails, takes longer than the source's `deadline` (30 s), or is larger than its `max_bytes` (8 MB, checked while streaming);
406
+ - more than 1% of its lines are not domain names (an HTML error page, a format change);
407
+ - it has fewer than `minimum_count` domains (1,000 for the presets);
408
+ - it is less than half the source's high-water mark, the largest size ever accepted for it (kept on its newest good snapshot, so neither pruning nor a series of 49% cuts can wear it down; `reset_baseline` is the human override), or more than three times its last good one;
409
+ - it is identical to a snapshot a human rolled back.
410
+
411
+ Every size is a count of *storable* domains (not junk, not a public suffix, not a known real provider, not a privacy relay), counted the same way whatever your settings, so a payload padded with such lines cannot pass for a full list and changing `block_privacy_relays` or `excluded_domains` never trips a guard. The list is never published empty. A primary that has never been fetched successfully either stands in its bundled list (the default source) or blocks publishing (any other), so a first run can never replace the seed with less than it. Additions and removals are logged per source. A source that lists a known real provider, or a public suffix, is logged as a possible poisoning, and the entry is dropped. Accepting a snapshot, publishing and rolling back all happen under the same lock; downloads happen outside it.
412
+
413
+ > [!WARNING]
414
+ > **Two concurrency limits, by design:**
415
+ >
416
+ > - **SQLite: run the refresh from one process.** PostgreSQL and MySQL get a database-wide advisory lock; SQLite has none, so the lock is per process. Two *processes* refreshing at once can interleave (one can republish a merge it read before the other rolled a source back). Schedule `DisposableEmailDomainListUpdateJob` on a single job runner, and run `rollback` from that same process or when no update is running.
417
+ > - **Rolling deploys: quiesce the old updater.** Processes still running 0.5 or earlier replace the table without taking the lock, so their single-source list can overwrite a multi-source one, or leave back a row a new process just withdrew. When upgrading: pause the scheduled refresh job, deploy, run `rails g nondisposable:upgrade && rails db:migrate`, then resume it and run `Nondisposable::DomainListUpdater.update` once.
418
+
419
+ Two more tools:
420
+
421
+ ```ruby
422
+ Nondisposable::DomainListUpdater.publish # rebuild from snapshots, no download (after a config change)
423
+ Nondisposable::DomainListUpdater.rollback("fakefilter") # withdraw that source's newest snapshot (its payload stays refused) and republish
424
+ Nondisposable::DomainListUpdater.reset_baseline("fakefilter") # accept a genuine shrink: the guard now measures from today's size
425
+ ```
426
+
256
427
  ## Updating the TLD list
257
428
 
258
429
  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.
@@ -271,6 +442,14 @@ It refuses to overwrite a good list with an implausibly short one (a captive por
271
442
  Nondisposable::Tld.version # => "2026082301, Last Updated Mon Aug 24 07:07:01 2026 UTC"
272
443
  ```
273
444
 
445
+ The bundled Public Suffix List (`data/public_suffix_list.dat`, redistributed unmodified under the MPL 2.0) is refreshed the same way, with the same safeguards:
446
+
447
+ ```bash
448
+ rake nondisposable:psl:update # rewrites data/public_suffix_list.dat from publicsuffix.org
449
+ ```
450
+
451
+ A stale PSL can only be less protective, never lock anyone out: a suffix created after the snapshot simply isn't known to be one.
452
+
274
453
  ## Troubleshooting
275
454
 
276
455
  ### SSL certificate verify failed (unable to get certificate CRL)
@@ -4,52 +4,71 @@ 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.
7
+ # Kept so code that referenced it still loads; no longer used. Until 0.6.0
8
+ # the parent walk stopped after this many labels, which let anybody who
9
+ # controls a listed domain escape it with `a.b.c.d.tempmail.com`. The walk
10
+ # now covers every parent down to the registrable domain: DNS itself caps
11
+ # a name at 253 characters, so the candidate list is bounded anyway.
18
12
  PARENT_MATCH_DEPTH = 3
19
13
 
14
+ # Kept (and equal to Domain::MAX_RAW_LENGTH) for code that referenced it.
15
+ # The raw-length bound itself now lives in Domain.normalize, which applies
16
+ # it AFTER stripping invisible characters: judging raw length here let
17
+ # padding hide a listed domain.
18
+ MAX_INPUT_LENGTH = Nondisposable::Domain::MAX_RAW_LENGTH
19
+
20
20
  class << self
21
+ # Whether `domain` is disposable, according to the precedence table in
22
+ # Nondisposable::Precedence: host exclusions, then the bundled
23
+ # never-disposable list, then host additions, then the privacy-relay
24
+ # policy, and only then this table.
21
25
  def disposable?(domain)
22
- return false if domain.blank?
26
+ # A name longer than DNS allows (over 253 characters or a label over
27
+ # 63 once canonical, or over 1,024 code points of raw input after
28
+ # invisible characters are stripped) is TRUE: it can never be a
29
+ # deliverable domain, and a check whose job is keeping junk out must
30
+ # not call junk fine. Judged by Domain.too_long?, never on the raw
31
+ # length: a raw cap let invisible padding (U+00AD x 1013 +
32
+ # "tempmail.com") hide a listed name. Domain.normalize bounds its own
33
+ # work on oversized input, so the parent walk below never runs on one.
34
+ raw = domain
35
+ return false if raw.to_s.strip.empty?
36
+ return true if Nondisposable::Domain.too_long?(raw)
23
37
 
24
- domain = domain.to_s.downcase
25
- config = Nondisposable.configuration
26
- candidates = match_candidates(domain)
38
+ domain = Nondisposable::Domain.normalize(raw)
39
+ return false if domain.empty?
40
+ # Not UTF-8: no list holds it and no mail server can deliver to it.
41
+ return false if domain.encoding == Encoding::BINARY && !domain.ascii_only?
27
42
 
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
43
+ verdict, candidates = Nondisposable::Precedence.decide(domain, match_candidates(domain))
44
+ return verdict == :block unless verdict == :lookup
30
45
 
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)
46
+ # Rows written before 0.5.0 stored a unicode domain as it came
47
+ # (lowercased, not punycode). Match that spelling of every candidate
48
+ # still eligible, so upgrading never unblocks them; the next publish
49
+ # rewrites them canonically.
50
+ legacy = Nondisposable::Domain.unicode(raw)
51
+ unless legacy == domain || legacy.ascii_only?
52
+ candidates |= match_candidates(legacy).select { |name| candidates.include?(Nondisposable::Domain.normalize(name)) }
53
+ end
35
54
 
36
- where(name: candidates - excluded).exists?
55
+ candidates.any? && where(name: candidates).exists? # one WHERE name IN (...)
37
56
  end
38
57
 
39
- # The exact domain plus up to PARENT_MATCH_DEPTH parent domains,
40
- # never descending below two labels (bare TLDs are never candidates).
58
+ # With check_parent_domains (the default): the domain and EVERY parent
59
+ # down to, and including, its registrable domain:
60
+ #
61
+ # a.b.c.d.tempmail.com => a.b.c.d.tempmail.com … d.tempmail.com, tempmail.com
62
+ #
63
+ # Never above the registrable domain: past it lie public suffixes (`com`,
64
+ # `co.uk`) and names like `sch.uk` that sit above a wildcard suffix
65
+ # (`*.sch.uk`), all belonging to unrelated registrants. Without
66
+ # check_parent_domains: the domain alone. Expects a normalized domain.
41
67
  def match_candidates(domain)
42
- return [domain] unless Nondisposable.configuration.check_parent_domains
68
+ namespace = Nondisposable::Precedence.namespace(domain)
69
+ return namespace if Nondisposable.configuration.check_parent_domains
43
70
 
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
71
+ namespace.first(1)
53
72
  end
54
73
  end
55
74
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nondisposable
4
+ # What one source said on one fetch — or why we refused to listen.
5
+ #
6
+ # The merged nondisposable_disposable_domains table cannot answer "which
7
+ # list put this domain here?", and it cannot be rebuilt when one source has a
8
+ # bad day: a single domain may come from several sources, and a domain one
9
+ # corroborating source lists must survive until a second one agrees. So each
10
+ # source keeps its own recent history here, and the merged table is always
11
+ # REBUILT from each source's last good snapshot (DomainListUpdater.publish).
12
+ #
13
+ # Statuses:
14
+ # ok accepted; `domains` holds the canonical list, one per line
15
+ # rejected downloaded, but failed a guard (too small, shrank or grew
16
+ # implausibly, mostly junk); `error` says which
17
+ # failed the download itself failed; `error` says how
18
+ # rolled_back an ok snapshot a human withdrew (DomainListUpdater.rollback)
19
+ #
20
+ # Only `ok` snapshots ever feed the published list. The table is optional:
21
+ # without it (hosts that have not run `rails g nondisposable:upgrade`), the
22
+ # updater keeps the single-source behaviour of 0.5 and earlier.
23
+ class SourceSnapshot < ApplicationRecord
24
+ self.table_name = 'nondisposable_source_snapshots'
25
+
26
+ STATUSES = %w[ok rejected failed rolled_back].freeze
27
+
28
+ # Rows kept per source: the newest RETAINED_PER_SOURCE of any status, plus
29
+ # the newest RETAINED_OK_PER_SOURCE good ones however old (failures must
30
+ # never evict the fallback, nor the shrink guard's baseline), plus EVERY
31
+ # rolled-back row (its sha256 is what refuses the same poison next time).
32
+ RETAINED_PER_SOURCE = 5
33
+ RETAINED_OK_PER_SOURCE = 3
34
+
35
+ validates :source, :status, :fetched_at, presence: true
36
+ validates :status, inclusion: { in: STATUSES }
37
+
38
+ scope :ok, -> { where(status: 'ok') }
39
+ scope :newest_first, -> { order(fetched_at: :desc, id: :desc) }
40
+
41
+ class << self
42
+ # Is the table there? Checked on every update rather than memoized, so a
43
+ # long-running process notices the upgrade migration without a restart.
44
+ def available?
45
+ connection_pool.with_connection { |connection| connection.data_source_exists?(table_name) }
46
+ rescue StandardError
47
+ false
48
+ end
49
+
50
+ def last_good(source_name)
51
+ ok.where(source: source_name).newest_first.first
52
+ end
53
+
54
+ def prune(source_name)
55
+ rows = where(source: source_name)
56
+ keep = rows.newest_first.limit(RETAINED_PER_SOURCE).pluck(:id) +
57
+ rows.ok.newest_first.limit(RETAINED_OK_PER_SOURCE).pluck(:id)
58
+ rows.where.not(id: keep).where.not(status: 'rolled_back').delete_all
59
+ end
60
+ end
61
+
62
+ def domain_list
63
+ domains.to_s.split("\n")
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,99 @@
1
+ # Reviewed typo domains, used by Nondisposable::KnownTypos.
2
+ #
3
+ # <typo domain> <the provider it is a misspelling of>
4
+ #
5
+ # These are the ONLY lookalike domains the validator refuses outright (when
6
+ # `reject_lookalike_domains` is on). Everything else one edit from a provider
7
+ # is a guess, and a guess only ever produces a confirmable error. So the bar
8
+ # for an entry is high, and it is the same bar for every entry:
9
+ #
10
+ # 1. The domain is a misspelling of a provider in data/email_providers.txt.
11
+ # 2. There is direct evidence that nobody has a real mailbox there, of one of
12
+ # these kinds:
13
+ # - it redirects to the provider, or is held on the provider's own DNS
14
+ # (a defensive registration: yaho.com, homail.com);
15
+ # - it is parked or for sale through a parking/marketplace network
16
+ # (nameservers or lander of parkingcrew, sedo, bodis, afternic,
17
+ # above.com…), or publishes a Null MX (RFC 7505: "accepts no mail");
18
+ # - it is a typosquat: it redirects visitors to an unrelated monetised
19
+ # page, or its mail goes to a catch-all run for misspelled domains;
20
+ # - it is registered but has no MX, A or AAAA record, so mail to it
21
+ # cannot be delivered at all (RFC 5321 §5.1 has nothing to fall
22
+ # back to). Every lookup must have answered NOERROR — recorded in the
23
+ # evidence's dns_status column, and the labelling script refuses the
24
+ # verdict otherwise — never inferred from a lookup that timed out.
25
+ # 3. The evidence is recorded, row by row, in test/fixtures/lookalike_corpus.csv
26
+ # (collected by script/collect_lookalike_evidence.rb on 2026-10-17), and
27
+ # the suite fails if an entry here loses its `typo` row there.
28
+ #
29
+ # What is NOT enough on its own: "no MX record" (SMTP falls back to the A
30
+ # record), a blank homepage, or a short name. When the evidence is ambiguous
31
+ # the domain stays OFF this list and remains confirmable — a wrong entry here
32
+ # is a person who cannot sign up and cannot say "yes, this is my address".
33
+ #
34
+ # Never generated, never bulk-imported. Add an entry with its evidence row.
35
+
36
+ # --- frontier.com ---
37
+ ffrontier.com frontier.com
38
+
39
+ # --- globo.com ---
40
+ globh.com globo.com
41
+
42
+ # --- gmail.com ---
43
+ gamail.com gmail.com
44
+ gimail.com gmail.com
45
+ gm4il.com gmail.com
46
+ gmai.com gmail.com
47
+ gmaii.com gmail.com
48
+ gmail.cm gmail.com
49
+ gmail.co gmail.com
50
+ gmaio.com gmail.com
51
+ gmal.com gmail.com
52
+ gmali.com gmail.com
53
+ gmeil.com gmail.com
54
+ gmial.com gmail.com
55
+ gmmail.com gmail.com
56
+ gmuil.com gmail.com
57
+ hmail.com gmail.com
58
+ jmail.com gmail.com
59
+
60
+ # --- hotmail.com ---
61
+ hitmail.com hotmail.com
62
+ homail.com hotmail.com
63
+ hotamil.com hotmail.com
64
+ hotmai.com hotmail.com
65
+ hotmal.com hotmail.com
66
+ hotmasil.com hotmail.com
67
+ hotmial.com hotmail.com
68
+ otmail.com hotmail.com
69
+
70
+ # --- icloud.com ---
71
+ icloude.com icloud.com
72
+
73
+ # --- outlook.com ---
74
+ autlook.com outlook.com
75
+ outlok.com outlook.com
76
+ outloo.com outlook.com
77
+
78
+ # --- runbox.com ---
79
+ rfunbox.com runbox.com
80
+
81
+ # --- yahoo.com ---
82
+ yahho.com yahoo.com
83
+ yaho.com yahoo.com
84
+ yaoo.com yahoo.com
85
+ yhaoo.com yahoo.com
86
+ yhoo.com yahoo.com
87
+
88
+ # --- yahoo.de ---
89
+ yaho.de yahoo.de
90
+
91
+ # --- yahoo.it ---
92
+ yhoo.it yahoo.it
93
+
94
+ # --- yandex.ru ---
95
+ ayndex.ru yandex.ru
96
+ yandez.ru yandex.ru
97
+
98
+ # --- zoho.com ---
99
+ zoho.co zoho.com