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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +185 -6
- data/app/models/nondisposable/disposable_domain.rb +53 -34
- data/app/models/nondisposable/source_snapshot.rb +66 -0
- data/data/known_typo_domains.txt +99 -0
- data/data/never_disposable.txt +480 -0
- data/data/privacy_relays.txt +42 -0
- data/data/public_suffix_list.dat +16571 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/generators/nondisposable/install_generator.rb +1 -0
- data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
- data/lib/generators/nondisposable/templates/nondisposable.rb +29 -7
- data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
- data/lib/nondisposable/confirmable_email.rb +75 -0
- data/lib/nondisposable/domain.rb +238 -0
- data/lib/nondisposable/domain_list_updater.rb +510 -88
- data/lib/nondisposable/email_validator.rb +51 -4
- data/lib/nondisposable/known_typos.rb +64 -0
- data/lib/nondisposable/precedence.rb +209 -0
- data/lib/nondisposable/public_suffix.rb +147 -0
- data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
- data/lib/nondisposable/source.rb +233 -0
- data/lib/nondisposable/source_fetcher.rb +69 -0
- data/lib/nondisposable/source_merge.rb +127 -0
- data/lib/nondisposable/suggestion.rb +201 -31
- data/lib/nondisposable/tld.rb +8 -3
- data/lib/nondisposable/version.rb +1 -1
- data/lib/nondisposable.rb +68 -13
- data/lib/tasks/nondisposable.rake +29 -0
- metadata +18 -2
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.0.gemfile
CHANGED
data/gemfiles/rails_8.1.gemfile
CHANGED
|
@@ -16,6 +16,7 @@ module Nondisposable
|
|
|
16
16
|
|
|
17
17
|
def create_migration_file
|
|
18
18
|
migration_template 'create_nondisposable_disposable_domains.rb.erb', File.join(db_migrate_path, "create_nondisposable_disposable_domains.rb")
|
|
19
|
+
migration_template 'create_nondisposable_source_snapshots.rb.erb', File.join(db_migrate_path, "create_nondisposable_source_snapshots.rb")
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def create_initializer
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
class CreateNondisposableSourceSnapshots < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
# One row per fetch of one blocklist source, so the merged
|
|
3
|
+
# nondisposable_disposable_domains table can always be rebuilt from each
|
|
4
|
+
# source's last good list (see Nondisposable::SourceSnapshot).
|
|
5
|
+
#
|
|
6
|
+
# Additive and safe to roll back: until this table exists, and again after
|
|
7
|
+
# dropping it, Nondisposable::DomainListUpdater uses the single default list
|
|
8
|
+
# exactly as before.
|
|
9
|
+
def change
|
|
10
|
+
primary_key_type = primary_key_type_setting
|
|
11
|
+
|
|
12
|
+
create_table :nondisposable_source_snapshots, id: primary_key_type do |t|
|
|
13
|
+
t.string :source, null: false
|
|
14
|
+
t.string :url
|
|
15
|
+
t.string :status, null: false
|
|
16
|
+
t.datetime :fetched_at, null: false
|
|
17
|
+
t.string :sha256
|
|
18
|
+
t.integer :domain_count
|
|
19
|
+
# Largest domain_count ever accepted for this source (the shrink guard's
|
|
20
|
+
# baseline), carried forward on each good snapshot so pruning never
|
|
21
|
+
# forgets it.
|
|
22
|
+
t.integer :high_water_count
|
|
23
|
+
# The largest list we know of is ~1.4 MB. The limit only matters on
|
|
24
|
+
# MySQL, where plain TEXT stops at 64 KB (this becomes LONGTEXT);
|
|
25
|
+
# PostgreSQL and SQLite ignore it.
|
|
26
|
+
t.text :domains, limit: 16.megabytes
|
|
27
|
+
t.text :error
|
|
28
|
+
|
|
29
|
+
t.timestamps
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
add_index :nondisposable_source_snapshots, [:source, :status, :fetched_at],
|
|
33
|
+
name: "index_nondisposable_source_snapshots_lookup"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def primary_key_type_setting
|
|
39
|
+
config = Rails.configuration.generators
|
|
40
|
+
config.options[config.orm][:primary_key_type] || :primary_key
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -14,9 +14,29 @@ Nondisposable.configure do |config|
|
|
|
14
14
|
# config.on_check_failure = :allow
|
|
15
15
|
#
|
|
16
16
|
# Also match parent domains: an email at x.tempmail.com is blocked when
|
|
17
|
-
# tempmail.com is on the list (checks
|
|
17
|
+
# tempmail.com is on the list (checks every parent down to the registrable domain). Default: true
|
|
18
18
|
# config.check_parent_domains = true
|
|
19
19
|
|
|
20
|
+
# ---- Where the blocklist comes from ----
|
|
21
|
+
#
|
|
22
|
+
# Default: the single disposable-email-domains list this gem has always used.
|
|
23
|
+
# RECOMMENDED adds fakefilter, a second list that watches temp-mail sites
|
|
24
|
+
# directly (+3,275 domains on 2026-10-17, no known real provider among them).
|
|
25
|
+
# config.sources = Nondisposable::Sources::RECOMMENDED
|
|
26
|
+
#
|
|
27
|
+
# :corroborating sources only block a domain once this many INDEPENDENT
|
|
28
|
+
# lists carry it (a list that copies another doesn't count twice).
|
|
29
|
+
# config.corroboration_threshold = 2
|
|
30
|
+
#
|
|
31
|
+
# Privacy relays (Hide My Email, DuckDuckGo, Firefox Relay, SimpleLogin,
|
|
32
|
+
# addy.io) forward to a real inbox. Allowed by default, even when an upstream
|
|
33
|
+
# list carries them; set to true to refuse them.
|
|
34
|
+
# config.block_privacy_relays = false
|
|
35
|
+
#
|
|
36
|
+
# Who wins when lists disagree (top wins): excluded_domains, the gem's
|
|
37
|
+
# never-disposable list of real providers, additional_domains, the
|
|
38
|
+
# privacy-relay setting, the upstream sources.
|
|
39
|
+
|
|
20
40
|
# ---- Catching typos ----
|
|
21
41
|
#
|
|
22
42
|
# A disposable address is someone hiding from you. A typo is someone who
|
|
@@ -39,15 +59,17 @@ Nondisposable.configure do |config|
|
|
|
39
59
|
# customer you have.
|
|
40
60
|
# config.allowed_tlds = nil
|
|
41
61
|
|
|
42
|
-
#
|
|
62
|
+
# Flag addresses one keystroke from a well-known provider (gmail.co,
|
|
43
63
|
# gmial.com). A TLD check CANNOT catch these — .co, .cm and .om are Colombia,
|
|
44
64
|
# Cameroon and Oman, all real.
|
|
45
65
|
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
66
|
+
# Reviewed typos (data/known_typo_domains.txt) are refused outright. Every
|
|
67
|
+
# other lookalike is a guess — ge.com is one edit from me.com and is General
|
|
68
|
+
# Electric — so it is a CONFIRMABLE error: the user ticks "yes, this is my
|
|
69
|
+
# address" and passes. Wire that checkbox first (see
|
|
70
|
+
# Nondisposable::ConfirmableEmail in the README), or a real user at a
|
|
71
|
+
# lookalike domain has no way through. `true` means the same as :confirmable.
|
|
72
|
+
# config.reject_lookalike_domains = :confirmable
|
|
51
73
|
#
|
|
52
74
|
# How many edits still count as a typo. 1 is safe; 2 starts colliding with
|
|
53
75
|
# genuinely different domains. 0 disables suggestions entirely.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators/base'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module Nondisposable
|
|
7
|
+
module Generators
|
|
8
|
+
# For apps installed before 0.6.0: adds the source snapshots table that
|
|
9
|
+
# multi-source blocklists need. Purely additive — it touches no existing
|
|
10
|
+
# table, and the app keeps working the old way until it is migrated.
|
|
11
|
+
#
|
|
12
|
+
# rails generate nondisposable:upgrade
|
|
13
|
+
# rails db:migrate
|
|
14
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path('templates', __dir__)
|
|
18
|
+
|
|
19
|
+
def self.next_migration_number(dir)
|
|
20
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_source_snapshots_migration
|
|
24
|
+
migration_template 'create_nondisposable_source_snapshots.rb.erb',
|
|
25
|
+
File.join(db_migrate_path, 'create_nondisposable_source_snapshots.rb')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def display_post_upgrade_message
|
|
29
|
+
say "\tAdded the nondisposable_source_snapshots migration.", :green
|
|
30
|
+
say "\nNext:"
|
|
31
|
+
say " 1. Run 'rails db:migrate'. Until you do, the gem keeps using the single default list, so deploying first is safe."
|
|
32
|
+
say " 2. Optionally choose your sources in config/initializers/nondisposable.rb, e.g. config.sources = Nondisposable::Sources::RECOMMENDED"
|
|
33
|
+
say " 3. Run 'Nondisposable::DomainListUpdater.update' (or wait for your scheduled job)."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def migration_version
|
|
39
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
require 'active_model/type'
|
|
5
|
+
|
|
6
|
+
module Nondisposable
|
|
7
|
+
# The way out of a lookalike error: "yes, this address is right".
|
|
8
|
+
#
|
|
9
|
+
# With `reject_lookalike_domains` on, `someone@ge.com` gets "looks like a
|
|
10
|
+
# typo. Did you mean someone@me.com?" — and General Electric's staff are not
|
|
11
|
+
# mistyping anything. Offline the gem cannot tell them from `gmial.com`, so
|
|
12
|
+
# instead of refusing, it asks. This concern gives the record somewhere to
|
|
13
|
+
# put the answer:
|
|
14
|
+
#
|
|
15
|
+
# class User < ApplicationRecord
|
|
16
|
+
# include Nondisposable::ConfirmableEmail
|
|
17
|
+
# confirmable_email :email # defines email_lookalike_confirmed
|
|
18
|
+
#
|
|
19
|
+
# validates :email, nondisposable: true
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # In the form, only when that error is showing:
|
|
23
|
+
# <% if @user.errors.of_kind?(:email, :lookalike_domain) %>
|
|
24
|
+
# <%= f.check_box :email_lookalike_confirmed %>
|
|
25
|
+
# <%= f.label :email_lookalike_confirmed, "Sí, mi email es correcto" %>
|
|
26
|
+
# <% end %>
|
|
27
|
+
#
|
|
28
|
+
# # And permit it: params.require(:user).permit(:email, :email_lookalike_confirmed)
|
|
29
|
+
#
|
|
30
|
+
# A confirmed record passes the lookalike check and nothing else: a
|
|
31
|
+
# disposable provider, a TLD that does not exist, a blocked TLD and a
|
|
32
|
+
# reviewed typo (Nondisposable::KnownTypos) are still refused, because for
|
|
33
|
+
# those there is no address the user could be right about.
|
|
34
|
+
#
|
|
35
|
+
# The attribute is virtual — never persisted — and is reset after every
|
|
36
|
+
# save, so it answers for one save: a later lookalike is asked again.
|
|
37
|
+
#
|
|
38
|
+
# No concern needed if you would rather wire it yourself: the validator
|
|
39
|
+
# looks for a method named `<attribute>_lookalike_confirmed` returning true,
|
|
40
|
+
# however the record defines it.
|
|
41
|
+
module ConfirmableEmail
|
|
42
|
+
extend ActiveSupport::Concern
|
|
43
|
+
|
|
44
|
+
SUFFIX = '_lookalike_confirmed'
|
|
45
|
+
|
|
46
|
+
class_methods do
|
|
47
|
+
def confirmable_email(*attributes)
|
|
48
|
+
attributes.each do |attribute|
|
|
49
|
+
name = "#{attribute}#{SUFFIX}"
|
|
50
|
+
ivar = "@#{name}"
|
|
51
|
+
|
|
52
|
+
define_method(name) { instance_variable_defined?(ivar) ? instance_variable_get(ivar) : false }
|
|
53
|
+
# Form params arrive as "1", "0", "true", "" — cast the way Rails
|
|
54
|
+
# casts a boolean column, so a checkbox works without ceremony.
|
|
55
|
+
define_method("#{name}=") { |value| instance_variable_set(ivar, ActiveModel::Type::Boolean.new.cast(value) || false) }
|
|
56
|
+
alias_method "#{name}?", name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Cleared after every save, so a record that was confirmed once and
|
|
60
|
+
# then given a different lookalike address is asked again.
|
|
61
|
+
return unless respond_to?(:after_save)
|
|
62
|
+
|
|
63
|
+
names = attributes.map { |attribute| "#{attribute}#{SUFFIX}" }
|
|
64
|
+
after_save { names.each { |name| public_send("#{name}=", false) } }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Has the user confirmed this attribute's address as typed? Strictly
|
|
69
|
+
# `true`: a truthy string a host forgot to cast must not count as a "yes".
|
|
70
|
+
def self.confirmed?(record, attribute)
|
|
71
|
+
reader = "#{attribute}#{SUFFIX}"
|
|
72
|
+
record.respond_to?(reader) && record.public_send(reader) == true
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nondisposable
|
|
4
|
+
# One answer to "is this the same domain?", shared by every path that
|
|
5
|
+
# compares a domain against a list.
|
|
6
|
+
#
|
|
7
|
+
# Before this existed each path trimmed its input its own way: the suggestion
|
|
8
|
+
# layer stripped a trailing dot and the database check did not, so
|
|
9
|
+
# `user@gmial.com.` and `user@gmial.com` could get different verdicts from the
|
|
10
|
+
# same gem. Anything that matches a domain against a list — bundled, host
|
|
11
|
+
# configured or downloaded — should go through #normalize first, so that two
|
|
12
|
+
# spellings of one host can never be judged differently.
|
|
13
|
+
#
|
|
14
|
+
# WHAT IT DOES
|
|
15
|
+
#
|
|
16
|
+
# Nondisposable::Domain.normalize(" GMail.COM. ") # => "gmail.com"
|
|
17
|
+
# Nondisposable::Domain.normalize("bücher.de") # => "xn--bcher-kva.de"
|
|
18
|
+
# Nondisposable::Domain.normalize("XN--BCHER-KVA.DE") # => "xn--bcher-kva.de"
|
|
19
|
+
# Nondisposable::Domain.from_email("First+tag@GMail.com") # => "gmail.com"
|
|
20
|
+
#
|
|
21
|
+
# Only the DOMAIN is ever touched. The local part is case-sensitive under
|
|
22
|
+
# RFC 5321 and `+tags` are the user's business; nothing here reads or
|
|
23
|
+
# rewrites them.
|
|
24
|
+
#
|
|
25
|
+
# WHY PUNYCODE, AND WHY NO DEPENDENCY
|
|
26
|
+
#
|
|
27
|
+
# A unicode domain and its punycode form are the same host (`bücher.de` ==
|
|
28
|
+
# `xn--bcher-kva.de`), and lists store the ASCII form, so matching has to
|
|
29
|
+
# happen in ASCII. The `simpleidn` / `addressable` gems would do it, but the
|
|
30
|
+
# encoding itself (RFC 3492) is forty lines of arithmetic and this gem ships
|
|
31
|
+
# with no runtime dependencies beyond Rails. What we deliberately do NOT
|
|
32
|
+
# implement is the full IDNA2008/UTS #46 validity table (which code points
|
|
33
|
+
# are disallowed): we map — NFKC, lowercase, the ideographic full stops, the
|
|
34
|
+
# invisible characters UTS #46 drops (nontransitionally: ZWJ/ZWNJ stay) — and
|
|
35
|
+
# encode, and leave "is this label legal" to the DNS. For matching that is
|
|
36
|
+
# the right trade: a domain we encode that the registry would have refused
|
|
37
|
+
# cannot be on anybody's list either.
|
|
38
|
+
module Domain
|
|
39
|
+
# The label separators UTS #46 treats as a full stop: ideographic,
|
|
40
|
+
# fullwidth and halfwidth ideographic. A user on a CJK keyboard types these.
|
|
41
|
+
DOTS = /[。.。]/.freeze
|
|
42
|
+
|
|
43
|
+
# Every code point UTS #46 maps to nothing ("ignored" in
|
|
44
|
+
# IdnaMappingTable.txt, Unicode 2026-06-29): soft hyphen, combining
|
|
45
|
+
# grapheme joiner, Hangul and Khmer fillers, Mongolian variation selectors,
|
|
46
|
+
# zero-width space, word joiner and invisible operators, variation
|
|
47
|
+
# selectors 1–256, BOM, shorthand format controls, musical beam controls.
|
|
48
|
+
# Invisible in a form field, so `maili<SHY>nator.com` would otherwise be a
|
|
49
|
+
# different string from the listed `mailinator.com` — while a mail server
|
|
50
|
+
# applying IDNA would deliver it to mailinator.com.
|
|
51
|
+
#
|
|
52
|
+
# ZWNJ and ZWJ (U+200C/D) are deliberately NOT here. UTS #46 lists them as
|
|
53
|
+
# "deviation" characters, and nontransitional processing (IDNA2008, what
|
|
54
|
+
# registries and browsers use) keeps them: `نامهای.com` with a ZWNJ is a
|
|
55
|
+
# different registered domain from the same letters without one.
|
|
56
|
+
IGNORED = /[͏ᅟᅠ឴឵᠋-᠏--ㅤ︀-️ᅠ\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0100}-\u{E01EF}]/.freeze
|
|
57
|
+
|
|
58
|
+
# DNS caps a name at 253 octets (RFC 1035 §2.3.4) and a label at 63, and
|
|
59
|
+
# the punycode encoder is quadratic in a label's distinct code points, so
|
|
60
|
+
# #normalize hands anything longer back unencoded: it cannot be a
|
|
61
|
+
# registered domain or match a stored row. The cap applies to the
|
|
62
|
+
# NORMALISED name — `e` + combining acute is two code points that NFKC
|
|
63
|
+
# composes into one `é`, so a valid 32-letter label can arrive as 64.
|
|
64
|
+
MAX_LENGTH = 253
|
|
65
|
+
MAX_LABEL_LENGTH = 63
|
|
66
|
+
|
|
67
|
+
# The bounds on what is fed to NFKC, which is superlinear on hostile input
|
|
68
|
+
# (a long run of combining marks to reorder). 1,024 code points is four
|
|
69
|
+
# per allowed character — room for any real decomposition (Hangul jamo
|
|
70
|
+
# take three) — and matches DisposableDomain::MAX_INPUT_LENGTH. A run of
|
|
71
|
+
# more than 30 combining marks is not stream-safe text (UAX #15 §13), so
|
|
72
|
+
# no registered label contains one and it is not worth normalising.
|
|
73
|
+
MAX_RAW_LENGTH = 1_024
|
|
74
|
+
UNSTREAMSAFE = /\p{M}{31,}/.freeze
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# RFC 3492 §5: the bootstring parameters that make it punycode.
|
|
78
|
+
BASE = 36
|
|
79
|
+
TMIN = 1
|
|
80
|
+
TMAX = 26
|
|
81
|
+
SKEW = 38
|
|
82
|
+
DAMP = 700
|
|
83
|
+
INITIAL_BIAS = 72
|
|
84
|
+
INITIAL_N = 128
|
|
85
|
+
private_constant :BASE, :TMIN, :TMAX, :SKEW, :DAMP, :INITIAL_BIAS, :INITIAL_N
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
# The canonical, ASCII form of a domain: stripped, lowercased, without a
|
|
89
|
+
# trailing root dot, unicode labels punycode-encoded. "" for blank input,
|
|
90
|
+
# never nil, so callers can chain without guarding.
|
|
91
|
+
def normalize(domain)
|
|
92
|
+
value = unicode(domain)
|
|
93
|
+
return value if value.ascii_only? || value.encoding == Encoding::BINARY
|
|
94
|
+
# The punycode encoder is quadratic in the distinct code points of a
|
|
95
|
+
# label: a 4,000-character label would cost seconds. Over DNS's limits
|
|
96
|
+
# it cannot be a registered domain; hand it back unencoded.
|
|
97
|
+
return value if oversized?(value)
|
|
98
|
+
|
|
99
|
+
value.split('.', -1).map { |label| label.ascii_only? ? label : "xn--#{punycode(label)}" }.join('.')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Longer than DNS allows once normalised — over 253 characters, a label
|
|
103
|
+
# over 63, or more than MAX_RAW_LENGTH code points before NFKC could
|
|
104
|
+
# even run? Such a name cannot be registered, so no address at it can
|
|
105
|
+
# receive mail, and no list can hold it: callers must treat it as
|
|
106
|
+
# invalid rather than as "not disposable". Judged on the punycode form,
|
|
107
|
+
# since a label that fits in unicode can still encode past 63.
|
|
108
|
+
def too_long?(domain)
|
|
109
|
+
oversized?(normalize(domain))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The same canonicalisation WITHOUT the punycode step, for code that
|
|
113
|
+
# compares what a human typed (the suggestion layer: a keyboard slip is a
|
|
114
|
+
# distance between the characters someone pressed, and punycode letters
|
|
115
|
+
# have no keyboard meaning — `xn--gmal-zqa` is `gmaíl`). Never raises.
|
|
116
|
+
def unicode(domain)
|
|
117
|
+
value = utf8(domain.to_s)
|
|
118
|
+
# Bytes that are not UTF-8 cannot be a domain anybody typed: keep a
|
|
119
|
+
# stable, comparable binary form instead of raising inside a validator.
|
|
120
|
+
return value.strip.downcase.delete_suffix('.') if value.encoding == Encoding::BINARY
|
|
121
|
+
|
|
122
|
+
value = value.strip
|
|
123
|
+
return value.downcase.delete_suffix('.') if value.ascii_only?
|
|
124
|
+
|
|
125
|
+
# Linear passes first, so the raw bound below sees what is left once
|
|
126
|
+
# the invisible padding is gone (300 soft hyphens around
|
|
127
|
+
# mailinator.com must not buy an exemption). The DNS limits are
|
|
128
|
+
# enforced by #normalize on the result, after NFKC has composed it.
|
|
129
|
+
value = value.gsub(IGNORED, '').gsub(DOTS, '.')
|
|
130
|
+
return value.downcase.delete_suffix('.') if value.length > MAX_RAW_LENGTH || value.match?(UNSTREAMSAFE)
|
|
131
|
+
|
|
132
|
+
# NFKC, not NFC: it also folds compatibility forms, so fullwidth
|
|
133
|
+
# `gmail` is `gmail` — the mapping UTS #46 applies before lookup.
|
|
134
|
+
value.unicode_normalize(:nfkc).gsub(DOTS, '.').downcase.delete_suffix('.')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The domain half of an address, normalised. Splits on the LAST "@", so a
|
|
138
|
+
# quoted local part containing one (`"a@b"@example.com`) still yields the
|
|
139
|
+
# right domain. "" when there is no "@" or nothing after it. An empty
|
|
140
|
+
# local part (`@tempmail.com`) still has a domain worth judging, so it
|
|
141
|
+
# is not this method's place to refuse it.
|
|
142
|
+
#
|
|
143
|
+
# `unicode: true` returns the #unicode form instead of the punycode one:
|
|
144
|
+
# what DisposableDomain needs to also match rows 0.4.0 stored unencoded.
|
|
145
|
+
def from_email(email, unicode: false)
|
|
146
|
+
email = email.to_s
|
|
147
|
+
email = email.b unless email.valid_encoding? # unreadable bytes must not raise
|
|
148
|
+
_local, at, domain = email.rpartition('@')
|
|
149
|
+
return '' if at.empty?
|
|
150
|
+
|
|
151
|
+
unicode ? unicode(domain) : normalize(domain)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# A String in UTF-8 when its bytes are UTF-8, whatever encoding it was
|
|
157
|
+
# labelled with: a form param or a log line handed over as ASCII-8BIT is
|
|
158
|
+
# still `bücher.de`, and 0.4.0 accepted it without raising.
|
|
159
|
+
def utf8(value)
|
|
160
|
+
return value if value.encoding == Encoding::UTF_8 && value.valid_encoding?
|
|
161
|
+
|
|
162
|
+
copy = value.dup.force_encoding(Encoding::UTF_8)
|
|
163
|
+
copy.valid_encoding? ? copy : value.b # BINARY: "not UTF-8"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def oversized?(value)
|
|
167
|
+
value.length > MAX_LENGTH || value.split('.', -1).any? { |label| label.length > MAX_LABEL_LENGTH }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# RFC 3492 §6.3, the encoder only: we never need to decode.
|
|
172
|
+
def punycode(label)
|
|
173
|
+
input = label.codepoints
|
|
174
|
+
output = input.select { |c| c < 0x80 }.pack('U*')
|
|
175
|
+
basic = output.length
|
|
176
|
+
handled = basic
|
|
177
|
+
output += '-' if basic.positive?
|
|
178
|
+
|
|
179
|
+
n = INITIAL_N
|
|
180
|
+
delta = 0
|
|
181
|
+
bias = INITIAL_BIAS
|
|
182
|
+
|
|
183
|
+
while handled < input.length
|
|
184
|
+
m = input.select { |c| c >= n }.min
|
|
185
|
+
delta += (m - n) * (handled + 1)
|
|
186
|
+
n = m
|
|
187
|
+
|
|
188
|
+
input.each do |c|
|
|
189
|
+
delta += 1 if c < n
|
|
190
|
+
next unless c == n
|
|
191
|
+
|
|
192
|
+
q = delta
|
|
193
|
+
k = BASE
|
|
194
|
+
loop do
|
|
195
|
+
t = threshold(k, bias)
|
|
196
|
+
break if q < t
|
|
197
|
+
|
|
198
|
+
output << digit(t + ((q - t) % (BASE - t)))
|
|
199
|
+
q = (q - t) / (BASE - t)
|
|
200
|
+
k += BASE
|
|
201
|
+
end
|
|
202
|
+
output << digit(q)
|
|
203
|
+
bias = adapt(delta, handled + 1, handled == basic)
|
|
204
|
+
delta = 0
|
|
205
|
+
handled += 1
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
delta += 1
|
|
209
|
+
n += 1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
output
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def threshold(k, bias)
|
|
216
|
+
return TMIN if k <= bias
|
|
217
|
+
return TMAX if k >= bias + TMAX
|
|
218
|
+
|
|
219
|
+
k - bias
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def digit(d)
|
|
223
|
+
(d < 26 ? d + 97 : d + 22).chr
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def adapt(delta, points, first)
|
|
227
|
+
delta = first ? delta / DAMP : delta / 2
|
|
228
|
+
delta += delta / points
|
|
229
|
+
k = 0
|
|
230
|
+
while delta > ((BASE - TMIN) * TMAX) / 2
|
|
231
|
+
delta /= BASE - TMIN
|
|
232
|
+
k += BASE
|
|
233
|
+
end
|
|
234
|
+
k + (((BASE - TMIN + 1) * delta) / (delta + SKEW))
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|