nondisposable 0.6.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96ee85d55e05d52d472cb9a4b9897e318499282c35818579621daec1bcd7bf9e
4
- data.tar.gz: f633e949328cb2972dfdff7c44f33072b29dae4b5bd95ab116af24ec504f676c
3
+ metadata.gz: 2193d2a242a699cdd8b05767d364d5da7f039f5f2e3203592d0f3222bb8c29db
4
+ data.tar.gz: bb866074eb28008df4eecd8fab1df646ebbde5eddd6907dfaa43126c3348a315
5
5
  SHA512:
6
- metadata.gz: 71c6fbbfcac0534c320decc1cee69147497984a9dc12f19569a2231eacc192a5a2cc328e88decdc5e8f692384bf8ae39705a9c77ff134fb5cccc02fc455c133d
7
- data.tar.gz: 8abdd8d29e8bfeb5f51da7cc98a6b213a8af428b561a8be6a9d2e84bce470f96a5e2c5bbeeff2f9a4e0dda0d719f5e696562c97b9643278a5d92a5c90ae8ff06
6
+ metadata.gz: d88abe6d8b21e332bab766e6a132a1d3e311bba0f067c4a80e5148172249465a58d8053ce3d100d842a3bfebe08dd8910daed05aa8f10d622d9e7a8802ebffa7
7
+ data.tar.gz: 967dbdbfea2858290d97b150b8e19827314c7031250fd69c274b14a06d4bd9036999ef180a794bc76537c785847babae3a707f69e0837c61b018f159579259b7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
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
+
1
13
  ## [0.6.1] - 2026-10-17
2
14
 
3
15
  ### Fixed
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 still refused first, and a blocked TLD is still refused after a confirmation. A host's `additional_email_providers` outranks the known-typo list.
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:
@@ -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
@@ -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, in the order a human
7
- # would ask them, and AT MOST ONE error however many of them fire —
8
- # stacking "provider is not allowed" on top of "did you mean gmail.com?"
9
- # helps nobody:
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
- # 1. Is this a throwaway provider? (always on — the gem's whole job)
12
- # 2. Can we name the address they meant? (config.reject_lookalike_domains,
13
- # or any invalid TLD we can correct — see below)
14
- # 3. Is the TLD real, and allowed? (config.check_tld)
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 2 and 3. "Doesn't look like a real email
19
- # address" is true but useless; "Did you mean user@gmail.com?" is the same
20
- # verdict with the fix attached. So whenever we can name a correction we
21
- # use that message, whether the TLD was invalid or merely lookalike. The
22
- # rule is: a suggestion, if we have one, always phrases the rejection.
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 config.check_tld || config.reject_lookalike_domains
66
- # No "@" at all is a malformed value, and this validator has never
67
- # judged shape — that is what `format:` is for (see the README). Note
68
- # `example@gmailmcom` DOES have one: a domain missing its dot is a real
69
- # address at an impossible domain, which is very much our business.
70
- return unless value.to_s.include?('@')
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
- # `@gmial.com` has no local part to build "Did you mean ..." from,
84
- # but it is still a reviewed typo: name the domain instead.
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
- return reject(record, attribute, config.invalid_tld_error_message) if invalid
106
- return reject(record, attribute, config.blocked_tld_error_message) if blocked
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nondisposable
4
- VERSION = "0.6.1"
4
+ VERSION = "0.6.2"
5
5
  end
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
- # %{suggestion} is replaced with the full corrected address.
158
- attr_accessor :lookalike_error_message
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 = "looks like a typo. Did you mean %{suggestion}?"
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nondisposable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez