Skip to content

Commit

Permalink
Merge pull request #103 from validates-email-format-of/idn-support
Browse files Browse the repository at this point in the history
Add support for Internationalized domain names (IDN)

Internationalised domains are converted to Punycode by default.  You can opt-out by setting `idn: false`

Thanks to https://github.com/sbilharz for the PR many years ago....
  • Loading branch information
alexdunae committed Mar 10, 2024
2 parents 5d95824 + 03ed2f8 commit 9b047d5
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 7 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
18 changes: 12 additions & 6 deletions lib/validates_email_format_of.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "validates_email_format_of/version"
require "simpleidn"

module ValidatesEmailFormatOf
def self.load_i18n_locales
Expand Down Expand Up @@ -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
#
Expand All @@ -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)
Expand All @@ -119,6 +122,7 @@ def self.default_message
# * <tt>message</tt> - A custom error message (default is: "does not appear to be valid")
# * <tt>check_mx</tt> - Check for MX records (default is false)
# * <tt>check_mx_timeout</tt> - Timeout in seconds for checking MX records before a `ResolvTimeout` is raised (default is 3)
# * <tt>idn</tt> - Enable or disable Internationalized Domain Names (default is true)
# * <tt>mx_message</tt> - A custom error message when an MX record validation fails (default is: "is not routable.")
# * <tt>local_length</tt> Maximum number of characters allowed in the local part (default is 64)
# * <tt>domain_length</tt> Maximum number of characters allowed in the domain part (default is 255)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion spec/validates_email_format_of_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions validates_email_format_of.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 9b047d5

Please sign in to comment.