diff --git a/README.md b/README.md index 364e63d..5ae8096 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ ValidatesEmailFormatOf::validate_email_format("invalid@") # => ["does not appear | `:message` | String | A custom error message when the email format is invalid (default is: "does not appear to be a valid email address") | | `:check_mx` | Boolean | Check domain for a valid MX record (default is false) | | `:check_mx_timeout` | Integer | Timeout in seconds for checking MX records before a `ResolvTimeout` is raised (default is 3). | +| `:idn` | Boolean | Allowed internationalized domain names like `test@exämple.com` and `test@пример.рф`. Otherwise only domains that have already been converted to [Punycode](https://en.wikipedia.org/wiki/Punycode) are supported. (default is true) | | `:mx_message` | String | A custom error message when the domain does not match a valid MX record (default is: "is not routable"). Ignored unless :check_mx option is true. | | `:local_length` |Integer | Maximum number of characters allowed in the local part (everything before the '@') (default is 64) | | `:domain_length` | Integer | Maximum number of characters allowed in the domain part (everything after the '@') (default is 255) | diff --git a/lib/validates_email_format_of.rb b/lib/validates_email_format_of.rb index 9505f18..f742e32 100644 --- a/lib/validates_email_format_of.rb +++ b/lib/validates_email_format_of.rb @@ -1,4 +1,5 @@ require "validates_email_format_of/version" +require "simpleidn" module ValidatesEmailFormatOf def self.load_i18n_locales @@ -77,7 +78,7 @@ def self.load_i18n_locales # > restriction on the first character is relaxed to allow either a # > letter or a digit. Host software MUST support this more liberal # > syntax. - DOMAIN_PART_LABEL = /\A[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]?\Z/ + DOMAIN_PART_LABEL = /\A[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]?\Z/ # From https://tools.ietf.org/id/draft-liman-tld-names-00.html#rfc.section.2 # @@ -92,10 +93,12 @@ def self.load_i18n_locales # ld = ALPHA / DIGIT # ALPHA = %x41-5A / %x61-7A ; A-Z / a-z # DIGIT = %x30-39 ; 0-9 - DOMAIN_PART_TLD = /\A[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9]\Z/ + DOMAIN_PART_TLD = /\A[A-Za-z][A-Za-z0-9-]*[A-Za-z0-9]\Z/ - def self.validate_email_domain(email, check_mx_timeout: 3) + def self.validate_email_domain(email, idn: true, check_mx_timeout: 3) domain = email.to_s.downcase.match(/@(.+)/)[1] + domain = SimpleIDN.to_ascii(domain) if idn + Resolv::DNS.open do |dns| dns.timeouts = check_mx_timeout @mx = dns.getresources(domain, Resolv::DNS::Resource::IN::MX) + dns.getresources(domain, Resolv::DNS::Resource::IN::A) @@ -119,6 +122,7 @@ def self.default_message # * message - A custom error message (default is: "does not appear to be valid") # * check_mx - Check for MX records (default is false) # * check_mx_timeout - Timeout in seconds for checking MX records before a `ResolvTimeout` is raised (default is 3) + # * idn - Enable or disable Internationalized Domain Names (default is true) # * mx_message - A custom error message when an MX record validation fails (default is: "is not routable.") # * local_length Maximum number of characters allowed in the local part (default is 64) # * domain_length Maximum number of characters allowed in the domain part (default is 255) @@ -127,6 +131,7 @@ def self.validate_email_format(email, options = {}) default_options = {message: options[:generate_message] ? ERROR_MESSAGE_I18N_KEY : default_message, check_mx: false, check_mx_timeout: 3, + idn: true, mx_message: if options[:generate_message] ERROR_MX_MESSAGE_I18N_KEY else @@ -156,10 +161,10 @@ def self.validate_email_format(email, options = {}) deprecation_warn(":with option is deprecated and will be removed in the next version") return [opts[:message]] unless email&.match?(opts[:with]) else - return [opts[:message]] unless validate_local_part_syntax(local) && validate_domain_part_syntax(domain) + return [opts[:message]] unless validate_local_part_syntax(local) && validate_domain_part_syntax(domain, idn: opts[:idn]) end - if opts[:check_mx] && !validate_email_domain(email, check_mx_timeout: opts[:check_mx_timeout]) + if opts[:check_mx] && !validate_email_domain(email, check_mx_timeout: opts[:check_mx_timeout], idn: opts[:idn]) return [opts[:mx_message]] end @@ -263,7 +268,8 @@ def self.validate_local_part_syntax(local) true end - def self.validate_domain_part_syntax(domain) + def self.validate_domain_part_syntax(domain, idn: true) + domain = SimpleIDN.to_ascii(domain) if idn parts = domain.downcase.split(".", -1) return false if parts.length <= 1 # Only one domain part diff --git a/spec/validates_email_format_of_spec.rb b/spec/validates_email_format_of_spec.rb index fc9912b..9da610f 100644 --- a/spec/validates_email_format_of_spec.rb +++ b/spec/validates_email_format_of_spec.rb @@ -58,9 +58,15 @@ def self.model_name "_somename@example.com", # apostrophes "test'test@example.com", - # international domain names + # punycode domain names "test@xn--bcher-kva.ch", "test@example.xn--0zwm56d", + + # IDN domains, + "test@exämple.com", + "test@пример.рф", + "test@почта.бел", + "test@192.192.192.1", # Allow quoted characters. Valid according to http://www.rfc-editor.org/errata_search.php?rfc=3696 '"Abc\@def"@example.com', @@ -185,6 +191,26 @@ def self.model_name end end + describe "when idn support is disabled" do + before(:each) do + allow(SimpleIDN).to receive(:to_ascii).never + end + let(:options) { {idn: false} } + describe "test@exämple.com" do + it { should have_errors_on_email.because("does not appear to be a valid email address") } + end + end + + describe "when idn support is enabled" do + before(:each) do + allow(SimpleIDN).to receive(:to_ascii).once.with("exämple.com").and_return("xn--exmple-cua.com") + end + let(:options) { {idn: true} } + describe "test@exämple.com" do + it { should_not have_errors_on_email } + end + end + describe "mx record" do domain = "example.com" email = "valid@#{domain}" @@ -242,6 +268,7 @@ def self.model_name end end end + describe "when not testing" do before(:each) { allow(Resolv::DNS).to receive(:open).never } describe "by default" do @@ -264,6 +291,36 @@ def self.model_name end end + describe "mx record for internationalized domain" do + domain = "пример.рф" + email = "valid@#{domain}" + + describe "when idn support is enabled" do + let(:dns) { double(Resolv::DNS) } + let(:options) { {check_mx: true, idn: true} } + + before(:each) do + allow(Resolv::DNS).to receive(:open).and_yield(dns) + allow(dns).to receive(:"timeouts=").with(3).once + allow(dns).to receive(:getresources).with(SimpleIDN.to_ascii(domain), Resolv::DNS::Resource::IN::A).once.and_return([double]) + allow(dns).to receive(:getresources).with(SimpleIDN.to_ascii(domain), Resolv::DNS::Resource::IN::MX).once.and_return([double]) + end + + describe email do + it { should_not have_errors_on_email } + end + end + + describe "when idn support is disabled" do + let(:options) { {check_mx: true, idn: false} } + + describe "test@пример.рф" do + let(:domain) { "exämple.com" } + it { should have_errors_on_email.because("does not appear to be a valid email address") } + end + end + end + describe "custom regex" do let(:options) { {with: /[0-9]+@[0-9]+/} } describe "012345@789" do diff --git a/validates_email_format_of.gemspec b/validates_email_format_of.gemspec index 524bcaa..6cf5094 100644 --- a/validates_email_format_of.gemspec +++ b/validates_email_format_of.gemspec @@ -19,6 +19,7 @@ Gem::Specification.new do |s| s.add_dependency "i18n", ">= 0.8.0" end + s.add_dependency "simpleidn" s.add_development_dependency "activemodel" s.add_development_dependency "bundler" s.add_development_dependency "rspec"