validates_email_format_of 1.6.3 → 1.7.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 +5 -5
- data/.github/workflows/ci.yml +67 -0
- data/.gitignore +2 -0
- data/Appraisals +32 -0
- data/CHANGELOG.md +35 -0
- data/README.rdoc +4 -2
- data/config/locales/fr.yml +8 -0
- data/config/locales/it.yml +8 -0
- data/config/locales/ja.yml +8 -0
- data/config/locales/pt-BR.yml +8 -0
- data/config/locales/pt.yml +8 -0
- data/gemfiles/rails_4.2.gemfile +8 -0
- data/gemfiles/rails_5.0.gemfile +8 -0
- data/gemfiles/rails_5.1.gemfile +8 -0
- data/gemfiles/rails_5.2.gemfile +8 -0
- data/gemfiles/rails_6.0.gemfile +7 -0
- data/gemfiles/rails_6.1.gemfile +7 -0
- data/gemfiles/rails_7.0.gemfile +7 -0
- data/lib/validates_email_format_of/active_model.rb +4 -4
- data/lib/validates_email_format_of/railtie.rb +2 -2
- data/lib/validates_email_format_of/rspec_matcher.rb +1 -1
- data/lib/validates_email_format_of/version.rb +1 -1
- data/lib/validates_email_format_of.rb +179 -67
- data/rakefile.rb +4 -1
- data/spec/rspec_matcher_spec.rb +9 -11
- data/spec/spec_helper.rb +6 -3
- data/spec/validates_email_format_of_spec.rb +134 -129
- data/validates_email_format_of.gemspec +21 -19
- metadata +77 -16
- data/.travis.yml +0 -18
- data/CHANGELOG +0 -3
- data/gemfiles/Gemfile.active_model_2_1 +0 -5
- data/gemfiles/Gemfile.active_model_3_0 +0 -5
- data/gemfiles/Gemfile.active_model_3_1 +0 -5
- data/gemfiles/Gemfile.active_model_3_2 +0 -5
- data/gemfiles/Gemfile.active_model_3_3 +0 -5
- data/gemfiles/Gemfile.active_model_4_0 +0 -5
- data/gemfiles/Gemfile.active_model_4_1 +0 -5
@@ -1,22 +1,106 @@
|
|
1
|
-
|
2
|
-
require 'validates_email_format_of/version'
|
1
|
+
require "validates_email_format_of/version"
|
3
2
|
|
4
3
|
module ValidatesEmailFormatOf
|
5
4
|
def self.load_i18n_locales
|
6
|
-
require
|
7
|
-
I18n.load_path += Dir.glob(File.expand_path(File.join(File.dirname(__FILE__),
|
5
|
+
require "i18n"
|
6
|
+
I18n.load_path += Dir.glob(File.expand_path(File.join(File.dirname(__FILE__), "..", "config", "locales", "*.yml")))
|
8
7
|
end
|
9
8
|
|
10
|
-
require
|
9
|
+
require "resolv"
|
11
10
|
|
12
|
-
|
11
|
+
# Characters that are allowed in to appear in the local part unquoted
|
12
|
+
# https://www.rfc-editor.org/rfc/rfc5322#section-3.2.3
|
13
|
+
#
|
14
|
+
# An addr-spec is a specific Internet identifier that contains a
|
15
|
+
# locally interpreted string followed by the at-sign character ("@",
|
16
|
+
# ASCII value 64) followed by an Internet domain. The locally
|
17
|
+
# interpreted string is either a quoted-string or a dot-atom. If the
|
18
|
+
# string can be represented as a dot-atom (that is, it contains no
|
19
|
+
# characters other than atext characters or "." surrounded by atext
|
20
|
+
# characters), then the dot-atom form SHOULD be used and the quoted-
|
21
|
+
# string form SHOULD NOT be used. Comments and folding white space
|
22
|
+
# SHOULD NOT be used around the "@" in the addr-spec.
|
23
|
+
#
|
24
|
+
# atext = ALPHA / DIGIT /
|
25
|
+
# "!" / "#" / "$" / "%" / "&" / "'" / "*" /
|
26
|
+
# "+" / "-" / "/" / "=" / "?" / "^" / "_" /
|
27
|
+
# "`" / "{" / "|" / "}" / "~"
|
28
|
+
# dot-atom-text = 1*atext *("." 1*atext)
|
29
|
+
# dot-atom = [CFWS] dot-atom-text [CFWS]
|
30
|
+
ATEXT = /\A[A-Z0-9!\#$%&'*\-\/=?+\^_`{|}~]\z/i
|
31
|
+
|
32
|
+
# Characters that are allowed to appear unquoted in comments
|
33
|
+
# https://www.rfc-editor.org/rfc/rfc5322#section-3.2.2
|
34
|
+
#
|
35
|
+
# ctext = %d33-39 / %d42-91 / %d93-126
|
36
|
+
# ccontent = ctext / quoted-pair / comment
|
37
|
+
# comment = "(" *([FWS] ccontent) [FWS] ")"
|
38
|
+
# CFWS = (1*([FWS] comment) [FWS]) / FWS
|
39
|
+
CTEXT = /\A[#{Regexp.escape([33..39, 42..91, 93..126].map { |ascii_range| ascii_range.map(&:chr) }.flatten.join)}\s]/i
|
40
|
+
|
41
|
+
# https://www.rfc-editor.org/rfc/rfc5322#section-3.2.4
|
42
|
+
#
|
43
|
+
# Strings of characters that include characters other than those
|
44
|
+
# allowed in atoms can be represented in a quoted string format, where
|
45
|
+
# the characters are surrounded by quote (DQUOTE, ASCII value 34)
|
46
|
+
# characters.
|
47
|
+
#
|
48
|
+
# qtext = %d33 / ; Printable US-ASCII
|
49
|
+
# %d35-91 / ; characters not including
|
50
|
+
# %d93-126 / ; "\" or the quote character
|
51
|
+
# obs-qtext
|
52
|
+
#
|
53
|
+
# qcontent = qtext / quoted-pair
|
54
|
+
# quoted-string = [CFWS]
|
55
|
+
# DQUOTE *([FWS] qcontent) [FWS] DQUOTE
|
56
|
+
# [CFWS]
|
57
|
+
QTEXT = /\A[#{Regexp.escape([33..33, 35..91, 93..126].map { |ascii_range| ascii_range.map(&:chr) }.flatten.join)}\s]/i
|
58
|
+
|
59
|
+
IP_OCTET = /\A[0-9]+\Z/
|
60
|
+
|
61
|
+
# From https://datatracker.ietf.org/doc/html/rfc1035#section-2.3.1
|
62
|
+
#
|
63
|
+
# > The labels must follow the rules for ARPANET host names. They must
|
64
|
+
# > start with a letter, end with a letter or digit, and have as interior
|
65
|
+
# > characters only letters, digits, and hyphen. There are also some
|
66
|
+
# > restrictions on the length. Labels must be 63 characters or less.
|
67
|
+
#
|
68
|
+
# <label> | <subdomain> "." <label>
|
69
|
+
# <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ]
|
70
|
+
# <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
|
71
|
+
# <let-dig-hyp> ::= <let-dig> | "-"
|
72
|
+
# <let-dig> ::= <letter> | <digit>
|
73
|
+
#
|
74
|
+
# Additionally, from https://datatracker.ietf.org/doc/html/rfc1123#section-2.1
|
75
|
+
#
|
76
|
+
# > One aspect of host name syntax is hereby changed: the
|
77
|
+
# > restriction on the first character is relaxed to allow either a
|
78
|
+
# > letter or a digit. Host software MUST support this more liberal
|
79
|
+
# > syntax.
|
80
|
+
DOMAIN_PART_LABEL = /\A[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]?\Z/
|
13
81
|
|
14
|
-
|
15
|
-
|
82
|
+
# From https://tools.ietf.org/id/draft-liman-tld-names-00.html#rfc.section.2
|
83
|
+
#
|
84
|
+
# > A TLD label MUST be at least two characters long and MAY be as long as 63 characters -
|
85
|
+
# > not counting any leading or trailing periods (.). It MUST consist of only ASCII characters
|
86
|
+
# > from the groups "letters" (A-Z), "digits" (0-9) and "hyphen" (-), and it MUST start with an
|
87
|
+
# > ASCII "letter", and it MUST NOT end with a "hyphen". Upper and lower case MAY be mixed at random,
|
88
|
+
# > since DNS lookups are case-insensitive.
|
89
|
+
#
|
90
|
+
# tldlabel = ALPHA *61(ldh) ld
|
91
|
+
# ldh = ld / "-"
|
92
|
+
# ld = ALPHA / DIGIT
|
93
|
+
# ALPHA = %x41-5A / %x61-7A ; A-Z / a-z
|
94
|
+
# DIGIT = %x30-39 ; 0-9
|
95
|
+
DOMAIN_PART_TLD = /\A[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9]\Z/
|
96
|
+
|
97
|
+
def self.validate_email_domain(email, check_mx_timeout: 3)
|
98
|
+
domain = email.to_s.downcase.match(/@(.+)/)[1]
|
16
99
|
Resolv::DNS.open do |dns|
|
100
|
+
dns.timeouts = check_mx_timeout
|
17
101
|
@mx = dns.getresources(domain, Resolv::DNS::Resource::IN::MX) + dns.getresources(domain, Resolv::DNS::Resource::IN::A)
|
18
102
|
end
|
19
|
-
@mx.size > 0
|
103
|
+
@mx.size > 0
|
20
104
|
end
|
21
105
|
|
22
106
|
DEFAULT_MESSAGE = "does not appear to be valid"
|
@@ -25,7 +109,7 @@ module ValidatesEmailFormatOf
|
|
25
109
|
ERROR_MX_MESSAGE_I18N_KEY = :email_address_not_routable
|
26
110
|
|
27
111
|
def self.default_message
|
28
|
-
defined?(I18n) ? I18n.t(ERROR_MESSAGE_I18N_KEY, :
|
112
|
+
defined?(I18n) ? I18n.t(ERROR_MESSAGE_I18N_KEY, scope: [:activemodel, :errors, :messages], default: DEFAULT_MESSAGE) : DEFAULT_MESSAGE
|
29
113
|
end
|
30
114
|
|
31
115
|
# Validates whether the specified value is a valid email address. Returns nil if the value is valid, otherwise returns an array
|
@@ -34,57 +118,60 @@ module ValidatesEmailFormatOf
|
|
34
118
|
# Configuration options:
|
35
119
|
# * <tt>message</tt> - A custom error message (default is: "does not appear to be valid")
|
36
120
|
# * <tt>check_mx</tt> - Check for MX records (default is false)
|
121
|
+
# * <tt>check_mx_timeout</tt> - Timeout in seconds for checking MX records before a `ResolvTimeout` is raised (default is 3)
|
37
122
|
# * <tt>mx_message</tt> - A custom error message when an MX record validation fails (default is: "is not routable.")
|
38
123
|
# * <tt>with</tt> The regex to use for validating the format of the email address (deprecated)
|
39
124
|
# * <tt>local_length</tt> Maximum number of characters allowed in the local part (default is 64)
|
40
125
|
# * <tt>domain_length</tt> Maximum number of characters allowed in the domain part (default is 255)
|
41
126
|
# * <tt>generate_message</tt> Return the I18n key of the error message instead of the error message itself (default is false)
|
42
|
-
def self.validate_email_format(email, options={})
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
127
|
+
def self.validate_email_format(email, options = {})
|
128
|
+
default_options = {message: options[:generate_message] ? ERROR_MESSAGE_I18N_KEY : default_message,
|
129
|
+
check_mx: false,
|
130
|
+
check_mx_timeout: 3,
|
131
|
+
mx_message: if options[:generate_message]
|
132
|
+
ERROR_MX_MESSAGE_I18N_KEY
|
133
|
+
else
|
134
|
+
(defined?(I18n) ? I18n.t(ERROR_MX_MESSAGE_I18N_KEY, scope: [:activemodel, :errors, :messages], default: DEFAULT_MX_MESSAGE) : DEFAULT_MX_MESSAGE)
|
135
|
+
end,
|
136
|
+
domain_length: 255,
|
137
|
+
local_length: 64,
|
138
|
+
generate_message: false}
|
139
|
+
opts = options.merge(default_options) { |key, old, new| old } # merge the default options into the specified options, retaining all specified options
|
140
|
+
|
141
|
+
begin
|
142
|
+
domain, local = email.reverse.split("@", 2)
|
143
|
+
rescue
|
144
|
+
return [opts[:message]]
|
145
|
+
end
|
59
146
|
|
60
|
-
|
61
|
-
|
147
|
+
# need local and domain parts
|
148
|
+
return [opts[:message]] unless local && !local.empty? && domain && !domain.empty?
|
62
149
|
|
63
|
-
|
64
|
-
|
150
|
+
# check lengths
|
151
|
+
return [opts[:message]] unless domain.length <= opts[:domain_length] && local.length <= opts[:local_length]
|
65
152
|
|
66
|
-
|
67
|
-
|
153
|
+
local.reverse!
|
154
|
+
domain.reverse!
|
68
155
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
156
|
+
if opts.has_key?(:with) # holdover from versions <= 1.4.7
|
157
|
+
return [opts[:message]] unless email&.match?(opts[:with])
|
158
|
+
else
|
159
|
+
return [opts[:message]] unless validate_local_part_syntax(local) && validate_domain_part_syntax(domain)
|
160
|
+
end
|
74
161
|
|
75
|
-
|
76
|
-
|
77
|
-
|
162
|
+
if opts[:check_mx] && !validate_email_domain(email, check_mx_timeout: opts[:check_mx_timeout])
|
163
|
+
return [opts[:mx_message]]
|
164
|
+
end
|
78
165
|
|
79
|
-
|
166
|
+
nil # represents no validation errors
|
80
167
|
end
|
81
168
|
|
82
|
-
|
83
169
|
def self.validate_local_part_syntax(local)
|
84
170
|
in_quoted_pair = false
|
85
171
|
in_quoted_string = false
|
172
|
+
comment_depth = 0
|
86
173
|
|
87
|
-
(0..local.length-1).each do |i|
|
174
|
+
(0..local.length - 1).each do |i|
|
88
175
|
ord = local[i].ord
|
89
176
|
|
90
177
|
# accept anything if it's got a backslash before it
|
@@ -93,9 +180,26 @@ module ValidatesEmailFormatOf
|
|
93
180
|
next
|
94
181
|
end
|
95
182
|
|
183
|
+
if in_quoted_string
|
184
|
+
next if QTEXT.match?(local[i])
|
185
|
+
end
|
186
|
+
|
187
|
+
# opening paren to show we are going into a comment (CFWS)
|
188
|
+
if ord == 40
|
189
|
+
comment_depth += 1
|
190
|
+
next
|
191
|
+
end
|
192
|
+
|
193
|
+
# closing paren
|
194
|
+
if ord == 41
|
195
|
+
comment_depth -= 1
|
196
|
+
return false if comment_depth < 0
|
197
|
+
next
|
198
|
+
end
|
199
|
+
|
96
200
|
# backslash signifies the start of a quoted pair
|
97
|
-
if ord == 92
|
98
|
-
return false if
|
201
|
+
if ord == 92 && i < local.length - 1
|
202
|
+
return false if !in_quoted_string # must be in quoted string per http://www.rfc-editor.org/errata_search.php?rfc=3696
|
99
203
|
in_quoted_pair = true
|
100
204
|
next
|
101
205
|
end
|
@@ -106,45 +210,53 @@ module ValidatesEmailFormatOf
|
|
106
210
|
next
|
107
211
|
end
|
108
212
|
|
109
|
-
|
110
|
-
|
213
|
+
if comment_depth > 0
|
214
|
+
next if CTEXT.match?(local[i])
|
215
|
+
elsif ATEXT.match?(local[i, 1])
|
216
|
+
next
|
217
|
+
end
|
111
218
|
|
112
219
|
# period must be followed by something
|
113
220
|
if ord == 46
|
114
|
-
return false if i == 0
|
115
|
-
next unless local[i+1].ord == 46 # can't be followed by a period
|
221
|
+
return false if i == 0 || i == local.length - 1 # can't be first or last char
|
222
|
+
next unless local[i + 1].ord == 46 # can't be followed by a period
|
116
223
|
end
|
117
224
|
|
118
225
|
return false
|
119
226
|
end
|
120
227
|
|
121
228
|
return false if in_quoted_string # unbalanced quotes
|
229
|
+
return false unless comment_depth.zero? # unbalanced comment parens
|
122
230
|
|
123
|
-
|
231
|
+
true
|
124
232
|
end
|
125
233
|
|
126
234
|
def self.validate_domain_part_syntax(domain)
|
127
|
-
parts = domain.downcase.split(
|
235
|
+
parts = domain.downcase.split(".", -1)
|
128
236
|
|
129
237
|
return false if parts.length <= 1 # Only one domain part
|
130
238
|
|
131
|
-
# Empty parts (double period) or invalid chars
|
132
|
-
return false if parts.any? {
|
133
|
-
|part|
|
134
|
-
part.nil? or
|
135
|
-
part.empty? or
|
136
|
-
not part =~ /\A[[:alnum:]\-]+\Z/ or
|
137
|
-
part[0,1] == '-' or part[-1,1] == '-' # hyphen at beginning or end of part
|
138
|
-
}
|
139
|
-
|
140
239
|
# ipv4
|
141
|
-
return true if parts.length == 4
|
142
|
-
|
143
|
-
|
240
|
+
return true if parts.length == 4 && parts.all? { |part| part =~ IP_OCTET && part.to_i.between?(0, 255) }
|
241
|
+
|
242
|
+
# From https://datatracker.ietf.org/doc/html/rfc3696#section-2 this is the recommended, pragmatic way to validate a domain name:
|
243
|
+
#
|
244
|
+
# > It is likely that the better strategy has now become to make the "at least one period" test,
|
245
|
+
# > to verify LDH conformance (including verification that the apparent TLD name is not all-numeric),
|
246
|
+
# > and then to use the DNS to determine domain name validity, rather than trying to maintain
|
247
|
+
# > a local list of valid TLD names.
|
248
|
+
#
|
249
|
+
# We do a little bit more but not too much and validate the tokens but do not check against a list of valid TLDs.
|
250
|
+
parts.each do |part|
|
251
|
+
return false if part.nil? || part.empty?
|
252
|
+
return false if part.length > 63
|
253
|
+
return false unless DOMAIN_PART_LABEL.match?(part)
|
254
|
+
end
|
144
255
|
|
145
|
-
return
|
256
|
+
return false unless DOMAIN_PART_TLD.match?(parts[-1])
|
257
|
+
true
|
146
258
|
end
|
147
259
|
end
|
148
260
|
|
149
|
-
require
|
150
|
-
require
|
261
|
+
require "validates_email_format_of/active_model" if defined?(::ActiveModel) && !(ActiveModel::VERSION::MAJOR < 2 || (ActiveModel::VERSION::MAJOR == 2 && ActiveModel::VERSION::MINOR < 1))
|
262
|
+
require "validates_email_format_of/railtie" if defined?(::Rails::Railtie)
|
data/rakefile.rb
CHANGED
data/spec/rspec_matcher_spec.rb
CHANGED
@@ -1,14 +1,12 @@
|
|
1
|
-
require "#{
|
2
|
-
|
3
|
-
require "validates_email_format_of/rspec_matcher"
|
1
|
+
require "#{__dir__}/spec_helper"
|
2
|
+
require "validates_email_format_of/rspec_matcher"
|
4
3
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
4
|
+
class Person
|
5
|
+
attr_accessor :email_address
|
6
|
+
include ::ActiveModel::Validations
|
7
|
+
validates_email_format_of :email_address
|
8
|
+
end
|
10
9
|
|
11
|
-
|
12
|
-
|
13
|
-
end
|
10
|
+
describe Person do
|
11
|
+
it { should validate_email_format_of(:email_address) }
|
14
12
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,4 +1,7 @@
|
|
1
|
-
require
|
1
|
+
require "active_model"
|
2
|
+
require "active_support"
|
3
|
+
require "pry"
|
4
|
+
require "byebug"
|
2
5
|
|
3
6
|
RSpec::Matchers.define :have_errors_on_email do
|
4
7
|
match do |actual|
|
@@ -6,7 +9,7 @@ RSpec::Matchers.define :have_errors_on_email do
|
|
6
9
|
expect(actual).not_to be_empty, "#{actual} should not be empty"
|
7
10
|
expect(actual.size).to eq(@reasons.size), "#{actual} should have #{@reasons.size} elements"
|
8
11
|
@reasons.each do |reason|
|
9
|
-
reason = "
|
12
|
+
reason = "Email #{reason}"
|
10
13
|
expect(actual).to include(reason), "#{actual} should contain #{reason}"
|
11
14
|
end
|
12
15
|
end
|
@@ -17,6 +20,6 @@ RSpec::Matchers.define :have_errors_on_email do
|
|
17
20
|
(@reasons ||= []) << reason
|
18
21
|
end
|
19
22
|
match_when_negated do |actual|
|
20
|
-
expect(actual).to
|
23
|
+
expect(actual).to(be_empty)
|
21
24
|
end
|
22
25
|
end
|