diff --git a/lib/valid_email2/address.rb b/lib/valid_email2/address.rb index c6f02f0..bf0c29f 100644 --- a/lib/valid_email2/address.rb +++ b/lib/valid_email2/address.rb @@ -11,6 +11,9 @@ class Address PROHIBITED_DOMAIN_CHARACTERS_REGEX = /[+!_\/\s'#`]/ DEFAULT_RECIPIENT_DELIMITER = '+' DOT_DELIMITER = '.' + HYPHEN_DELIMITER = '-' + MAX_DOMAIN_LENGTH = 253 + MAX_LABEL_LENGTH = 63 def self.prohibited_domain_characters_regex @prohibited_domain_characters_regex ||= PROHIBITED_DOMAIN_CHARACTERS_REGEX @@ -52,13 +55,13 @@ def valid? def valid_domain? domain = address.domain return false if domain.nil? + return false if domain =~ self.class.prohibited_domain_characters_regex + return false if domain.length > MAX_DOMAIN_LENGTH - domain !~ self.class.prohibited_domain_characters_regex && - domain.include?('.') && - !domain.include?('..') && - !domain.start_with?('.') && - !domain.start_with?('-') && - !domain.include?('-.') + labels = domain.split(DOT_DELIMITER, -1) + return false if labels.length < 2 + + labels.all? { |label| valid_domain_label?(label) } end def valid_address? @@ -109,6 +112,14 @@ def valid_strict_mx? private + # An RFC 1035 label: 1-63 octets, and it may not begin or end with a hyphen. + def valid_domain_label?(label) + !label.empty? && + label.length <= MAX_LABEL_LENGTH && + !label.start_with?(HYPHEN_DELIMITER) && + !label.end_with?(HYPHEN_DELIMITER) + end + def disposable_mx_server? mx_server_is_in?(ValidEmail2.disposable_emails) end diff --git a/spec/valid_email2_spec.rb b/spec/valid_email2_spec.rb index 83c63d6..b86f918 100644 --- a/spec/valid_email2_spec.rb +++ b/spec/valid_email2_spec.rb @@ -171,6 +171,48 @@ class TestUserMultiple < TestModel user = TestUser.new(email: "foo@example.com#") expect(user.valid?).to be_falsey end + + describe "domain label structure" do + # A domain label may not begin or end with a hyphen and is limited to 63 + # octets; the whole domain is limited to 253. These rules apply to every + # label, not only the first/last position of the domain string. + long_label = "a" * 64 + long_domain = (["aa"] + ["a"] * 126).join(".") # 254 chars, all-valid labels + + { + "a leading hyphen on the first label" => "foo@-example.com", + "a leading hyphen on a later label" => "foo@sub.-example.com", + "a leading hyphen on a deeply nested label" => "foo@a.b.-c.example.com", + "a trailing hyphen on a non-final label" => "foo@example-.com", + "a trailing hyphen on the final label" => "foo@example.com-", + "a bare hyphen label" => "foo@example.-.com", + "a label longer than 63 octets" => "foo@#{long_label}.com", + "a later label longer than 63 octets" => "foo@example.#{long_label}.com", + "a domain longer than 253 octets" => "foo@#{long_domain}" + }.each do |description, email| + it "is invalid with #{description}" do + expect(TestUser.new(email: email).valid?).to be_falsey + end + end + + full_label = "a" * 63 + max_domain = (["a"] * 127).join(".") # exactly 253 chars + + { + "an internal hyphen" => "foo@ex-ample.com", + "hyphens on several internal labels" => "foo@f-o-o.example.com", + "a double internal hyphen" => "foo@a--b.example.com", + "a punycode (IDNA ACE) label" => "foo@xn--bcher-kva.com", + "a label beginning with a digit" => "foo@3m.com", + "a 63 octet label at the limit" => "foo@#{full_label}.com", + "a later 63 octet label" => "foo@example.#{full_label}.com", + "a domain at the 253 octet limit" => "foo@#{max_domain}" + }.each do |description, email| + it "is valid with #{description}" do + expect(TestUser.new(email: email).valid?).to be_truthy + end + end + end end describe "with disposable validation" do