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"