nondisposable 0.6.0 → 0.6.2
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 +18 -0
- data/README.md +27 -3
- data/lib/generators/nondisposable/templates/nondisposable.rb +4 -0
- data/lib/nondisposable/domain_list_updater.rb +24 -5
- data/lib/nondisposable/email_validator.rb +252 -51
- data/lib/nondisposable/known_typos.rb +8 -0
- data/lib/nondisposable/version.rb +1 -1
- data/lib/nondisposable.rb +90 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2193d2a242a699cdd8b05767d364d5da7f039f5f2e3203592d0f3222bb8c29db
|
|
4
|
+
data.tar.gz: bb866074eb28008df4eecd8fab1df646ebbde5eddd6907dfaa43126c3348a315
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d88abe6d8b21e332bab766e6a132a1d3e311bba0f067c4a80e5148172249465a58d8053ce3d100d842a3bfebe08dd8910daed05aa8f10d622d9e7a8802ebffa7
|
|
7
|
+
data.tar.gz: 967dbdbfea2858290d97b150b8e19827314c7031250fd69c274b14a06d4bd9036999ef180a794bc76537c785847babae3a707f69e0837c61b018f159579259b7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## [0.6.2] - 2026-10-18
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **A reviewed typo that a blocklist also lists now gets "Did you mean …?", not the disposable message.** The bundled upstream snapshot lists `gmial.com`, `hotmai.com` and `hotmial.com`, three of the entries in `data/known_typo_domains.txt`, and the validator asked the disposable table first, so with `reject_lookalike_domains` on and the table loaded, `ana@gmial.com` was told "provider is not allowed" instead of "Did you mean ana@gmail.com?". The address was refused either way; the message was wrong, and it sent a person who missed a key looking for a problem they don't have. The two **hard** corrections (a reviewed typo, and an impossible TLD with a nameable correction such as `gmail.con`) are now answered before the disposable check, without touching the database: a lookup that raises can neither let `ana@gmial.com` through under `on_check_failure = :allow` nor replace the correction with the generic "cannot check" error under `:reject`. A **guess** still comes after it, and so does the ranker that produces it, so an ordinary listed throwaway never pays for the distance walk: `gotmail.com` and `tmail.com` are both one finger from a provider and listed throwaways, and a confirmable "did you mean" there would only let the user confirm an address that is then refused anyway. **What changes, exactly** (every case below was refused before and is still refused; only the message differs): with `reject_lookalike_domains` on, a reviewed typo gets "Did you mean …?" even when it is listed, whether by an upstream source or by the host's own `additional_domains`, and even over a validator's own `message:` option. With `check_tld` on (alone or not), so does a listed domain whose ending does not exist and for which a correction can be named. Nothing changes with both checks off, at `lookalike_distance = 0`, or for a domain a host put in `additional_email_providers`: the table answers, with the disposable message, as before. The overlap with the blocklist is deliberately **not** filtered out of the stored list: that would let those typos through for every host without the lookalike layer, and for `Nondisposable.disposable?`. Reported by a review of a Rails app integrating the gem.
|
|
6
|
+
- **Reading a "Did you mean …?" error can no longer raise, or rewrite what the user typed.** `%`, `{` and `}` are legal in the local part of an address, and the confirmable error was added as `errors.add(:email, :lookalike_domain, message: …)`, which ActiveModel looks up and I18n-interpolates again on every read. So since 0.5.0, `a%{x}b@gnail.com` made `errors.full_messages` raise `I18n::MissingInterpolationArgument` (a broken signup response), and `a%{value}b@gnail.com` pasted the whole address into the middle of the message. The lookalike errors are now `Nondisposable::LookalikeError`s: an `ActiveModel::Error` whose message is resolved once, when the address is validated, and returned as is from then on. Type and details are unchanged (`errors.of_kind?(:email, :lookalike_domain)`, `errors.details[:email]` => `[{ error: :lookalike_domain, suggestion: … }]`), and `merge!`/`dup` keep the message. The text is computed the way ActiveModel computed it before, once: a per-model `lookalike_domain` translation (`activerecord.errors.models.user.attributes.email.lookalike_domain`) still wins over a configured String, and `%{attribute}` and `%{value}` still interpolate in that String; only what the user typed is no longer read as a pattern.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`config.lookalike_error_message` accepts a Rails error name or a callable**, as well as a String. A Symbol means what it already means for `error_message` and the TLD messages: a short error name (`:email_lookalike`) resolved through ActiveModel's lookup chain (`activerecord.errors.models.user.attributes.email.email_lookalike`, …, `errors.messages.email_lookalike`) in the locale of the request, with `%{suggestion}` (plus `%{attribute}`, `%{value}`) to interpolate. It is resolved once, when the address is validated, so an error read later (in another locale, or by a translation Proc that would raise the second time) shows what was resolved and never "Translation missing". The hard error is added with that name, as `error_message` is; the confirmable one stays typed `:lookalike_domain` with the Symbol as its message, so forms still tell it apart. `:lookalike_domain` itself is refused at boot, since a hard error of that type would offer the confirmation checkbox. A callable is called with the suggestion (`->(suggestion) { … }`) and must return a String. A String works exactly as before, and so does any object answering `#sub`, which until now was the only way to localise this message. `Configuration#lookalike_message(suggestion)` resolves it outside a model (a Symbol under `errors.messages`).
|
|
11
|
+
- **A message that cannot be used never weakens the refusal.** A missing translation, one naming a variable the gem does not pass, a key naming a whole translation group, a `nil` message, a callable that raises, takes no argument or returns something other than a String: each is logged and replaced by the default English message, and the address is still refused. Before, a raise there reached the validator's rescue, where the default `on_check_failure = :allow` accepted the address. A String is now substituted with the block form of `#sub`, so a `\0` or `\&` typed in the local part is printed as typed.
|
|
12
|
+
|
|
13
|
+
## [0.6.1] - 2026-10-17
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **A populated table is no longer replaced with the bundled list by an update** (an explicit `rollback` still can; see below). In 0.6.0, when a PRIMARY source with a bundled list (the default `disposable-email-domains`) had never been fetched successfully, `DomainListUpdater` put the list bundled with the gem in its place and published it, even over a populated table. **Who is affected:** any host with the `nondisposable_source_snapshots` table whose domain table was populated before the default source had an accepted snapshot of its own. Typically that means an upgrade from 0.4/0.5, with `Sources::DEFAULT` or a multi-source configuration such as `Sources::RECOMMENDED`. If the default source failed on such a host's first update, the table was replaced by the bundled snapshot (plus any other primary that succeeded), and every domain added upstream since the gem's release stopped being blocked until the next successful run. `update` returned false, but the list had already shrunk. Now the bundled stand-in is used **only on an empty table** (a fresh install). With a populated table, publishing is skipped with an error log and the table is kept as it is, until every primary has an accepted snapshot of its own. **The deliberate exception is `DomainListUpdater.rollback`:** withdrawing a poisoned list must always succeed, so there the bundled list still stands in for a primary without a snapshot, whatever the table holds. The emptiness check also bypasses the query cache, so a stale cached answer can never let the bundled list replace rows it never saw. Reported by a codex review in CarHey.
|
|
18
|
+
|
|
1
19
|
## [0.6.0] - 2026-10-17
|
|
2
20
|
|
|
3
21
|
Ships everything listed under 0.5.0 too: 0.5.0 was never released on its own.
|
data/README.md
CHANGED
|
@@ -135,7 +135,7 @@ Nondisposable.configure do |config|
|
|
|
135
135
|
|
|
136
136
|
config.invalid_tld_error_message = "doesn't look like a real email address"
|
|
137
137
|
config.blocked_tld_error_message = "domain ending is not allowed"
|
|
138
|
-
config.lookalike_error_message = "looks like a typo. Did you mean %{suggestion}?"
|
|
138
|
+
config.lookalike_error_message = "looks like a typo. Did you mean %{suggestion}?" # or a Rails error name (:email_lookalike), or a lambda
|
|
139
139
|
end
|
|
140
140
|
```
|
|
141
141
|
|
|
@@ -252,7 +252,9 @@ config.reject_lookalike_domains = :confirmable # `true` means the same thing
|
|
|
252
252
|
| any other lookalike the ranker suggests | `gnail.com` | **confirmable**: same message, unless the user confirmed the address |
|
|
253
253
|
| anything else | `ge.com`, `yahoo.com.au` | accepted |
|
|
254
254
|
|
|
255
|
-
A disposable provider is
|
|
255
|
+
A disposable provider is refused before any **guess** (a confirmable "did you mean" would only let the user confirm an address that is then refused anyway), and a blocked TLD is still refused after a confirmation. A host's `additional_email_providers` outranks the known-typo list.
|
|
256
|
+
|
|
257
|
+
The two **hard** corrections — an impossible TLD and a reviewed typo — are answered before the disposable check, without a database query (since 0.6.2). Upstream blocklists carry some reviewed typos (the bundled snapshot lists `gmial.com`, `hotmai.com` and `hotmial.com`), and the address is refused either way; but `ana@gmial.com` is a person who missed a key, not someone hiding behind a temporary inbox, so they get "Did you mean ana@gmail.com?" rather than the disposable message — also over a validator's own `message:`, and when the domain is in your `additional_domains`. Before 0.6.2 these typos got the disposable message whenever the table was loaded.
|
|
256
258
|
|
|
257
259
|
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
260
|
|
|
@@ -296,9 +298,31 @@ At most one error is added, however many checks fire — and whenever the gem ca
|
|
|
296
298
|
| `user@gnail.com` | accepted | same message, confirmable |
|
|
297
299
|
| `user@ge.com` | accepted | accepted |
|
|
298
300
|
| `user@tempmail.com` | "provider is not allowed" | same |
|
|
301
|
+
| `user@gmial.com` (a reviewed typo the blocklist also lists) | "provider is not allowed" | "looks like a typo. Did you mean user@gmail.com?" |
|
|
299
302
|
|
|
300
303
|
"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.
|
|
301
304
|
|
|
305
|
+
#### Translating the "Did you mean" message
|
|
306
|
+
|
|
307
|
+
`lookalike_error_message` takes a String (with `%{suggestion}`), a Rails error name, or anything callable. A Symbol means what it means for `error_message` and the TLD messages: a short error name, looked up when the address is validated through the usual ActiveModel chain (`activerecord.errors.models.user.attributes.email.<name>`, …, `errors.messages.<name>`), so it answers in the current request's locale and can differ per model or attribute:
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
# config/initializers/nondisposable.rb
|
|
311
|
+
config.lookalike_error_message = :email_lookalike
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
```yaml
|
|
315
|
+
# config/locales/es.yml
|
|
316
|
+
es:
|
|
317
|
+
errors:
|
|
318
|
+
messages:
|
|
319
|
+
email_lookalike: "parece una errata. ¿Querías decir %{suggestion}?"
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`%{suggestion}` is the full corrected address (`ana@gmail.com`), or just the corrected domain when nothing was typed before the `@`; `%{attribute}` and `%{value}` work as in any Rails error message. The hard error is added under that name (`errors.of_kind?(:email, :email_lookalike)`), like `error_message`; the confirmable one stays `:lookalike_domain`, so your checkbox logic doesn't change. That is also why `:lookalike_domain` itself is refused as the name. Unlike an ordinary Rails error, the text is resolved once, when the address is validated, and kept: rendering never looks it up again, so it can't fail later, and whatever the user typed (`%{…}` is legal in an address) is printed as typed.
|
|
323
|
+
|
|
324
|
+
If the key has no translation in the current locale, Rails' lookup (and I18n's locale fallbacks, if you've turned them on) gets the first try; only when nothing is found does the gem use its default English message, never "Translation missing". The same goes for a translation that names a variable the gem doesn't pass, a key that names a whole group, or a callable that raises: the message falls back and the error is logged, and the address is refused either way, so a broken locale file can never let it through. For anything else, pass a callable that returns a String: `config.lookalike_error_message = ->(suggestion) { I18n.t("my_app.email_typo", suggestion: suggestion) }`. A String works exactly as before, including on the confirmable error: `%{attribute}` and `%{value}` interpolate, and a translation of that model's `lookalike_domain` (`activerecord.errors.models.user.attributes.email.lookalike_domain`) wins over it.
|
|
325
|
+
|
|
302
326
|
### Direct Check
|
|
303
327
|
|
|
304
328
|
You can also check if an email is disposable directly:
|
|
@@ -408,7 +432,7 @@ Each source is fetched into its own row in `nondisposable_source_snapshots` (sta
|
|
|
408
432
|
- 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
433
|
- it is identical to a snapshot a human rolled back.
|
|
410
434
|
|
|
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
|
|
435
|
+
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 blocks publishing, and the current table is kept as it is. Two exceptions let the default source's bundled list stand in for it: an EMPTY table (a fresh install), and an explicit `rollback`, which must always be able to withdraw a poisoned list. So an update can never replace a fresh seed with less than it, nor a table upgraded from 0.4/0.5 (with any source configuration) with an older bundled snapshot. 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
436
|
|
|
413
437
|
> [!WARNING]
|
|
414
438
|
> **Two concurrency limits, by design:**
|
|
@@ -82,4 +82,8 @@ Nondisposable.configure do |config|
|
|
|
82
82
|
# config.invalid_tld_error_message = "doesn't look like a real email address"
|
|
83
83
|
# config.blocked_tld_error_message = "domain ending is not allowed"
|
|
84
84
|
# config.lookalike_error_message = "looks like a typo. Did you mean %{suggestion}?"
|
|
85
|
+
# ...or a Rails error name, like the messages above: looked up per request
|
|
86
|
+
# through ActiveModel's chain, e.g. errors.messages.email_lookalike in your
|
|
87
|
+
# locale files, with %{suggestion} to interpolate:
|
|
88
|
+
# config.lookalike_error_message = :email_lookalike
|
|
85
89
|
end
|
|
@@ -303,6 +303,16 @@ module Nondisposable
|
|
|
303
303
|
def publish!(withdrawn: nil)
|
|
304
304
|
snapshots = {}
|
|
305
305
|
missing_primaries = []
|
|
306
|
+
# The bundled list may stand in for a never-fetched primary ONLY on an
|
|
307
|
+
# empty table. A populated table (a host upgrading from 0.4/0.5, whose
|
|
308
|
+
# rows are newer than this gem's release) is kept as it is until every
|
|
309
|
+
# primary has an accepted snapshot of its own: replacing it with the
|
|
310
|
+
# bundled snapshot would silently unblock every domain added upstream
|
|
311
|
+
# since the release (0.6.1; reported by a CarHey codex review).
|
|
312
|
+
# A rollback is the deliberate exception: withdrawing a poisoned list
|
|
313
|
+
# must succeed even when the default source never had a snapshot of
|
|
314
|
+
# its own, so there the stand-in is used whatever the table holds.
|
|
315
|
+
table_empty = DisposableDomain.uncached { DisposableDomain.none? }
|
|
306
316
|
|
|
307
317
|
configured_sources.each do |source|
|
|
308
318
|
snapshot = SourceSnapshot.last_good(source.name)
|
|
@@ -310,7 +320,7 @@ module Nondisposable
|
|
|
310
320
|
snapshots[source] = snapshot.domain_list
|
|
311
321
|
elsif source.name == withdrawn
|
|
312
322
|
log :warn, "#{source.name} has no good snapshot left after the rollback; building the list without it."
|
|
313
|
-
elsif source.primary? && source.bundled_path
|
|
323
|
+
elsif source.primary? && source.bundled_path && (table_empty || withdrawn)
|
|
314
324
|
log :warn, "#{source.name} has no good snapshot yet; using the list bundled with the gem in its place."
|
|
315
325
|
snapshots[source] = Source.parse(File.read(source.bundled_path)).domains
|
|
316
326
|
elsif source.primary?
|
|
@@ -322,7 +332,8 @@ module Nondisposable
|
|
|
322
332
|
|
|
323
333
|
if missing_primaries.any?
|
|
324
334
|
log :error, "Not publishing: primary source(s) #{missing_primaries.join(', ')} have never been fetched successfully. " \
|
|
325
|
-
'Keeping the current list (seeding it from the bundled snapshot if it is empty)
|
|
335
|
+
'Keeping the current list unchanged (seeding it from the bundled snapshot only if it is empty) ' \
|
|
336
|
+
'until every primary has an accepted snapshot of its own.'
|
|
326
337
|
seed!
|
|
327
338
|
return false
|
|
328
339
|
end
|
|
@@ -371,7 +382,9 @@ module Nondisposable
|
|
|
371
382
|
# ---- Seeding and writing ----------------------------------------------
|
|
372
383
|
|
|
373
384
|
def seed!
|
|
374
|
-
|
|
385
|
+
# Callers hold the publish lock, which already disables the query
|
|
386
|
+
# cache; asked uncached here as well so no future caller can forget.
|
|
387
|
+
return false unless DisposableDomain.uncached { DisposableDomain.none? }
|
|
375
388
|
|
|
376
389
|
log :info, 'Domain table is empty; seeding from the blocklist bundled with the gem...'
|
|
377
390
|
domains = Source.parse(bundled_domains.join("\n")).domains
|
|
@@ -465,10 +478,16 @@ module Nondisposable
|
|
|
465
478
|
# time, and two processes publishing the same snapshots write the same
|
|
466
479
|
# rows. Returns the block's value, or nil when another process held the
|
|
467
480
|
# lock for longer than PUBLISH_LOCK_WAIT (it is publishing the same thing).
|
|
481
|
+
#
|
|
482
|
+
# Everything under the lock runs with the query cache OFF: an emptiness
|
|
483
|
+
# check (`DisposableDomain.none?`) answered from a cache filled before
|
|
484
|
+
# another process or thread wrote the table would let the bundled list
|
|
485
|
+
# replace rows it never saw.
|
|
468
486
|
def with_publish_lock(&block)
|
|
487
|
+
uncached = -> { DisposableDomain.uncached(&block) }
|
|
469
488
|
PROCESS_LOCK.synchronize do
|
|
470
489
|
DisposableDomain.connection_pool.with_connection do |connection|
|
|
471
|
-
next
|
|
490
|
+
next uncached.call unless connection.supports_advisory_locks?
|
|
472
491
|
|
|
473
492
|
acquired = false
|
|
474
493
|
deadline = monotonic + PUBLISH_LOCK_WAIT
|
|
@@ -482,7 +501,7 @@ module Nondisposable
|
|
|
482
501
|
next nil unless acquired
|
|
483
502
|
|
|
484
503
|
begin
|
|
485
|
-
|
|
504
|
+
uncached.call
|
|
486
505
|
ensure
|
|
487
506
|
connection.release_advisory_lock(PUBLISH_LOCK_ID)
|
|
488
507
|
end
|
|
@@ -1,25 +1,98 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
module Nondisposable
|
|
4
|
+
# A validation error whose message was resolved when it was added, and is
|
|
5
|
+
# never resolved again.
|
|
6
|
+
#
|
|
7
|
+
# An ordinary `errors.add(:email, :some_type, message: "…")` keeps the type
|
|
8
|
+
# and looks the message up AGAIN every time it is read: the first key of
|
|
9
|
+
# ActiveModel's chain in whatever locale is current then, and I18n
|
|
10
|
+
# interpolation over the text. For "Did you mean …?" both are hazards. The
|
|
11
|
+
# text contains what the user typed, and `%`, `{` and `}` are legal in a
|
|
12
|
+
# local part, so `a%{x}b@gnail.com` made errors.full_messages raise
|
|
13
|
+
# I18n::MissingInterpolationArgument (since 0.5.0) and `a%{value}b@…` pasted
|
|
14
|
+
# the whole address into the middle of it. And a Symbol message we already
|
|
15
|
+
# checked could resolve differently at render time (another locale, a
|
|
16
|
+
# translation Proc that raises the second time), outside any fallback.
|
|
17
|
+
#
|
|
18
|
+
# So the message is frozen here, while the type and details stay what a
|
|
19
|
+
# form and an API expect: `errors.of_kind?(:email, :lookalike_domain)`,
|
|
20
|
+
# `errors.details[:email]` => [{ error: :lookalike_domain, suggestion: … }].
|
|
21
|
+
# It is in the locale of the request that validated, which is the request
|
|
22
|
+
# that renders it.
|
|
23
|
+
class LookalikeError < ActiveModel::Error
|
|
24
|
+
def initialize(base, attribute, type, resolved_message, **options)
|
|
25
|
+
super(base, attribute, type, **options)
|
|
26
|
+
@resolved_message = resolved_message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def message
|
|
30
|
+
@resolved_message
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
3
35
|
module ActiveModel
|
|
4
36
|
module Validations
|
|
5
37
|
class NondisposableValidator < EachValidator
|
|
6
|
-
# Three independent questions about one address,
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
38
|
+
# Three independent questions about one address, and AT MOST ONE error
|
|
39
|
+
# however many of them fire — stacking "provider is not allowed" on top
|
|
40
|
+
# of "did you mean gmail.com?" helps nobody:
|
|
41
|
+
#
|
|
42
|
+
# - Is this a throwaway provider? (always on — the gem's whole job)
|
|
43
|
+
# - Can we name the address they meant? (config.reject_lookalike_domains,
|
|
44
|
+
# or any invalid TLD we can correct — see below)
|
|
45
|
+
# - Is the TLD real, and allowed? (config.check_tld)
|
|
46
|
+
#
|
|
47
|
+
# The answer is chosen in this order, first match wins:
|
|
48
|
+
#
|
|
49
|
+
# 1. A HARD correction: an impossible TLD or a reviewed typo we can name
|
|
50
|
+
# the fix for (`gmail.con`, `gmial.com`). "Did you mean …?"
|
|
51
|
+
# 2. A throwaway provider. "provider is not allowed"
|
|
52
|
+
# 3. A GUESS (`gnail.com`): the same "Did you mean …?", but confirmable.
|
|
53
|
+
# 4. An invalid or blocked TLD.
|
|
54
|
+
#
|
|
55
|
+
# WHY A HARD CORRECTION OUTRANKS THE THROWAWAY CHECK (since 0.6.2)
|
|
56
|
+
#
|
|
57
|
+
# Upstream blocklists carry some of the very typos we reviewed: the
|
|
58
|
+
# bundled snapshot lists `gmial.com`, `hotmai.com` and `hotmial.com`.
|
|
59
|
+
# Both verdicts refuse the address, so the order only decides the
|
|
60
|
+
# MESSAGE, and only one of them is true about the person typing: someone
|
|
61
|
+
# at `ana@gmial.com` did not pick a temporary inbox, they missed a key
|
|
62
|
+
# on the way to gmail.com. Being told their provider is a throwaway
|
|
63
|
+
# sends them looking for a problem they don't have; "Did you mean
|
|
64
|
+
# ana@gmail.com?" fixes it in one click. A hard correction is certain (no mail reaches
|
|
65
|
+
# `.con`; somebody checked `gmial.com`), so it is safe to let it phrase a
|
|
66
|
+
# refusal the blocklist would have made anyway. Until 0.6.2 the throwaway
|
|
67
|
+
# check ran first, and these typos got the wrong message whenever the
|
|
68
|
+
# table was loaded.
|
|
69
|
+
#
|
|
70
|
+
# It is answered BEFORE the blocklist is even asked, not just phrased
|
|
71
|
+
# after it: a certain correction needs no database, so a lookup that
|
|
72
|
+
# raises can neither wave `ana@gmial.com` through (on_check_failure =
|
|
73
|
+
# :allow) nor bury the correction under the generic "cannot check"
|
|
74
|
+
# error (:reject). And it is cheap — a hash lookup in the reviewed list,
|
|
75
|
+
# and the ranker only for an ending that does not exist.
|
|
10
76
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
77
|
+
# A guess stays BEHIND the throwaway check. It is only a likelihood, and
|
|
78
|
+
# the user can answer it: `gotmail.com` and `tmail.com` are both one
|
|
79
|
+
# finger from a provider AND listed throwaways, so asking "did you mean
|
|
80
|
+
# hotmail.com?" there would let them tick "yes, this is right" only to
|
|
81
|
+
# be refused anyway on the next submit. A listed throwaway is the
|
|
82
|
+
# stronger evidence, and the more useful answer. It is also the
|
|
83
|
+
# expensive part (a distance walk over every provider), so it runs only
|
|
84
|
+
# for addresses the blocklist did not already refuse: an ordinary
|
|
85
|
+
# throwaway never pays for it.
|
|
15
86
|
#
|
|
16
87
|
# WHY A NAMEABLE CORRECTION OUTRANKS THE GENERIC TLD ERROR
|
|
17
88
|
#
|
|
18
|
-
# `user@gmail.con` fails both
|
|
19
|
-
# address" is true but useless; "Did you mean
|
|
20
|
-
# verdict with the fix attached. So whenever
|
|
21
|
-
# use that message, whether the TLD was
|
|
22
|
-
# rule is: a suggestion, if we have one,
|
|
89
|
+
# `user@gmail.con` fails both the lookalike and the TLD question. "Doesn't
|
|
90
|
+
# look like a real email address" is true but useless; "Did you mean
|
|
91
|
+
# user@gmail.com?" is the same verdict with the fix attached. So whenever
|
|
92
|
+
# we can name a correction we use that message, whether the TLD was
|
|
93
|
+
# invalid or merely lookalike. The rule is: a suggestion, if we have one,
|
|
94
|
+
# always phrases the rejection — unless it is a guess about a domain we
|
|
95
|
+
# KNOW is a throwaway (above).
|
|
23
96
|
#
|
|
24
97
|
# HARD vs CONFIRMABLE — WHICH LOOKALIKES CAN BE WAVED THROUGH
|
|
25
98
|
#
|
|
@@ -58,52 +131,34 @@ module ActiveModel
|
|
|
58
131
|
return reject(record, attribute, config.invalid_tld_error_message)
|
|
59
132
|
end
|
|
60
133
|
|
|
134
|
+
spelling = spelling_of(value, config)
|
|
135
|
+
|
|
136
|
+
# 1. A certain correction: no database, no ranker for a real ending.
|
|
137
|
+
# See "why a hard correction outranks the throwaway check" above. A
|
|
138
|
+
# plain message, not the confirmable :lookalike_domain kind, so a
|
|
139
|
+
# form never offers "yes, this is right" for it.
|
|
140
|
+
return add_lookalike_error(record, attribute, config, spelling.correction, confirmable: false) if spelling&.correction
|
|
141
|
+
|
|
142
|
+
# 2. A throwaway provider.
|
|
61
143
|
if Nondisposable::DisposableDomain.disposable?(domain)
|
|
62
144
|
return reject(record, attribute, options[:message] || config.error_message)
|
|
63
145
|
end
|
|
64
146
|
|
|
65
|
-
return unless
|
|
66
|
-
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
return unless Nondisposable::Tld.judgeable?(value)
|
|
72
|
-
|
|
73
|
-
# nil here means the domain has NO TLD (`example@gmailmcom` — a
|
|
74
|
-
# production-shaped typo that missed the dot), which fails "must end in a real TLD"
|
|
75
|
-
# just as surely as `.con` does.
|
|
76
|
-
tld = Nondisposable::Tld.extract(value)
|
|
77
|
-
impossible = tld.nil? || !Nondisposable::Tld.valid?(tld)
|
|
78
|
-
invalid = config.check_tld && impossible
|
|
79
|
-
blocked = config.check_tld && !invalid && Nondisposable::Tld.blocked?(tld)
|
|
80
|
-
|
|
81
|
-
if invalid || config.reject_lookalike_domains
|
|
147
|
+
return unless spelling
|
|
148
|
+
|
|
149
|
+
# 3. A guess, which the user can confirm. Not when the ending is
|
|
150
|
+
# impossible: spelling_of already asked the ranker then, and it had
|
|
151
|
+
# nothing to name.
|
|
152
|
+
if config.reject_lookalike_domains && !spelling.impossible
|
|
82
153
|
suggestion = Nondisposable::Suggestion.for(value)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if suggestion.nil? && config.lookalike_distance.to_i.positive?
|
|
86
|
-
suggestion = Nondisposable::KnownTypos.correction_for(value)
|
|
87
|
-
end
|
|
88
|
-
if suggestion
|
|
89
|
-
message = config.lookalike_error_message.sub('%{suggestion}', suggestion)
|
|
90
|
-
# Hard: no confirmation can make an impossible TLD deliverable —
|
|
91
|
-
# whether or not the host turned check_tld on, `gmail.con` cannot
|
|
92
|
-
# receive the confirmation email — and a reviewed typo is not a
|
|
93
|
-
# guess. The error stays a plain message, exactly as before 0.5.0.
|
|
94
|
-
return reject(record, attribute, message) if impossible || Nondisposable::KnownTypos.include?(value)
|
|
95
|
-
|
|
96
|
-
unless Nondisposable::ConfirmableEmail.confirmed?(record, attribute)
|
|
97
|
-
# Typed, so a form can tell this error apart and offer the
|
|
98
|
-
# "yes, this is right" checkbox: errors.of_kind?(:email, :lookalike_domain).
|
|
99
|
-
record.errors.add(attribute, :lookalike_domain, message: message, suggestion: suggestion)
|
|
100
|
-
return
|
|
101
|
-
end
|
|
154
|
+
if suggestion && !Nondisposable::ConfirmableEmail.confirmed?(record, attribute)
|
|
155
|
+
return add_lookalike_error(record, attribute, config, suggestion, confirmable: true)
|
|
102
156
|
end
|
|
103
157
|
end
|
|
104
158
|
|
|
105
|
-
|
|
106
|
-
return reject(record, attribute, config.
|
|
159
|
+
# 4. The TLD itself.
|
|
160
|
+
return reject(record, attribute, config.invalid_tld_error_message) if spelling.invalid
|
|
161
|
+
return reject(record, attribute, config.blocked_tld_error_message) if spelling.blocked
|
|
107
162
|
rescue StandardError => e
|
|
108
163
|
if Nondisposable.configuration.on_check_failure == :reject
|
|
109
164
|
Rails.logger.error "[nondisposable] Nondisposable validation error: #{e.message} — rejecting #{attribute} (on_check_failure = :reject)"
|
|
@@ -116,6 +171,152 @@ module ActiveModel
|
|
|
116
171
|
|
|
117
172
|
private
|
|
118
173
|
|
|
174
|
+
# What the TLD and lookalike checks make of an address BEFORE anything
|
|
175
|
+
# costly runs: no database, and no ranker unless the ending is
|
|
176
|
+
# impossible.
|
|
177
|
+
#
|
|
178
|
+
# correction a CERTAIN correction (see #certain_correction), or nil
|
|
179
|
+
# impossible the ending is not a TLD at all (`.con`, or no dot)
|
|
180
|
+
# invalid the TLD fails check_tld
|
|
181
|
+
# blocked the TLD is real, but blocked_tlds/allowed_tlds refuse it
|
|
182
|
+
Spelling = Struct.new(:correction, :impossible, :invalid, :blocked, keyword_init: true)
|
|
183
|
+
private_constant :Spelling
|
|
184
|
+
|
|
185
|
+
# nil when neither check applies to this value at all.
|
|
186
|
+
def spelling_of(value, config)
|
|
187
|
+
return nil unless config.check_tld || config.reject_lookalike_domains
|
|
188
|
+
# No "@" at all is a malformed value, and this validator has never
|
|
189
|
+
# judged shape — that is what `format:` is for (see the README). Note
|
|
190
|
+
# `example@gmailmcom` DOES have one: a domain missing its dot is a real
|
|
191
|
+
# address at an impossible domain, which is very much our business.
|
|
192
|
+
return nil unless value.to_s.include?('@')
|
|
193
|
+
return nil unless Nondisposable::Tld.judgeable?(value)
|
|
194
|
+
|
|
195
|
+
# nil here means the domain has NO TLD (`example@gmailmcom` — a
|
|
196
|
+
# production-shaped typo that missed the dot), which fails "must end in a real TLD"
|
|
197
|
+
# just as surely as `.con` does.
|
|
198
|
+
tld = Nondisposable::Tld.extract(value)
|
|
199
|
+
impossible = tld.nil? || !Nondisposable::Tld.valid?(tld)
|
|
200
|
+
invalid = config.check_tld && impossible
|
|
201
|
+
|
|
202
|
+
Spelling.new(
|
|
203
|
+
correction: certain_correction(value, config, impossible),
|
|
204
|
+
impossible: impossible,
|
|
205
|
+
invalid: invalid,
|
|
206
|
+
blocked: config.check_tld && !invalid && Nondisposable::Tld.blocked?(tld)
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A correction no confirmation can override, or nil. Two kinds:
|
|
211
|
+
#
|
|
212
|
+
# - An impossible ending with anything the ranker can name (`gmail.con`):
|
|
213
|
+
# no mail reaches `.con`, so the guess about WHICH address they meant
|
|
214
|
+
# is the only uncertain part, and the refusal is not. Applies whether
|
|
215
|
+
# or not the host turned check_tld on — `gmail.con` cannot receive the
|
|
216
|
+
# confirmation email either way.
|
|
217
|
+
# - A reviewed typo (`gmial.com`): somebody checked; nobody has a mailbox
|
|
218
|
+
# there. Only with reject_lookalike_domains on, and not at
|
|
219
|
+
# lookalike_distance 0, which switches the whole layer off.
|
|
220
|
+
#
|
|
221
|
+
# `@gmial.com` has no local part to build "Did you mean ..." from, but
|
|
222
|
+
# it is still a reviewed typo: name the domain instead.
|
|
223
|
+
def certain_correction(value, config, impossible)
|
|
224
|
+
return nil unless config.lookalike_distance.to_i.positive?
|
|
225
|
+
|
|
226
|
+
if impossible
|
|
227
|
+
Nondisposable::Suggestion.for(value) || Nondisposable::KnownTypos.correction_for(value)
|
|
228
|
+
elsif config.reject_lookalike_domains && Nondisposable::KnownTypos.include?(value)
|
|
229
|
+
# Suggestion.for asks the reviewed list before the ranker, so this
|
|
230
|
+
# never pays for a distance walk.
|
|
231
|
+
Nondisposable::Suggestion.for(value) || Nondisposable::KnownTypos.correction_for(value)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The "Did you mean …?" error, hard or confirmable.
|
|
236
|
+
#
|
|
237
|
+
# The confirmable one is always typed :lookalike_domain (with the
|
|
238
|
+
# suggestion in its details), so a form can tell it apart and offer the
|
|
239
|
+
# "yes, this is right" checkbox: errors.of_kind?(:email, :lookalike_domain).
|
|
240
|
+
# The hard one is a plain message, exactly as before 0.5.0 — or, for a
|
|
241
|
+
# Symbol, an error of that name, just as error_message and the TLD
|
|
242
|
+
# messages are added.
|
|
243
|
+
#
|
|
244
|
+
# A Symbol resolves through the same ActiveModel chain as every other
|
|
245
|
+
# Rails error name, and the confirmable error still honours a per-model
|
|
246
|
+
# `lookalike_domain` translation (#confirmable_message) — but NOW, once,
|
|
247
|
+
# into a String (see Nondisposable::LookalikeError for why it is never
|
|
248
|
+
# resolved again). A
|
|
249
|
+
# translation that is missing, names a variable we don't pass, or is a
|
|
250
|
+
# whole group (a Hash) is a refusal with a broken message, and we refuse
|
|
251
|
+
# with the default message instead — never a raise, which
|
|
252
|
+
# on_check_failure = :allow would turn into an acceptance.
|
|
253
|
+
def add_lookalike_error(record, attribute, config, suggestion, confirmable:)
|
|
254
|
+
key = config.lookalike_error_message
|
|
255
|
+
|
|
256
|
+
if confirmable
|
|
257
|
+
add_resolved(record, attribute, :lookalike_domain, confirmable_message(record, attribute, config, suggestion), suggestion)
|
|
258
|
+
elsif key.is_a?(Symbol)
|
|
259
|
+
message = resolve_rails_message(record, attribute, config, key, suggestion) || config.default_lookalike_message(suggestion)
|
|
260
|
+
add_resolved(record, attribute, key, message, suggestion)
|
|
261
|
+
else
|
|
262
|
+
# A plain String error, as since 0.5.0: no translation lookup, and
|
|
263
|
+
# never interpolated when read, so nothing to freeze.
|
|
264
|
+
record.errors.add(attribute, config.lookalike_message(suggestion))
|
|
265
|
+
end
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The confirmable error's text, computed exactly as ActiveModel used to
|
|
270
|
+
# compute it on every read of `errors.add(attr, :lookalike_domain,
|
|
271
|
+
# message: …)`, but once:
|
|
272
|
+
#
|
|
273
|
+
# - A Symbol replaces the type in Rails' lookup, so it is its own
|
|
274
|
+
# chain (#resolve_rails_message).
|
|
275
|
+
# - Anything else is the default for the FIRST key of the chain,
|
|
276
|
+
# activemodel/activerecord.errors.models.<model>.attributes.<attr>.lookalike_domain,
|
|
277
|
+
# so an app that translated that key keeps its translation (it did
|
|
278
|
+
# until 0.6.1, and must not lose it on upgrade). A String goes in as
|
|
279
|
+
# the template it is, with %{suggestion} as a VALUE: I18n never
|
|
280
|
+
# rescans a value, so a `%{x}` the user typed is printed, not
|
|
281
|
+
# interpolated. A callable's (or a #sub object's) output is already
|
|
282
|
+
# final, so its `%` are escaped for the same reason.
|
|
283
|
+
def confirmable_message(record, attribute, config, suggestion)
|
|
284
|
+
key = config.lookalike_error_message
|
|
285
|
+
if key.is_a?(Symbol)
|
|
286
|
+
return resolve_rails_message(record, attribute, config, key, suggestion) || config.default_lookalike_message(suggestion)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
template = key.is_a?(String) ? key : config.lookalike_message(suggestion).gsub('%', '%%')
|
|
290
|
+
text = record.errors.generate_message(attribute, :lookalike_domain, message: template, suggestion: suggestion)
|
|
291
|
+
raise TypeError, "it resolved to #{text.class}, not a String" unless text.is_a?(String)
|
|
292
|
+
|
|
293
|
+
text
|
|
294
|
+
rescue StandardError => e
|
|
295
|
+
config.default_lookalike_message(suggestion, e) # logs it
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Errors#add would build an ActiveModel::Error that re-resolves its
|
|
299
|
+
# message on every read; this appends one that does not, the same way
|
|
300
|
+
# Errors#add appends its own.
|
|
301
|
+
def add_resolved(record, attribute, type, message, suggestion)
|
|
302
|
+
record.errors.objects.append(
|
|
303
|
+
Nondisposable::LookalikeError.new(record, attribute.to_sym, type, message, suggestion: suggestion)
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# This Rails error name as a String for this record, in the current
|
|
308
|
+
# locale (and its I18n fallbacks, when the host has them on), or nil
|
|
309
|
+
# when it cannot be one.
|
|
310
|
+
def resolve_rails_message(record, attribute, config, key, suggestion)
|
|
311
|
+
text = record.errors.generate_message(attribute, key, suggestion: suggestion, raise: true)
|
|
312
|
+
raise TypeError, "it resolved to #{text.class}, not a String" unless text.is_a?(String)
|
|
313
|
+
|
|
314
|
+
text
|
|
315
|
+
rescue StandardError => e
|
|
316
|
+
config.default_lookalike_message(suggestion, e) # logs it
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
119
320
|
def reject(record, attribute, message)
|
|
120
321
|
record.errors.add(attribute, message)
|
|
121
322
|
nil
|
|
@@ -17,6 +17,14 @@ module Nondisposable
|
|
|
17
17
|
# test/fixtures/lookalike_corpus.csv, and the test suite fails if an entry
|
|
18
18
|
# loses it. Nothing generates this list automatically; see the file's header
|
|
19
19
|
# for the bar an entry has to clear.
|
|
20
|
+
#
|
|
21
|
+
# Upstream blocklists list some of these too: the bundled snapshot has
|
|
22
|
+
# `gmial.com`, `hotmai.com` and `hotmial.com`. That is left alone on purpose: both lists say "refuse it", and dropping the typos from
|
|
23
|
+
# the stored list would let them through for every host that never switched
|
|
24
|
+
# the lookalike layer on, and for every Nondisposable.disposable? caller.
|
|
25
|
+
# What the overlap must not change is the MESSAGE, and the validator settles
|
|
26
|
+
# that by asking this list before the throwaway check (see
|
|
27
|
+
# NondisposableValidator), whatever the daily update brings in.
|
|
20
28
|
module KnownTypos
|
|
21
29
|
LIST_PATH = File.expand_path('../../data/known_typo_domains.txt', __dir__)
|
|
22
30
|
|
data/lib/nondisposable.rb
CHANGED
|
@@ -70,6 +70,7 @@ module Nondisposable
|
|
|
70
70
|
class Configuration
|
|
71
71
|
ON_CHECK_FAILURE_MODES = [:allow, :reject].freeze
|
|
72
72
|
LOOKALIKE_MODES = [false, true, :confirmable].freeze
|
|
73
|
+
DEFAULT_LOOKALIKE_ERROR_MESSAGE = "looks like a typo. Did you mean %{suggestion}?"
|
|
73
74
|
|
|
74
75
|
attr_accessor :error_message, :additional_domains, :excluded_domains
|
|
75
76
|
# Whether to also match parent domains: with the default true, an email at
|
|
@@ -154,8 +155,22 @@ module Nondisposable
|
|
|
154
155
|
# ---- Error messages -----------------------------------------------------
|
|
155
156
|
|
|
156
157
|
attr_accessor :invalid_tld_error_message, :blocked_tld_error_message
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
|
|
159
|
+
# "Did you mean …?", in one of three forms:
|
|
160
|
+
#
|
|
161
|
+
# "looks like a typo. Did you mean %{suggestion}?" a String (default)
|
|
162
|
+
# :email_lookalike a Rails error name
|
|
163
|
+
# ->(suggestion) { "…#{suggestion}…" } a callable
|
|
164
|
+
#
|
|
165
|
+
# %{suggestion} is the full corrected address (or the corrected domain,
|
|
166
|
+
# for a value with nothing before the "@").
|
|
167
|
+
#
|
|
168
|
+
# A Symbol means what it means for error_message and the TLD messages: a
|
|
169
|
+
# Rails error name, looked up through ActiveModel's chain
|
|
170
|
+
# (activerecord.errors.models.user.attributes.email.email_lookalike, …,
|
|
171
|
+
# errors.messages.email_lookalike), with %{suggestion} as well as
|
|
172
|
+
# %{attribute} and %{value} to interpolate.
|
|
173
|
+
attr_reader :lookalike_error_message
|
|
159
174
|
|
|
160
175
|
def initialize
|
|
161
176
|
@error_message = "provider is not allowed"
|
|
@@ -179,7 +194,79 @@ module Nondisposable
|
|
|
179
194
|
|
|
180
195
|
@invalid_tld_error_message = "doesn't look like a real email address"
|
|
181
196
|
@blocked_tld_error_message = "domain ending is not allowed"
|
|
182
|
-
@lookalike_error_message =
|
|
197
|
+
@lookalike_error_message = DEFAULT_LOOKALIKE_ERROR_MESSAGE
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# `:lookalike_domain` is the TYPE of the confirmable error — the one a form
|
|
201
|
+
# offers "yes, this is right" for (errors.of_kind?(:email, :lookalike_domain)).
|
|
202
|
+
# As the name of the hard error too it would put that checkbox next to a
|
|
203
|
+
# refusal no confirmation can lift, so it fails at boot instead.
|
|
204
|
+
def lookalike_error_message=(message)
|
|
205
|
+
if message == :lookalike_domain
|
|
206
|
+
raise ArgumentError, "lookalike_error_message cannot be :lookalike_domain, the type of the confirmable error: " \
|
|
207
|
+
"name the translation anything else (e.g. :email_lookalike)"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
@lookalike_error_message = message
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The lookalike error for this suggestion as a String, resolved NOW, at
|
|
214
|
+
# validation time, so it answers in the locale of the request being
|
|
215
|
+
# validated rather than whichever locale the initializer ran in.
|
|
216
|
+
#
|
|
217
|
+
# - A Symbol: outside a model there is no ActiveModel chain to walk, so
|
|
218
|
+
# this looks up its last link, errors.messages.<name>. (The validator
|
|
219
|
+
# adds a Symbol through errors.add instead, so a per-model or
|
|
220
|
+
# per-attribute translation wins there.)
|
|
221
|
+
# - Anything that responds to #call is called with the suggestion.
|
|
222
|
+
# - Anything else is treated as before 0.6.2: `%{suggestion}` replaced
|
|
223
|
+
# with #sub. That keeps a String exactly as it was, and keeps working
|
|
224
|
+
# any object a host passed that answers #sub (the only way to localise
|
|
225
|
+
# this message until now). A String is substituted with the block
|
|
226
|
+
# form, so a `\0` or `\&` typed in the local part is printed, not read
|
|
227
|
+
# as a back-reference.
|
|
228
|
+
#
|
|
229
|
+
# NEVER RAISES. By the time we phrase this message the address is already
|
|
230
|
+
# refused; the only open question is the wording. A missing translation,
|
|
231
|
+
# one naming a variable we don't pass (`%{address}`), a key naming a whole
|
|
232
|
+
# translation group (a Hash), a nil message, or a callable that raises or
|
|
233
|
+
# takes no argument would otherwise reach the validator's rescue, where
|
|
234
|
+
# the default on_check_failure = :allow ACCEPTS the address — a listed
|
|
235
|
+
# typo let through by a broken locale file. So any failure, or anything
|
|
236
|
+
# that is not a String, falls back to the default English message, logged
|
|
237
|
+
# so the host can fix it.
|
|
238
|
+
def lookalike_message(suggestion)
|
|
239
|
+
message = lookalike_error_message
|
|
240
|
+
text =
|
|
241
|
+
if message.is_a?(Symbol)
|
|
242
|
+
I18n.t(:"errors.messages.#{message}", suggestion: suggestion, raise: true)
|
|
243
|
+
elsif message.respond_to?(:call)
|
|
244
|
+
message.call(suggestion)
|
|
245
|
+
elsif message.is_a?(String)
|
|
246
|
+
message.sub('%{suggestion}') { suggestion }
|
|
247
|
+
else
|
|
248
|
+
message.sub('%{suggestion}', suggestion) # a host's own object, called exactly as before 0.6.2
|
|
249
|
+
end
|
|
250
|
+
raise TypeError, "it resolved to #{text.class}, not a String" unless text.is_a?(String)
|
|
251
|
+
|
|
252
|
+
text
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
default_lookalike_message(suggestion, e)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The gem's own English message, for when the host's cannot be used.
|
|
258
|
+
# Given the error that made it necessary, logs it.
|
|
259
|
+
def default_lookalike_message(suggestion, error = nil)
|
|
260
|
+
if error
|
|
261
|
+
log_message = "[nondisposable] Could not use lookalike_error_message #{lookalike_error_message.inspect} " \
|
|
262
|
+
"(#{error.class}: #{error.message}); using the default message, the address is still refused."
|
|
263
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
264
|
+
Rails.logger.error(log_message)
|
|
265
|
+
else
|
|
266
|
+
warn log_message
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
DEFAULT_LOOKALIKE_ERROR_MESSAGE.sub('%{suggestion}') { suggestion.to_s }
|
|
183
270
|
end
|
|
184
271
|
|
|
185
272
|
# What the validator does when the disposable check itself raises
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: nondisposable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|