├── lib ├── email_address │ ├── version.rb │ ├── messages.yaml │ ├── email_address_type.rb │ ├── canonical_email_address_type.rb │ ├── active_record_validator.rb │ ├── exchanger.rb │ ├── rewriter.rb │ ├── config.rb │ ├── address.rb │ ├── local.rb │ └── host.rb └── email_address.rb ├── Gemfile ├── .gitignore ├── test ├── email_address │ ├── test_rewriter.rb │ ├── test_exchanger.rb │ ├── test_config.rb │ ├── test_local.rb │ ├── test_host.rb │ └── test_address.rb ├── test_helper.rb ├── test_email_address.rb ├── activerecord │ ├── test_ar.rb │ └── user.rb └── test_aliasing.rb ├── Rakefile ├── .github └── workflows │ └── ci.yml ├── LICENSE.txt ├── email_address.gemspec └── README.md /lib/email_address/version.rb: -------------------------------------------------------------------------------- 1 | module EmailAddress 2 | VERSION = "0.2.7" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in email_address.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .ruby-lsp 19 | -------------------------------------------------------------------------------- /test/email_address/test_rewriter.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class TestRewriter < Minitest::Test 4 | def test_srs 5 | ea = "first.LAST+tag@gmail.com" 6 | e = EmailAddress.new(ea) 7 | s = e.srs("example.com") 8 | assert s.match(EmailAddress::Address::SRS_FORMAT_REGEX) 9 | assert EmailAddress.new(s).to_s == e.to_s 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require "bundler/setup" 4 | require "rake/testtask" 5 | 6 | task default: :test 7 | 8 | desc "Run the Test Suite, toot suite" 9 | Rake::TestTask.new do |t| 10 | t.libs << "test" 11 | t.pattern = "test/**/test_*.rb" 12 | end 13 | 14 | desc "Open and IRB Console with the gem loaded" 15 | task :console do 16 | sh "bundle exec irb -Ilib -I . -r active_record -r email_address" 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | ruby-version: [3.2, 3.3, 3.4] 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: ${{ matrix.ruby-version }} 14 | bundler-cache: true # runs `bundle install` and caches installed gems automatically 15 | - name: Install dependencies 16 | run: bundle install 17 | - name: Run tests 18 | run: bundle exec rake 19 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # EmailAddress Testing 3 | # - 🔥 rake 4 | # - 🔍️ ruby test/email_address/test_local.rb --name test_tag_punctuation 5 | # - 🧪 rake console 6 | ################################################################################ 7 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib")) 8 | 9 | require "pry" 10 | require "simplecov" 11 | SimpleCov.start 12 | 13 | require "active_record" 14 | require "rubygems" 15 | require "minitest/autorun" 16 | require "minitest/unit" 17 | require "minitest/pride" 18 | require "email_address" 19 | -------------------------------------------------------------------------------- /lib/email_address/messages.yaml: -------------------------------------------------------------------------------- 1 | en: 2 | email_address: 3 | address_unknown: "Unknown Email Address" 4 | domain_does_not_accept_email: "This domain is not configured to accept email" 5 | domain_invalid: "Invalid Domain Name" 6 | domain_no_localhost: "localhost is not allowed for your domain name" 7 | domain_unknown: "Domain name not registered" 8 | exceeds_size: "Address too long" 9 | incomplete_domain: "Domain name is incomplete" 10 | invalid_address: "Invalid Email Address" 11 | invalid_host: "Invalid Host/Domain Name" 12 | invalid_mailbox: "Invalid Mailbox" 13 | ip_address_forbidden: "IP Addresses are not allowed" 14 | ip_address_no_localhost: "Localhost IP addresses are not allowed" 15 | ipv4_address_invalid: "This is not a valid IPv4 address" 16 | ipv6_address_invalid: "This is not a valid IPv6 address" 17 | local_size_long: "Mailbox name too long" 18 | local_size_short: "Mailbox name too short" 19 | local_invalid: "Recipient is not valid" 20 | not_allowed: "Address is not allowed" 21 | server_not_available: "The remote email server is not available" 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Allen Fair 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /test/email_address/test_exchanger.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class TestExchanger < MiniTest::Test 4 | def test_exchanger 5 | e = EmailAddress::Exchanger.new("gmail.com") 6 | assert_equal true, e.mxers.size > 1 7 | assert_equal :google, e.provider 8 | assert_equal "google.com", e.domains.first 9 | assert_equal "google.com", e.matches?("google.com") 10 | assert_equal false, e.matches?("fa00:1450:4013:c01::1a/16") 11 | assert_equal false, e.matches?("127.0.0.1/24") 12 | assert_equal true, e.mx_ips.size > 1 13 | end 14 | 15 | def test_not_found 16 | e = EmailAddress::Exchanger.new("oops.gmail.com") 17 | assert_equal 0, e.mxers.size 18 | end 19 | 20 | # assert_equal true, a.has_dns_a_record? # example.com has no MX'ers 21 | # def test_dns 22 | # good = EmailAddress::Exchanger.new("gmail.com") 23 | # bad = EmailAddress::Exchanger.new("exampldkeie4iufe.com") 24 | # assert_equal true, good.has_dns_a_record? 25 | # assert_equal false, bad.has_dns_a_record? 26 | # assert_equal "gmail.com", good.dns_a_record.first 27 | # assert(/google.com\z/, good.mxers.first.first) 28 | # #assert_equal 'google.com', good.domains.first 29 | # end 30 | end 31 | -------------------------------------------------------------------------------- /test/email_address/test_config.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class TestConfig < MiniTest::Test 4 | def test_setting 5 | assert_equal :a, EmailAddress::Config.setting(:dns_lookup) 6 | assert_equal :off, EmailAddress::Config.setting(:dns_lookup, :off) 7 | assert_equal :off, EmailAddress::Config.setting(:dns_lookup) 8 | EmailAddress::Config.setting(:dns_lookup, :a) 9 | end 10 | 11 | def test_configure 12 | assert_equal :a, EmailAddress::Config.setting(:dns_lookup) 13 | assert_equal true, EmailAddress::Config.setting(:local_downcase) 14 | EmailAddress::Config.configure(local_downcase: false, dns_lookup: :off) 15 | assert_equal :off, EmailAddress::Config.setting(:dns_lookup) 16 | assert_equal false, EmailAddress::Config.setting(:local_downcase) 17 | EmailAddress::Config.configure(local_downcase: true, dns_lookup: :a) 18 | end 19 | 20 | def test_provider 21 | assert_nil EmailAddress::Config.provider(:github) 22 | EmailAddress::Config.provider(:github, host_match: %w[github.com], local_format: :standard) 23 | assert_equal :standard, EmailAddress::Config.provider(:github)[:local_format] 24 | assert_equal :github, EmailAddress::Host.new("github.com").provider 25 | EmailAddress::Config.providers.delete(:github) 26 | assert_nil EmailAddress::Config.provider(:github) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/test_email_address.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class TestEmailAddress < MiniTest::Test 4 | def test_new 5 | a = EmailAddress.new("user@example.com") 6 | assert_equal "user", a.local.to_s 7 | assert_equal "example.com", a.host.to_s 8 | end 9 | 10 | def test_canonical 11 | assert_equal "firstlast@gmail.com", EmailAddress.canonical("First.Last+TAG@gmail.com") 12 | a = EmailAddress.new_canonical("First.Last+TAG@gmail.com") 13 | assert_equal "firstlast", a.local.to_s 14 | end 15 | 16 | def test_normal 17 | assert_equal "user+tag@gmail.com", EmailAddress.normal("USER+TAG@GMAIL.com") 18 | end 19 | 20 | def test_valid 21 | assert_equal true, EmailAddress.valid?("user@yahoo.com") 22 | assert_equal true, EmailAddress.valid?("a@yahoo.com") 23 | end 24 | 25 | def test_matches 26 | assert_equal "yahoo.", EmailAddress.matches?("user@yahoo.com", "yahoo.") 27 | end 28 | 29 | def test_reference 30 | assert_equal "dfeafc750cecde54f9a4775f5713bf01", EmailAddress.reference("user@yahoo.com") 31 | end 32 | 33 | def test_redact 34 | assert_equal "{e037b6c476357f34f92b8f35b25d179a4f573f1e}@yahoo.com", EmailAddress.redact("user@yahoo.com") 35 | end 36 | 37 | def test_cases 38 | %w[miles.o'brien@yahoo.com first.last@gmail.com a-b.c_d+e@f.gx].each do |address| 39 | assert EmailAddress.valid?(address, host_validation: :syntax), "valid?(#{address})" 40 | end 41 | end 42 | 43 | def test_empty 44 | assert_equal "", EmailAddress.normal("") 45 | assert_equal "", EmailAddress.normal(" ") 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/email_address/email_address_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ################################################################################ 4 | # ActiveRecord v5.0 Custom Type 5 | # 6 | # 1) Register your types 7 | # 8 | # # config/initializers/email_address.rb 9 | # ActiveRecord::Type.register(:email_address, EmailAddress::Address) 10 | # ActiveRecord::Type.register(:canonical_email_address, 11 | # EmailAddress::CanonicalEmailAddressType) 12 | # 13 | # 2) Define your email address columns in your model class 14 | # 15 | # class User < ApplicationRecord 16 | # attribute :email, :email_address 17 | # attribute :canonical_email, :canonical_email_address 18 | # 19 | # def email=(email_address) 20 | # self[:canonical_email] = email_address 21 | # self[:email] = email_address 22 | # end 23 | # end 24 | # 25 | # 3) Profit! 26 | # 27 | # user = User.new(email:"Pat.Smith+registrations@gmail.com") 28 | # user.email #=> "pat.smith+registrations@gmail.com" 29 | # user.canonical_email #=> "patsmith@gmail.com" 30 | ################################################################################ 31 | 32 | module EmailAddress 33 | class EmailAddressType < ActiveRecord::Type::Value 34 | 35 | # From user input, setter 36 | def cast(value) 37 | super(Address.new(value).normal) 38 | end 39 | 40 | # From a database value 41 | def deserialize(value) 42 | value && Address.new(value).normal 43 | end 44 | 45 | # To a database value (string) 46 | def serialize(value) 47 | value && Address.new(value).normal 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/email_address/canonical_email_address_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ################################################################################ 4 | # ActiveRecord v5.0 Custom Type 5 | # 6 | # 1) Register your types 7 | # 8 | # # config/initializers/email_address.rb 9 | # ActiveRecord::Type.register(:email_address, EmailAddress::Address) 10 | # ActiveRecord::Type.register(:canonical_email_address, 11 | # EmailAddress::CanonicalEmailAddressType) 12 | # 13 | # 2) Define your email address columns in your model class 14 | # 15 | # class User < ApplicationRecord 16 | # attribute :email, :email_address 17 | # attribute :canonical_email, :canonical_email_address 18 | # 19 | # def email=(email_address) 20 | # self[:canonical_email] = email_address 21 | # self[:email] = email_address 22 | # end 23 | # end 24 | # 25 | # 3) Profit! 26 | # 27 | # user = User.new(email:"Pat.Smith+registrations@gmail.com") 28 | # user.email #=> "pat.smith+registrations@gmail.com" 29 | # user.canonical_email #=> "patsmith@gmail.com" 30 | ################################################################################ 31 | 32 | module EmailAddress 33 | class CanonicalEmailAddressType < ActiveRecord::Type::Value 34 | 35 | # From user input, setter 36 | def cast(value) 37 | super(Address.new(value).canonical) 38 | end 39 | 40 | # From a database value 41 | def deserialize(value) 42 | value && Address.new(value).normal 43 | end 44 | 45 | # To a database value (string) 46 | def serialize(value) 47 | value && Address.new(value).normal 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/activerecord/test_ar.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class TestAR < MiniTest::Test 4 | require_relative "user" 5 | 6 | def test_validation 7 | # Disabled JRuby checks... weird CI failures. Hopefully someone can help? 8 | if RUBY_PLATFORM != "java" # jruby 9 | user = User.new(email: "Pat.Jones+ASDF#GMAIL.com") 10 | assert_equal false, user.valid? 11 | assert user.errors.messages[:email].first 12 | 13 | user = User.new(email: "Pat.Jones+ASDF@GMAIL.com") 14 | assert_equal true, user.valid? 15 | end 16 | end 17 | 18 | def test_validation_error_message 19 | if RUBY_PLATFORM != "java" # jruby 20 | user = User.new(alternate_email: "Pat.Jones+ASDF#GMAIL.com") 21 | assert_equal false, user.valid? 22 | assert user.errors.messages[:alternate_email].first.include?("Check your email") 23 | assert_equal :some_error_code, user.errors.details[:alternate_email].first[:error] 24 | end 25 | end 26 | 27 | def test_datatype 28 | # Disabled JRuby checks... weird CI failures. Hopefully someone can help? 29 | if RUBY_PLATFORM != "java" # jruby 30 | if defined?(ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 5 31 | user = User.new(email: "Pat.Jones+ASDF@GMAIL.com") 32 | assert_equal "pat.jones+asdf@gmail.com", user.email 33 | assert_equal "patjones@gmail.com", user.canonical_email 34 | end 35 | end 36 | end 37 | 38 | def test_store_accessor_valid_email 39 | user = User.new(support_email: "test@gmail.com") 40 | assert user.valid? 41 | end 42 | 43 | def test_store_accessor_invalid_email 44 | user = User.new(support_email: "this_is_not_an_email") 45 | assert_equal false, user.valid? 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/email_address/active_record_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EmailAddress 4 | # ActiveRecord validator class for validating an email 5 | # address with this library. 6 | # Note the initialization happens once per process. 7 | # 8 | # Usage: 9 | # validates_with EmailAddress::ActiveRecordValidator, field: :name 10 | # 11 | # Options: 12 | # * field: email, 13 | # * fields: [:email1, :email2] 14 | # 15 | # * code: custom error code (default: :invalid_address) 16 | # * message: custom error message (default: "Invalid Email Address") 17 | # 18 | # Default field: :email or :email_address (first found) 19 | # 20 | # 21 | class ActiveRecordValidator < ActiveModel::Validator 22 | def initialize(options = {}) 23 | @opt = options 24 | end 25 | 26 | def validate(r) 27 | if @opt[:fields] 28 | @opt[:fields].each { |f| validate_email(r, f) } 29 | elsif @opt[:field] 30 | validate_email(r, @opt[:field]) 31 | else 32 | validate_email(r, :email) || validate_email(r, :email_address) 33 | end 34 | end 35 | 36 | def validate_email(r, f) 37 | v = field_value(r, f) 38 | return if v.nil? 39 | 40 | e = Address.new(v) 41 | unless e.valid? 42 | error_code = @opt[:code] || :invalid_address 43 | error_message = @opt[:message] || 44 | Config.error_message(:invalid_address, I18n.locale.to_s) || 45 | "Invalid Email Address" 46 | r.errors.add(f, error_code, message: error_message) 47 | end 48 | end 49 | 50 | def field_value(r, f) 51 | if r.respond_to?(f) 52 | r.send(f) 53 | elsif r[f] 54 | r[f] 55 | else 56 | nil 57 | end 58 | rescue NoMethodError 59 | nil 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/test_aliasing.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class TestAliasing < MiniTest::Test 4 | def setup 5 | Object.send(:const_set, :EmailAddressValidator, EmailAddress) 6 | Object.send(:remove_const, :EmailAddress) 7 | end 8 | 9 | def test_email_address_not_defined 10 | assert_nil defined?(EmailAddress) 11 | assert_nil defined?(EmailAddress::Address) 12 | assert_nil defined?(EmailAddress::Config) 13 | assert_nil defined?(EmailAddress::Exchanger) 14 | assert_nil defined?(EmailAddress::Host) 15 | assert_nil defined?(EmailAddress::Local) 16 | assert_nil defined?(EmailAddress::Rewriter) 17 | end 18 | 19 | def test_alias_defined 20 | assert_equal defined?(EmailAddressValidator), "constant" 21 | assert_equal defined?(EmailAddressValidator::Address), "constant" 22 | assert_equal defined?(EmailAddressValidator::Config), "constant" 23 | assert_equal defined?(EmailAddressValidator::Exchanger), "constant" 24 | assert_equal defined?(EmailAddressValidator::Host), "constant" 25 | assert_equal defined?(EmailAddressValidator::Local), "constant" 26 | assert_equal defined?(EmailAddressValidator::Rewriter), "constant" 27 | end 28 | 29 | def test_alias_class_methods 30 | assert_equal true, EmailAddressValidator.valid?("user@yahoo.com") 31 | end 32 | 33 | def test_alias_host_methods 34 | assert_equal true, EmailAddressValidator::Host.new("yahoo.com").valid? 35 | end 36 | 37 | def test_alias_address_methods 38 | assert_equal true, EmailAddressValidator::Address.new("user@yahoo.com").valid? 39 | end 40 | 41 | def test_alias_config_methods 42 | assert Hash, EmailAddressValidator::Config.new.to_h 43 | end 44 | 45 | def test_alias_local_methods 46 | assert_equal true, EmailAddressValidator::Local.new("user").valid? 47 | end 48 | 49 | def teardown 50 | Object.send(:const_set, :EmailAddress, EmailAddressValidator) 51 | Object.send(:remove_const, :EmailAddressValidator) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /email_address.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "email_address/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "email_address" 7 | spec.version = EmailAddress::VERSION 8 | spec.authors = ["Allen Fair"] 9 | spec.email = ["allen.fair@gmail.com"] 10 | spec.description = "The EmailAddress Gem to work with and validate email addresses." 11 | spec.summary = "This gem provides a ruby language library for working with and validating email addresses. By default, it validates against conventional usage, the format preferred for user email addresses. It can be configured to validate against RFC “Standard” formats, common email service provider formats, and perform DNS validation." 12 | spec.homepage = "https://github.com/afair/email_address" 13 | spec.license = "MIT" 14 | 15 | spec.required_ruby_version = ">= 2.5", "< 4" 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_development_dependency "rake" 21 | spec.add_development_dependency "minitest", "~> 5.11" 22 | spec.add_development_dependency "bundler" # , "~> 1.16.0" 23 | if RUBY_PLATFORM == "java" 24 | spec.add_development_dependency "activerecord", "~> 7.2.0" 25 | spec.add_development_dependency "activerecord-jdbcsqlite3-adapter", "~> 70.2" 26 | else 27 | spec.add_development_dependency "activerecord", "~> 8.0.0" 28 | spec.add_development_dependency "sqlite3", "~> 2.1" 29 | spec.add_development_dependency "standard" 30 | end 31 | spec.add_development_dependency "net-smtp" 32 | spec.add_development_dependency "simplecov" 33 | spec.add_development_dependency "pry" 34 | spec.add_development_dependency "bigdecimal" 35 | spec.add_development_dependency "mutex_m" 36 | spec.add_development_dependency "irb" 37 | 38 | spec.add_dependency "base64" 39 | spec.add_dependency "simpleidn" 40 | end 41 | -------------------------------------------------------------------------------- /test/activerecord/user.rb: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # ActiveRecord Test Setup ... 3 | ################################################################################ 4 | 5 | class ApplicationRecord < ActiveRecord::Base 6 | self.abstract_class = true 7 | end 8 | 9 | dbfile = ENV["EMAIL_ADDRESS_TEST_DB"] || "/tmp/email_address.gem.db" 10 | File.unlink(dbfile) if File.exist?(dbfile) 11 | 12 | # Connection: JRuby vs. MRI 13 | # Disabled JRuby checks... weird CI failures. Hopefully someone can help? 14 | if RUBY_PLATFORM == "java" # jruby 15 | # require "jdbc/sqlite3" 16 | # require "java" 17 | # require "activerecord-jdbcsqlite3-adapter" 18 | # Jdbc::SQLite3.load_driver 19 | # ActiveRecord::Base.establish_connection( 20 | # adapter: "jdbc", 21 | # driver: "org.sqlite.JDBC", 22 | # url: "jdbc:sqlite:" + dbfile 23 | # ) 24 | else 25 | require "sqlite3" 26 | ActiveRecord::Base.establish_connection( 27 | adapter: "sqlite3", 28 | database: dbfile 29 | ) 30 | 31 | # The following would be executed for both JRuby/MRI 32 | ApplicationRecord.connection.execute( 33 | "create table users ( email varchar, canonical_email varchar)" 34 | ) 35 | 36 | if defined?(ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 5 37 | ActiveRecord::Type.register(:email_address, EmailAddress::EmailAddressType) 38 | ActiveRecord::Type.register(:canonical_email_address, 39 | EmailAddress::CanonicalEmailAddressType) 40 | end 41 | end 42 | 43 | ################################################################################ 44 | # User Model 45 | ################################################################################ 46 | 47 | class User < ApplicationRecord 48 | store :settings, accessors: [ :support_email ], coder: JSON 49 | 50 | if defined?(ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 5 51 | attribute :email, :email_address 52 | attribute :canonical_email, :canonical_email_address 53 | attribute :alternate_email, :email_address 54 | end 55 | 56 | validates_with EmailAddress::ActiveRecordValidator, 57 | fields: %i[email canonical_email support_email] 58 | validates_with EmailAddress::ActiveRecordValidator, 59 | field: :alternate_email, code: :some_error_code, message: "Check your email" 60 | 61 | def email=(email_address) 62 | self[:canonical_email] = email_address 63 | self[:email] = email_address 64 | end 65 | 66 | def self.find_by_email(email) 67 | user = find_by(email: EmailAddress.normal(email)) 68 | user ||= find_by(canonical_email: EmailAddress.canonical(email)) 69 | user ||= find_by(canonical_email: EmailAddress.redacted(email)) 70 | user 71 | end 72 | 73 | def redact! 74 | self[:canonical_email] = EmailAddress.redact(canonical_email) 75 | self[:email] = self[:canonical_email] 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/email_address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # EmailAddress parses and validates email addresses against RFC standard, 4 | # conventional, canonical, formats and other special uses. 5 | module EmailAddress 6 | require "email_address/config" 7 | require "email_address/exchanger" 8 | require "email_address/host" 9 | require "email_address/local" 10 | require "email_address/rewriter" 11 | require "email_address/address" 12 | require "email_address/version" 13 | require "email_address/active_record_validator" if defined?(ActiveModel) 14 | if defined?(ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 5 15 | require "email_address/email_address_type" 16 | require "email_address/canonical_email_address_type" 17 | end 18 | 19 | # @!method self.valid?(email_address, options={}) 20 | # Proxy method to {EmailAddress::Address#valid?} 21 | # @!method self.error(email_address) 22 | # Proxy method to {EmailAddress::Address#error} 23 | # @!method self.normal(email_address) 24 | # Proxy method to {EmailAddress::Address#normal} 25 | # @!method self.redact(email_address, options={}) 26 | # Proxy method to {EmailAddress::Address#redact} 27 | # @!method self.munge(email_address, options={}) 28 | # Proxy method to {EmailAddress::Address#munge} 29 | # @!method self.base(email_address, options{}) 30 | # Returns the base form of the email address, the mailbox 31 | # without optional puncuation removed, no tag, and the host name. 32 | # @!method self.canonical(email_address, options{}) 33 | # Proxy method to {EmailAddress::Address#canonical} 34 | # @!method self.reference(email_address, form=:base, options={}) 35 | # Returns the reference form of the email address, by default 36 | # the MD5 digest of the Base Form the the address. 37 | # @!method self.srs(email_address, sending_domain, options={}) 38 | # Returns the address encoded for SRS forwarding. Pass a local 39 | # secret to use in options[:secret] 40 | class << self 41 | (%i[valid? error normal redact munge canonical reference base srs] & 42 | Address.public_instance_methods 43 | ).each do |proxy_method| 44 | define_method(proxy_method) do |*args, &block| 45 | Address.new(*args).public_send(proxy_method, &block) 46 | end 47 | end 48 | 49 | # Creates an instance of this email address. 50 | # This is a short-cut to EmailAddress::Address.new 51 | def new(email_address, config = {}, locale = "en") 52 | Address.new(email_address, config, locale) 53 | end 54 | 55 | def new_redacted(email_address, config = {}, locale = "en") 56 | Address.new(Address.new(email_address, config, locale).redact) 57 | end 58 | 59 | def new_canonical(email_address, config = {}, locale = "en") 60 | Address.new(Address.new(email_address, config, locale).canonical, config) 61 | end 62 | 63 | # Does the email address match any of the given rules 64 | def matches?(email_address, rules, config = {}, locale = "en") 65 | Address.new(email_address, config, locale).matches?(rules) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/email_address/exchanger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "resolv" 4 | require "socket" 5 | 6 | module EmailAddress 7 | class Exchanger 8 | include Enumerable 9 | 10 | def self.cached(host, config = {}) 11 | @host_cache ||= {} 12 | @cache_size ||= ENV["EMAIL_ADDRESS_CACHE_SIZE"].to_i || 100 13 | if @host_cache.has_key?(host) 14 | o = @host_cache.delete(host) 15 | @host_cache[host] = o # LRU cache, move to end 16 | elsif @host_cache.size >= @cache_size 17 | @host_cache.delete(@host_cache.keys.first) 18 | @host_cache[host] = new(host, config) 19 | else 20 | @host_cache[host] = new(host, config) 21 | end 22 | end 23 | 24 | def initialize(host, config = {}) 25 | @host = host 26 | @config = config.is_a?(Hash) ? Config.new(config) : config 27 | @dns_disabled = @config[:host_validation] == :syntax || @config[:dns_lookup] == :off 28 | end 29 | 30 | def each(&block) 31 | return if @dns_disabled 32 | mxers.each do |m| 33 | yield({host: m[0], ip: m[1], priority: m[2]}) 34 | end 35 | end 36 | 37 | # Returns the provider name based on the MX-er host names, or nil if not matched 38 | def provider 39 | return @provider if defined? @provider 40 | Config.providers.each do |provider, config| 41 | if config[:exchanger_match] && matches?(config[:exchanger_match]) 42 | return @provider = provider 43 | end 44 | end 45 | @provider = :default 46 | end 47 | 48 | # Returns: [["mta7.am0.yahoodns.net", "66.94.237.139", 1], ["mta5.am0.yahoodns.net", "67.195.168.230", 1], ["mta6.am0.yahoodns.net", "98.139.54.60", 1]] 49 | # If not found, returns [] 50 | # Returns a dummy record when dns_lookup is turned off since it may exists, though 51 | # may not find provider by MX name or IP. I'm not sure about the "0.0.0.0" ip, it should 52 | # be good in this context, but in "listen" context it means "all bound IP's" 53 | def mxers 54 | return [["example.com", "0.0.0.0", 1]] if @dns_disabled 55 | @mxers ||= Resolv::DNS.open { |dns| 56 | dns.timeouts = @config[:dns_timeout] if @config[:dns_timeout] 57 | 58 | ress = begin 59 | dns.getresources(@host, Resolv::DNS::Resource::IN::MX) 60 | rescue Resolv::ResolvTimeout 61 | [] 62 | end 63 | 64 | records = ress.map { |r| 65 | if r.exchange.to_s > " " 66 | [r.exchange.to_s, IPSocket.getaddress(r.exchange.to_s), r.preference] 67 | end 68 | } 69 | records.compact 70 | } 71 | # not found, but could also mean network not work or it could mean one record doesn't resolve an address 72 | rescue SocketError 73 | [["example.com", "0.0.0.0", 1]] 74 | end 75 | 76 | # Returns Array of domain names for the MX'ers, used to determine the Provider 77 | def domains 78 | @_domains ||= mxers.map { |m| Host.new(m.first).domain_name }.sort.uniq 79 | end 80 | 81 | # Returns an array of MX IP address (String) for the given email domain 82 | def mx_ips 83 | return ["0.0.0.0"] if @dns_disabled 84 | mxers.map { |m| m[1] } 85 | end 86 | 87 | # Simple matcher, takes an array of CIDR addresses (ip/bits) and strings. 88 | # Returns true if any MX IP matches the CIDR or host name ends in string. 89 | # Ex: match?(%w(127.0.0.1/32 0:0:1/64 .yahoodns.net)) 90 | # Note: Your networking stack may return IPv6 addresses instead of IPv4 91 | # when both are available. If matching on IP, be sure to include both 92 | # IPv4 and IPv6 forms for matching for hosts running on IPv6 (like gmail). 93 | def matches?(rules) 94 | rules = Array(rules) 95 | rules.each do |rule| 96 | if rule.include?("/") 97 | return rule if in_cidr?(rule) 98 | else 99 | each { |mx| return rule if mx[:host].end_with?(rule) } 100 | end 101 | end 102 | false 103 | end 104 | 105 | # Given a cidr (ip/bits) and ip address, returns true on match. Caches cidr object. 106 | def in_cidr?(cidr) 107 | net = IPAddr.new(cidr) 108 | found = mx_ips.detect { |ip| net.include?(IPAddr.new(ip)) } 109 | !!found 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/email_address/test_local.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class TestLocal < MiniTest::Test 4 | def test_valid_standard 5 | [ # Via https://en.wikipedia.org/wiki/Email_address 6 | %(prettyandsimple), 7 | %(very.common), 8 | %(disposable.style.email.with+symbol), 9 | %(other.email-with-dash), 10 | %("much.more unusual"), 11 | %{"(comment)very.unusual.@.unusual.com"}, 12 | %(#!$%&'*+-/=?^_`{}|~), 13 | %(" "), 14 | %{"very.(),:;<>[]\\".VERY.\\"very@\\ \\"very\\".unusual"}, 15 | %{"()<>[]:,;@\\\"!#$%&'*+-/=?^_`{}| ~.a"}, 16 | %(token." ".token), 17 | %(abc."defghi".xyz) 18 | ].each do |local| 19 | assert EmailAddress::Local.new(local, local_fix: false).standard?, local 20 | end 21 | end 22 | 23 | def test_invalid_standard 24 | [ # Via https://en.wikipedia.org/wiki/Email_address 25 | %(A@b@c), 26 | %{a"b(c)d,e:f;gi[j\k]l}, 27 | %(just"not"right), 28 | %(this is"not\allowed), 29 | %(this\ still\"not\\allowed), 30 | %(john..doe), 31 | %( invalid), 32 | %(invalid ), 33 | %(abc"defghi"xyz) 34 | ].each do |local| 35 | assert_equal false, EmailAddress::Local.new(local, local_fix: false).standard?, local 36 | end 37 | end 38 | 39 | def test_relaxed 40 | assert EmailAddress::Local.new("first..last", local_format: :relaxed).valid?, "relax.." 41 | assert EmailAddress::Local.new("first.-last", local_format: :relaxed).valid?, "relax.-" 42 | assert EmailAddress::Local.new("a", local_format: :relaxed).valid?, "relax single" 43 | assert EmailAddress::Local.new("firstlast_", local_format: :relaxed).valid?, "last_" 44 | end 45 | 46 | def test_unicode 47 | assert !EmailAddress::Local.new("üñîçøðé1", local_encoding: :ascii).standard?, "not üñîçøðé1" 48 | assert EmailAddress::Local.new("üñîçøðé2", local_encoding: :unicode).standard?, "üñîçøðé2" 49 | assert EmailAddress::Local.new("test", local_encoding: :unicode).valid?, "unicode should include ascii" 50 | assert !EmailAddress::Local.new("üñîçøðé3").valid?, "üñîçøðé3 valid" 51 | end 52 | 53 | def test_valid_conventional 54 | %w[first.last first First+Tag o'brien].each do |local| 55 | assert EmailAddress::Local.new(local).conventional?, local 56 | end 57 | end 58 | 59 | def test_invalid_conventional 60 | (%w[first;.last +leading trailing+ o%brien] + ["first space"]).each do |local| 61 | assert !EmailAddress::Local.new(local, local_fix: false).conventional?, local 62 | end 63 | end 64 | 65 | def test_valid 66 | refute EmailAddress::Local.new("first(comment)", local_format: :conventional).valid? 67 | assert EmailAddress::Local.new("first(comment)", local_format: :standard).valid? 68 | end 69 | 70 | def test_format 71 | assert_equal :conventional, EmailAddress::Local.new("can1").format? 72 | assert_equal :standard, EmailAddress::Local.new(%("can 1")).format? 73 | assert_equal "can1", EmailAddress::Local.new(%{"can1(commment)"}).format(:conventional) 74 | end 75 | 76 | def test_redacted 77 | l = "{bea3f3560a757f8142d38d212a931237b218eb5e}" 78 | assert EmailAddress::Local.redacted?(l), "redacted? #{l}" 79 | assert_equal :redacted, EmailAddress::Local.new(l).format? 80 | end 81 | 82 | def test_matches 83 | a = EmailAddress.new("User+tag@gmail.com") 84 | assert_equal false, a.matches?("user") 85 | assert_equal false, a.matches?("user@") 86 | assert_equal "user*@", a.matches?("user*@") 87 | end 88 | 89 | def test_munge 90 | assert_equal "us*****", EmailAddress::Local.new("User+tag").munge 91 | end 92 | 93 | def test_hosted 94 | assert EmailAddress.valid?("x@exposure.co") 95 | assert EmailAddress.error("xx+subscriber@gmail.com") 96 | assert EmailAddress.valid?("xxxxx+subscriber@gmail.com") 97 | end 98 | 99 | def test_ending_underscore 100 | assert EmailAddress.valid?("name_@icloud.com") 101 | assert EmailAddress.valid?("username_@gmail.com") 102 | assert EmailAddress.valid?("username_____@gmail.com") 103 | end 104 | 105 | def test_tag_punctuation 106 | assert EmailAddress.valid?("first.last+foo.bar@gmail.com") 107 | end 108 | 109 | def test_relaxed_tag 110 | assert EmailAddress.valid? "foo+abc@example.com", host_validation: :syntax, local_format: :relaxed 111 | end 112 | 113 | def test_tag_punctuation 114 | refute EmailAddress.valid?("name+tag.@domain.com", host_validation: :syntax) 115 | assert EmailAddress.valid?("name+tag.-@domain.com", host_validation: :syntax, local_format: :relaxed) 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/email_address/rewriter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "base64" 4 | 5 | module EmailAddress::Rewriter 6 | SRS_FORMAT_REGEX = /\ASRS0=(....)=(\w\w)=(.+?)=(.+?)@(.+)\z/ 7 | 8 | def parse_rewritten(e) 9 | @rewrite_scheme = nil 10 | @rewrite_error = nil 11 | parse_srs(e) 12 | # e = parse_batv(e) 13 | end 14 | 15 | #--------------------------------------------------------------------------- 16 | # SRS (Sender Rewriting Scheme) allows an address to be forwarded from the 17 | # original owner and encoded to be used with the domain name of the MTA (Mail 18 | # Transport Agent). It encodes the original address within the local part of the 19 | # sending email address and respects VERP. If example.com needs to forward a 20 | # message from "sender@gmail.com", the SMTP envelope sender is used at this 21 | # address. These methods respect DMARC and prevent spoofing email send using 22 | # a different domain. 23 | # Format: SRS0=HHH=TT=domain=local@sending-domain.com 24 | #--------------------------------------------------------------------------- 25 | def srs(sending_domain, options = {}, &block) 26 | tt = srs_tt 27 | a = [tt, hostname, local.to_s].join("=") + "@" + sending_domain 28 | hhh = srs_hash(a, options, &block) 29 | 30 | ["SRS0", hhh, a].join("=") 31 | end 32 | 33 | def srs?(email) 34 | email.match(SRS_FORMAT_REGEX) ? true : false 35 | end 36 | 37 | def parse_srs(email, options = {}, &block) 38 | if email&.match(SRS_FORMAT_REGEX) 39 | @rewrite_scheme = :srs 40 | hhh, tt, domain, local, sending_domain = [$1, $2, $3, $4, $5] 41 | # hhh = tt = sending_domain if false && hhh # Hide warnings for now :-) 42 | a = [tt, domain, local].join("=") + "@" + sending_domain 43 | unless srs_hash(a, options, &block) === hhh 44 | @rewrite_error = "Invalid SRS Email Address: Possibly altered" 45 | end 46 | unless tt == srs_tt 47 | @rewrite_error = "Invalid SRS Email Address: Too old" 48 | end 49 | [local, domain].join("@") 50 | else 51 | email 52 | end 53 | end 54 | 55 | # SRS Timeout Token 56 | # Returns a 2-character code for the day. After a few days the code will roll. 57 | # TT has a one-day resolution in order to make the address invalid after a few days. 58 | # The cycle period is 3.5 years. Used to control late bounces and harvesting. 59 | def srs_tt(t = Time.now.utc) 60 | Base64.encode64((t.to_i / (60 * 60 * 24) % 210).to_s)[0, 2] 61 | end 62 | 63 | def srs_hash(email, options = {}, &block) 64 | key = options[:key] || @config[:key] || email.reverse 65 | if block 66 | block.call(email)[0, 4] 67 | else 68 | Base64.encode64(Digest::SHA1.digest(email + key))[0, 4] 69 | end 70 | end 71 | 72 | #--------------------------------------------------------------------------- 73 | # Returns a BATV form email address with "Private Signature" (prvs). 74 | # Options: key: 0-9 key digit to use 75 | # key_0..key_9: secret key used to sign/verify 76 | # prvs_days: number of days before address "expires" 77 | # 78 | # BATV - Bounce Address Tag Validation 79 | # PRVS - Simple Private Signature 80 | # Ex: prvs=KDDDSSSS=user@example.com 81 | # * K: Digit for Key rotation 82 | # * DDD: Expiry date, since 1970, low 3 digits 83 | # * SSSSSS: sha1( KDDD + orig-mailfrom + key)[0,6] 84 | # See: https://tools.ietf.org/html/draft-levine-smtp-batv-01 85 | #--------------------------------------------------------------------------- 86 | def batv_prvs(options = {}) 87 | k = options[:prvs_key_id] || "0" 88 | prvs_days = options[:prvs_days] || @config[:prvs_days] || 30 89 | ddd = prvs_day(prvs_days) 90 | ssssss = prvs_sign(k, ddd, to_s, options) 91 | ["prvs=", k, ddd, ssssss, "=", to_s].join("") 92 | end 93 | 94 | PRVS_REGEX = /\Aprvs=(\d)(\d{3})(\w{6})=(.+)\z/ 95 | 96 | def parse_prvs(email, options = {}) 97 | if email.match(PRVS_REGEX) 98 | @rewrite_scheme = :prvs 99 | k, ddd, ssssss, email = [$1, $2, $3, $4] 100 | 101 | unless ssssss == prvs_sign(k, ddd, email, options) 102 | @rewrite_error = "Invalid BATV Address: Signature unverified" 103 | end 104 | exp = ddd.to_i 105 | roll = 1000 - exp # rolling 1000 day window 106 | today = prvs_day(0) 107 | # I'm sure this is wrong 108 | if exp > today && exp < roll 109 | @rewrite_error = "Invalid SRS Email Address: Address expired" 110 | elsif exp < today && (today - exp) > 0 111 | @rewrite_error = "Invalid SRS Email Address: Address expired" 112 | end 113 | [local, domain].join("@") 114 | else 115 | email 116 | end 117 | end 118 | 119 | def prvs_day(days) 120 | ((Time.now.to_i + (days * 24 * 60 * 60)) / (24 * 60 * 60)).to_s[-3, 3] 121 | end 122 | 123 | def prvs_sign(k, ddd, email, options = {}) 124 | str = [ddd, ssssss, "=", to_s].join("") 125 | key = options["key_#{k}".to_i] || @config["key_#{k}".to_i] || str.reverse 126 | Digest::SHA1.hexdigest([k, ddd, email, key].join(""))[0, 6] 127 | end 128 | 129 | #--------------------------------------------------------------------------- 130 | # VERP Embeds a recipient email address into the bounce address 131 | # Bounce Address: message-id@example.net 132 | # Recipient Email: recipient@example.org 133 | # VERP : message-id+recipient=example.org@example.net 134 | # To handle incoming verp, the "tag" is the recipient email address, 135 | # remember to convert the last '=' into a '@' to reconstruct it. 136 | #--------------------------------------------------------------------------- 137 | def verp(recipient, split_char = "+") 138 | local.to_s + 139 | split_char + recipient.tr("@", "=") + 140 | "@" + hostname 141 | end 142 | 143 | # NEXT: DMARC, SPF Validation 144 | end 145 | -------------------------------------------------------------------------------- /test/email_address/test_host.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class TestHost < MiniTest::Test 4 | def test_host 5 | a = EmailAddress::Host.new("example.com") 6 | assert_equal "example.com", a.host_name 7 | assert_equal "example.com", a.domain_name 8 | assert_equal "example", a.registration_name 9 | assert_equal "com", a.tld 10 | assert_equal "ex*****", a.munge 11 | assert_nil a.subdomains 12 | end 13 | 14 | def test_dns_enabled 15 | a = EmailAddress::Host.new("example.com") 16 | assert_instance_of TrueClass, a.dns_enabled? 17 | a = EmailAddress::Host.new("example.com", host_validation: :syntax) 18 | assert_instance_of FalseClass, a.dns_enabled? 19 | a = EmailAddress::Host.new("example.com", dns_lookup: :off) 20 | assert_instance_of FalseClass, a.dns_enabled? 21 | end 22 | 23 | def test_foreign_host 24 | a = EmailAddress::Host.new("my.yahoo.co.jp") 25 | assert_equal "my.yahoo.co.jp", a.host_name 26 | assert_equal "yahoo.co.jp", a.domain_name 27 | assert_equal "yahoo", a.registration_name 28 | assert_equal "co.jp", a.tld2 29 | assert_equal "my", a.subdomains 30 | end 31 | 32 | def test_ip_host 33 | a = EmailAddress::Host.new("[127.0.0.1]") 34 | assert_equal "[127.0.0.1]", a.name 35 | assert_equal "127.0.0.1", a.ip_address 36 | end 37 | 38 | def test_unicode_host 39 | a = EmailAddress::Host.new("å.com") 40 | assert_equal "xn--5ca.com", a.dns_name 41 | a = EmailAddress::Host.new("xn--5ca.com", host_encoding: :unicode) 42 | assert_equal "å.com", a.to_s 43 | end 44 | 45 | def test_provider 46 | a = EmailAddress::Host.new("my.yahoo.co.jp") 47 | assert_equal :yahoo, a.provider 48 | a = EmailAddress::Host.new("example.com") 49 | assert_equal :default, a.provider 50 | end 51 | 52 | def test_dmarc 53 | d = EmailAddress::Host.new("yahoo.com").dmarc 54 | assert_equal "reject", d[:p] 55 | d = EmailAddress::Host.new("calculator.net").dmarc 56 | assert_equal true, d.empty? 57 | end 58 | 59 | def test_ipv4 60 | h = EmailAddress::Host.new("[127.0.0.1]", host_allow_ip: true, host_local: true) 61 | assert_equal "127.0.0.1", h.ip_address 62 | assert_equal true, h.valid? 63 | end 64 | 65 | def test_ipv6 66 | h = EmailAddress::Host.new("[IPv6:::1]", host_allow_ip: true, host_local: true) 67 | assert_equal "::1", h.ip_address 68 | assert_equal true, h.valid? 69 | end 70 | 71 | def test_localhost 72 | h = EmailAddress::Host.new("localhost", host_local: true, host_validation: :syntax) 73 | assert_equal true, h.valid? 74 | end 75 | 76 | def test_host_no_dot 77 | h = EmailAddress::Host.new("local", host_validation: :syntax) 78 | assert_equal false, h.valid? 79 | end 80 | 81 | def test_host_no_dot_enable_fqdn 82 | h = EmailAddress::Host.new("local", host_fqdn: false, host_validation: :syntax) 83 | assert_equal true, h.valid? 84 | end 85 | 86 | def test_comment 87 | h = EmailAddress::Host.new("(oops)gmail.com") 88 | assert_equal "gmail.com", h.to_s 89 | assert_equal "oops", h.comment 90 | h = EmailAddress::Host.new("gmail.com(oops)") 91 | assert_equal "gmail.com", h.to_s 92 | assert_equal "oops", h.comment 93 | end 94 | 95 | def test_matches 96 | h = EmailAddress::Host.new("yahoo.co.jp") 97 | assert_equal false, h.matches?("gmail.com") 98 | assert_equal "yahoo.co.jp", h.matches?("yahoo.co.jp") 99 | assert_equal ".co.jp", h.matches?(".co.jp") 100 | assert_equal ".jp", h.matches?(".jp") 101 | assert_equal "yahoo.", h.matches?("yahoo.") 102 | assert_equal "yah*.jp", h.matches?("yah*.jp") 103 | end 104 | 105 | def test_ipv4_matches 106 | h = EmailAddress::Host.new("[123.123.123.8]", host_allow_ip: true) 107 | assert_equal "123.123.123.8", h.ip_address 108 | assert_equal false, h.matches?("127.0.0.0/8") 109 | assert_equal "123.123.123.0/24", h.matches?("123.123.123.0/24") 110 | end 111 | 112 | def test_ipv6_matches 113 | h = EmailAddress::Host.new("[IPV6:2001:db8::1]", host_allow_ip: true) 114 | assert_equal "2001:db8::1", h.ip_address 115 | assert_equal false, h.matches?("2002:db8::/118") 116 | assert_equal "2001:db8::/118", h.matches?("2001:db8::/118") 117 | end 118 | 119 | def test_regexen 120 | assert "asdf.com".match EmailAddress::Host::CANONICAL_HOST_REGEX 121 | assert "xn--5ca.com".match EmailAddress::Host::CANONICAL_HOST_REGEX 122 | assert "[127.0.0.1]".match EmailAddress::Host::STANDARD_HOST_REGEX 123 | assert "[IPv6:2001:dead::1]".match EmailAddress::Host::STANDARD_HOST_REGEX 124 | assert_nil "[256.0.0.1]".match(EmailAddress::Host::STANDARD_HOST_REGEX) 125 | end 126 | 127 | def test_hosted_service 128 | # Is there a gmail-hosted domain that will continue to exist? Removing until then 129 | # assert EmailAddress.valid?("test@jiff.com", dns_lookup: :mx) 130 | assert !EmailAddress.valid?("t@gmail.com", dns_lookup: :mx) 131 | end 132 | 133 | def test_yahoo_bad_tld 134 | assert !EmailAddress.valid?("test@yahoo.badtld") 135 | assert !EmailAddress.valid?("test@yahoo.wtf") # Registered, but MX IP = 0.0.0.0 136 | end 137 | 138 | def test_bad_formats 139 | assert !EmailAddress::Host.new("ya hoo.com").valid? 140 | assert EmailAddress::Host.new("ya hoo.com", host_remove_spaces: true).valid? 141 | end 142 | 143 | def test_errors 144 | assert_nil EmailAddress::Host.new("yahoo.com").error 145 | #assert_equal EmailAddress::Host.new("example.com").error, "This domain is not configured to accept email" 146 | assert_equal EmailAddress::Host.new("yahoo.wtf").error, "Domain name not registered" 147 | assert_nil EmailAddress::Host.new("ajsdfhajshdfklasjhd.wtf", host_validation: :syntax).error 148 | assert_equal EmailAddress::Host.new("ya hoo.com", host_validation: :syntax).error, "Invalid Domain Name" 149 | assert_equal EmailAddress::Host.new("[127.0.0.1]").error, "IP Addresses are not allowed" 150 | assert_equal EmailAddress::Host.new("[127.0.0.666]", host_allow_ip: true).error, "This is not a valid IPv4 address" 151 | assert_equal EmailAddress::Host.new("[IPv6::12t]", host_allow_ip: true).error, "This is not a valid IPv6 address" 152 | end 153 | 154 | def test_host_size 155 | assert !EmailAddress::Host.new("stackoverflow.com", {host_size: 1..3}).valid? 156 | end 157 | 158 | # When a domain is not configured to receive email (missing MX record), 159 | # Though some MTA's will fallback to the A/AAAA host record, so this isn't a good test 160 | #def test_no_mx 161 | # assert !EmailAddress::Host.new("zaboz.com").valid? 162 | # assert EmailAddress::Host.new("zaboz.com", dns_lookup: :a).valid? 163 | #end 164 | 165 | # Issue #102 off---white.com should be valid 166 | def test_triple_dash_domain 167 | assert EmailAddress::Host.new("off---white.com").valid? 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /test/email_address/test_address.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class TestAddress < Minitest::Test 4 | def test_address 5 | a = EmailAddress.new("User+tag@example.com") 6 | assert_equal "user+tag", a.local.to_s 7 | assert_equal "example.com", a.host.to_s 8 | assert_equal "us*****@ex*****", a.munge 9 | assert_equal :default, a.provider 10 | end 11 | 12 | # LOCAL 13 | def test_local 14 | a = EmailAddress.new("User+tag@example.com") 15 | assert_equal "user", a.mailbox 16 | assert_equal "user+tag", a.left 17 | assert_equal "tag", a.tag 18 | end 19 | 20 | # HOST 21 | def test_host 22 | a = EmailAddress.new("User+tag@example.com") 23 | assert_equal "example.com", a.hostname 24 | # assert_equal :default, a.provider 25 | end 26 | 27 | # ADDRESS 28 | def test_forms 29 | a = EmailAddress.new("User+tag@example.com") 30 | assert_equal "user+tag@example.com", a.to_s 31 | assert_equal "user@example.com", a.base 32 | assert_equal "user@example.com", a.canonical 33 | assert_equal "{63a710569261a24b3766275b7000ce8d7b32e2f7}@example.com", a.redact 34 | assert_equal "{b58996c504c5638798eb6b511e6f49af}@example.com", a.redact(:md5) 35 | assert_equal "b58996c504c5638798eb6b511e6f49af", a.reference 36 | assert_equal "6bdd00c53645790ad9bbcb50caa93880", EmailAddress.reference("Gmail.User+tag@gmail.com") 37 | end 38 | 39 | def test_sha1 40 | a = EmailAddress.new("User+tag@example.com") 41 | assert_equal "63a710569261a24b3766275b7000ce8d7b32e2f7", a.sha1 42 | end 43 | 44 | def test_sha1_with_secret 45 | a = EmailAddress.new("User+tag@example.com", sha1_secret: "test-secret") 46 | assert_equal "122df4ec3ce7121db6edc06a9e29eab39a7e8007", a.sha1 47 | end 48 | 49 | def test_sha256 50 | a = EmailAddress.new("User+tag@example.com") 51 | assert_equal "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514", a.sha256 52 | end 53 | 54 | def test_sha256_with_secret 55 | a = EmailAddress.new("User+tag@example.com", sha256_secret: "test-secret") 56 | assert_equal "480899ff53ccd446cc123f0c5685869644af445e788f1b559054919674307a07", a.sha256 57 | end 58 | 59 | def test_google_hosted 60 | a = EmailAddress.new("Ex.am.ple+tag@boomer.com") 61 | assert_equal a.canonical, "ex.am.ple@boomer.com" 62 | end 63 | 64 | # COMPARISON & MATCHING 65 | def test_compare 66 | a = "User+tag@example.com" 67 | # e = EmailAddress.new("user@example.com") 68 | n = EmailAddress.new(a) 69 | c = EmailAddress.new_canonical(a) 70 | # r = EmailAddress.new_redacted(a) 71 | assert_equal true, n == "user+tag@example.com" 72 | assert_equal true, n > "b@example.com" 73 | assert_equal true, n.same_as?(c) 74 | assert_equal true, n.same_as?(a) 75 | end 76 | 77 | def test_matches 78 | a = EmailAddress.new("User+tag@gmail.com") 79 | assert_equal false, a.matches?("mail.com") 80 | assert_equal "google", a.matches?("google") 81 | assert_equal "user+tag@", a.matches?("user+tag@") 82 | assert_equal "user*@gmail*", a.matches?("user*@gmail*") 83 | end 84 | 85 | def test_empty_address 86 | a = EmailAddress.new("") 87 | assert_equal "{9a78211436f6d425ec38f5c4e02270801f3524f8}", a.redact 88 | assert_equal "", a.to_s 89 | assert_equal "", a.canonical 90 | assert_equal "518ed29525738cebdac49c49e60ea9d3", a.reference 91 | end 92 | 93 | # VALIDATION 94 | def test_valid 95 | assert EmailAddress.valid?("User+tag@example.com", host_validation: :a), "valid 1" 96 | assert !EmailAddress.valid?("User%tag@example.com", host_validation: :a), "valid 2" 97 | assert EmailAddress.new("ɹᴉɐℲuǝll∀@ɹᴉɐℲuǝll∀.ws", local_encoding: :uncode, host_validation: :syntax), "valid unicode" 98 | end 99 | 100 | def test_localhost 101 | e = EmailAddress.new("User+tag.gmail.ws") # No domain means localhost 102 | assert_equal "", e.hostname 103 | assert_equal false, e.valid? # localhost not allowed by default 104 | assert_equal EmailAddress.error("user1"), "Invalid Domain Name" 105 | #assert_equal EmailAddress.error("user1", host_local: true), "This domain is not configured to accept email" 106 | assert_equal EmailAddress.error("user1", host_local: true, host_auto_append: false), "Invalid Domain Name" 107 | #assert_equal EmailAddress.error("user1@localhost", host_local: true), "This domain is not configured to accept email" 108 | assert_equal EmailAddress.error("user1@localhost", host_local: false, host_validation: :syntax), "localhost is not allowed for your domain name" 109 | assert_equal EmailAddress.error("user1@localhost", host_local: false, dns_lookup: :off), "localhost is not allowed for your domain name" 110 | assert_nil EmailAddress.error("user2@localhost", host_local: true, dns_lookup: :off, host_validation: :syntax) 111 | end 112 | 113 | def test_regexen 114 | assert "First.Last+TAG@example.com".match(EmailAddress::Address::CONVENTIONAL_REGEX) 115 | assert "First.Last+TAG@example.com".match(EmailAddress::Address::STANDARD_REGEX) 116 | assert_nil "First.Last+TAGexample.com".match(EmailAddress::Address::STANDARD_REGEX) 117 | assert_nil "First#Last+TAGexample.com".match(EmailAddress::Address::CONVENTIONAL_REGEX) 118 | assert "aasdf-34-.z@example.com".match(EmailAddress::Address::RELAXED_REGEX) 119 | end 120 | 121 | def test_srs 122 | ea = "first.LAST+tag@gmail.com" 123 | e = EmailAddress.new(ea) 124 | s = e.srs("example.com") 125 | assert s.match(EmailAddress::Address::SRS_FORMAT_REGEX) 126 | assert EmailAddress.new(s).to_s == e.to_s 127 | end 128 | 129 | # Quick Regression tests for addresses that should have been valid (but fixed) 130 | def test_issues 131 | assert true, EmailAddress.valid?("test@jiff.com", dns_lookup: :mx) # #7 132 | assert true, EmailAddress.valid?("w.-asdf-_@hotmail.com") # #8 133 | assert true, EmailAddress.valid?("first_last@hotmail.com") # #8 134 | end 135 | 136 | def test_issue9 137 | assert !EmailAddress.valid?("example.user@foo.") 138 | assert !EmailAddress.valid?("ogog@sss.c") 139 | assert !EmailAddress.valid?("example.user@foo.com/") 140 | end 141 | 142 | def test_relaxed_normal 143 | assert !EmailAddress.new("a.c.m.e.-industries@foo.com").valid? 144 | assert true, EmailAddress.new("a.c.m.e.-industries@foo.com", local_format: :relaxed).valid? 145 | end 146 | 147 | def test_nil_address 148 | assert_nil EmailAddress.new(nil).normal, "Expected a nil input to make nil output" 149 | end 150 | 151 | def test_nonstandard_tag 152 | # assert EmailAddress.valid?("asdfas+-@icloud.com") 153 | end 154 | 155 | def test_newline_characters 156 | assert !EmailAddress.valid?("user@foo.com\nother@bar.com") 157 | assert !EmailAddress.valid?("user@foo.com\r\nother@bar.com") 158 | assert EmailAddress.valid?("\nuser@foo.com") # valid because strip processing removes \n 159 | assert EmailAddress.valid?("user@foo.com\r\n") # valid because strip processing removes \n 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/email_address/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EmailAddress 4 | # Global configurations and for default/unknown providers. Settings are: 5 | # 6 | # * dns_lookup: :mx, :a, :off 7 | # Enables DNS lookup for validation by 8 | # :mx - DNS MX Record lookup 9 | # :a - DNS A Record lookup (as some domains don't specify an MX incorrectly) 10 | # :off - Do not perform DNS lookup (Test mode, network unavailable) 11 | # 12 | # * dns_timeout: nil 13 | # False, or a timeout in seconds. Timeout on the DNS lookup, after which it will fail. 14 | # 15 | # * sha1_secret "" 16 | # This application-level secret is appended to the email_address to compute 17 | # the SHA1 Digest, making it unique to your application so it can't easily be 18 | # discovered by comparing against a known list of email/sha1 pairs. 19 | # 20 | # * sha256_secret "" 21 | # This application-level secret is appended to the email_address to compute 22 | # the SHA256 Digest, making it unique to your application so it can't easily be 23 | # discovered by comparing against a known list of email/sha256 pairs. 24 | # 25 | # For local part configuration: 26 | # * local_downcase: true 27 | # Downcase the local part. You probably want this for uniqueness. 28 | # RFC says local part is case insensitive, that's a bad part. 29 | # 30 | # * local_fix: true, 31 | # Make simple fixes when available, remove spaces, condense multiple punctuations 32 | # 33 | # * local_encoding: :ascii, :unicode, 34 | # Enable Unicode in local part. Most mail systems do not yet support this. 35 | # You probably want to stay with ASCII for now. 36 | # 37 | # * local_parse: nil, ->(local) { [mailbox, tag, comment] } 38 | # Specify an optional lambda/Proc to parse the local part. It should return an 39 | # array (tuple) of mailbox, tag, and comment. 40 | # 41 | # * local_format: :conventional, :relaxed, :redacted, :standard, Proc 42 | # :conventional word ( puncuation{1} word )* 43 | # :relaxed alphanum ( allowed_characters)* alphanum 44 | # :standard RFC Compliant email addresses (anything goes!) 45 | # 46 | # * local_size: 1..64, 47 | # A Range specifying the allowed size for mailbox + tags + comment 48 | # 49 | # * tag_separator: nil, character (+) 50 | # Nil, or a character used to split the tag from the mailbox 51 | # 52 | # For the mailbox (AKA account, role), without the tag 53 | # * mailbox_size: 1..64 54 | # A Range specifying the allowed size for mailbox 55 | # 56 | # * mailbox_canonical: nil, ->(mailbox) { mailbox } 57 | # An optional lambda/Proc taking a mailbox name, returning a canonical 58 | # version of it. (E.G.: gmail removes '.' characters) 59 | # 60 | # * mailbox_validator: nil, ->(mailbox) { true } 61 | # An optional lambda/Proc taking a mailbox name, returning true or false. 62 | # 63 | # * host_encoding: :punycode, :unicode, 64 | # How to treat International Domain Names (IDN). Note that most mail and 65 | # DNS systems do not support unicode, so punycode needs to be passed. 66 | # :punycode Convert Unicode names to punycode representation 67 | # :unicode Keep Unicode names as is. 68 | # 69 | # * host_validation: 70 | # :mx Ensure host is configured with DNS MX records 71 | # :a Ensure host is known to DNS (A Record) 72 | # :syntax Validate by syntax only, no Network verification 73 | # :connect Attempt host connection (not implemented, BAD!) 74 | # 75 | # * host_size: 1..253, 76 | # A range specifying the size limit of the host part, 77 | # 78 | # * host_allow_ip: false, 79 | # Allow IP address format in host: [127.0.0.1], [IPv6:::1] 80 | # 81 | # * host_local: false, 82 | # Allow localhost, no domain, or local subdomains. 83 | # 84 | # * host_fqdn: true 85 | # Check if host name is FQDN 86 | # 87 | # * host_auto_append: true 88 | # Append localhost if host is missing 89 | # 90 | # * address_validation: :parts, :smtp, ->(address) { true } 91 | # Address validation policy 92 | # :parts Validate local and host. 93 | # :smtp Validate via SMTP (not implemented, BAD!) 94 | # A lambda/Proc taking the address string, returning true or false 95 | # 96 | # * address_size: 3..254, 97 | # A range specifying the size limit of the complete address 98 | # 99 | # * address_fqdn_domain: nil || "domain.tld" 100 | # Configure to complete the FQDN (Fully Qualified Domain Name) 101 | # When host is blank, this value is used 102 | # When host is computer name only, a dot and this is appended to get the FQDN 103 | # You probably don't want this unless you have host-local email accounts 104 | # 105 | # For provider rules to match to domain names and Exchanger hosts 106 | # The value is an array of match tokens. 107 | # * host_match: %w(.org example.com hotmail. user*@ sub.*.com) 108 | # * exchanger_match: %w(google.com 127.0.0.1 10.9.8.0/24 ::1/64) 109 | # 110 | 111 | require "yaml" 112 | 113 | class Config 114 | @config = { 115 | dns_lookup: :a, # :mx, :a, :off 116 | dns_timeout: nil, 117 | sha1_secret: "", 118 | sha256_secret: "", 119 | munge_string: "*****", 120 | 121 | local_downcase: true, 122 | local_fix: false, 123 | local_encoding: :ascii, # :ascii, :unicode, 124 | local_parse: nil, # nil, Proc 125 | local_format: :conventional, # :conventional, :relaxed, :redacted, :standard, Proc 126 | local_size: 1..64, 127 | tag_separator: "+", # nil, character 128 | mailbox_size: 1..64, # without tag 129 | mailbox_canonical: nil, # nil, Proc 130 | mailbox_validator: nil, # nil, Proc 131 | 132 | host_encoding: :punycode || :unicode, 133 | host_validation: :mx || :a || :connect || :syntax, 134 | host_size: 1..253, 135 | host_allow_ip: false, 136 | host_remove_spaces: false, 137 | host_local: false, 138 | host_fqdn: true, 139 | host_auto_append: true, 140 | host_timeout: 3, 141 | 142 | address_validation: :parts, # :parts, :smtp, Proc 143 | address_size: 3..254, 144 | address_fqdn_domain: nil # Fully Qualified Domain Name = [host].[domain.tld] 145 | } 146 | 147 | # 2018-04: AOL and Yahoo now under "oath.com", owned by Verizon. Keeping separate for now 148 | @providers = { 149 | aol: { 150 | host_match: %w[aol. compuserve. netscape. aim. cs.] 151 | }, 152 | google: { 153 | host_match: %w[gmail.com googlemail.com], 154 | exchanger_match: %w[google.com googlemail.com], 155 | local_size: 3..64, 156 | local_private_size: 1..64, # When hostname not in host_match (private label) 157 | mailbox_canonical: ->(m) { m.delete(".") } 158 | }, 159 | msn: { 160 | host_match: %w[msn. hotmail. outlook. live.], 161 | exchanger_match: %w[outlook.com], 162 | mailbox_validator: ->(m, t) { m =~ /\A\w[-\w]*(?:\.[-\w]+)*\z/i } 163 | }, 164 | yahoo: { 165 | host_match: %w[yahoo. ymail. rocketmail.], 166 | exchanger_match: %w[yahoodns yahoo-inc] 167 | } 168 | } 169 | 170 | # Loads messages: {"en"=>{"email_address"=>{"invalid_address"=>"Invalid Email Address",...}}} 171 | # Rails/I18n gem: t(email_address.error, scope: "email_address") 172 | @errors = YAML.load_file(File.dirname(__FILE__) + "/messages.yaml") 173 | 174 | # Set multiple default configuration settings 175 | def self.configure(config = {}) 176 | @config.merge!(config) 177 | end 178 | 179 | def self.setting(name, *value) 180 | name = name.to_sym 181 | @config[name] = value.first if value.size > 0 182 | @config[name] 183 | end 184 | 185 | # Returns the hash of Provider rules 186 | class << self 187 | attr_reader :providers 188 | end 189 | 190 | # Configure or lookup a provider by name. 191 | def self.provider(name, config = {}) 192 | name = name.to_sym 193 | if config.size > 0 194 | @providers[name.to_sym] = config 195 | end 196 | @providers[name] 197 | end 198 | 199 | def self.error_message(name, locale = "en") 200 | @errors.dig(locale, "email_address", name.to_s) || name.to_s 201 | end 202 | 203 | # Customize your own error message text. 204 | def self.error_messages(hash = {}, locale = "en", *extra) 205 | hash = extra.first if extra.first.is_a? Hash 206 | 207 | @errors[locale] ||= {} 208 | @errors[locale]["email_address"] ||= {} 209 | 210 | unless hash.nil? || hash.empty? 211 | @errors[locale]["email_address"] = @errors[locale]["email_address"].merge(hash) 212 | end 213 | 214 | @errors[locale]["email_address"] 215 | end 216 | 217 | def self.all_settings(*configs) 218 | config = @config.clone 219 | configs.each { |c| config.merge!(c) } 220 | config 221 | end 222 | 223 | def initialize(overrides = {}) 224 | @config = Config.all_settings(overrides) 225 | end 226 | 227 | def []=(setting, value) 228 | @config[setting.to_sym] = value 229 | end 230 | 231 | def [](setting) 232 | @config[setting.to_sym] 233 | end 234 | 235 | def configure(settings) 236 | @config = @config.merge(settings) 237 | end 238 | 239 | def to_h 240 | @config 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /lib/email_address/address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest/sha1" 4 | require "digest/sha2" 5 | require "digest/md5" 6 | 7 | module EmailAddress 8 | # Implements the Email Address container, which hold the Local 9 | # (EmailAddress::Local) and Host (EmailAddress::Host) parts. 10 | class Address 11 | include Comparable 12 | include Rewriter 13 | 14 | attr_accessor :original, :local, :host, :config, :reason, :locale 15 | 16 | CONVENTIONAL_REGEX = /\A#{Local::CONVENTIONAL_MAILBOX_WITHIN} 17 | @#{Host::DNS_HOST_REGEX}\z/x 18 | STANDARD_REGEX = /\A#{Local::STANDARD_LOCAL_WITHIN} 19 | @#{Host::DNS_HOST_REGEX}\z/x 20 | RELAXED_REGEX = /\A#{Local::RELAXED_MAILBOX_WITHIN} 21 | @#{Host::DNS_HOST_REGEX}\z/x 22 | 23 | # Given an email address of the form "local@hostname", this sets up the 24 | # instance, and initializes the address to the "normalized" format of the 25 | # address. The original string is available in the #original method. 26 | def initialize(email_address, config = {}, locale = "en") 27 | @config = Config.new(config) 28 | @original = email_address 29 | @locale = locale 30 | email_address = (email_address || "").strip 31 | email_address = parse_rewritten(email_address) unless config[:skip_rewrite] 32 | local, host = Address.split_local_host(email_address) 33 | 34 | @host = Host.new(host, @config, locale) 35 | @local = Local.new(local, @config, @host, locale) 36 | @error = @error_message = nil 37 | end 38 | 39 | # Given an email address, this returns an array of [local, host] parts 40 | def self.split_local_host(email) 41 | if (lh = email.match(/\A(.+)@(.+)\z/)) 42 | lh.to_a[1, 2] 43 | else 44 | [email, ""] 45 | end 46 | end 47 | 48 | ############################################################################ 49 | # Local Part (left of @) access 50 | # * local: Access full local part instance 51 | # * left: everything on the left of @ 52 | # * mailbox: parsed mailbox or email account name 53 | # * tag: address tag (mailbox+tag) 54 | ############################################################################ 55 | 56 | # Everything to the left of the @ in the address, called the local part. 57 | def left 58 | local.to_s 59 | end 60 | 61 | # Returns the mailbox portion of the local port, with no tags. Usually, this 62 | # can be considered the user account or role account names. Some systems 63 | # employ dynamic email addresses which don't have the same meaning. 64 | def mailbox 65 | local.mailbox 66 | end 67 | 68 | # Returns the tag part of the local address, or nil if not given. 69 | def tag 70 | local.tag 71 | end 72 | 73 | # Retuns any comments parsed from the local part of the email address. 74 | # This is retained for inspection after construction, even if it is 75 | # removed from the normalized email address. 76 | def comment 77 | local.comment 78 | end 79 | 80 | ############################################################################ 81 | # Host Part (right of @) access 82 | # * host: Access full local part instance (alias: right) 83 | # * hostname: everything on the right of @ 84 | # * provider: determined email service provider 85 | ############################################################################ 86 | 87 | # Returns the host name, the part to the right of the @ sign. 88 | def host_name 89 | @host.host_name 90 | end 91 | alias_method :right, :host_name 92 | alias_method :hostname, :host_name 93 | 94 | # Returns the ESP (Email Service Provider) or ISP name derived 95 | # using the provider configuration rules. 96 | def provider 97 | @host.provider 98 | end 99 | 100 | ############################################################################ 101 | # Address methods 102 | ############################################################################ 103 | 104 | # Returns the string representation of the normalized email address. 105 | def normal 106 | if !@original 107 | @original 108 | elsif local.to_s.size == 0 109 | "" 110 | elsif host.to_s.size == 0 111 | local.to_s 112 | else 113 | "#{local}@#{host}" 114 | end 115 | end 116 | alias_method :to_s, :normal 117 | 118 | def inspect 119 | "#<#{self.class}:0x#{object_id.to_s(16)} address=\"#{self}\">" 120 | end 121 | 122 | # Returns the canonical email address according to the provider 123 | # uniqueness rules. Usually, this downcases the address, removes 124 | # spaves and comments and tags, and any extraneous part of the address 125 | # not considered a unique account by the provider. 126 | def canonical 127 | c = local.canonical 128 | c += "@" + host.canonical if host.canonical && host.canonical > " " 129 | c 130 | end 131 | 132 | # True if the given address is already in it's canonical form. 133 | def canonical? 134 | canonical == to_s 135 | end 136 | 137 | # The base address is the mailbox, without tags, and host. 138 | def base 139 | mailbox + "@" + hostname 140 | end 141 | 142 | # Returns the redacted form of the address 143 | # This format is defined by this libaray, and may change as usage increases. 144 | # Takes either :sha1 (default) or :md5 as the argument 145 | def redact(digest = :sha1) 146 | raise "Unknown Digest type: #{digest}" unless %i[sha1 md5].include?(digest) 147 | return to_s if local.redacted? 148 | r = %({#{send(digest)}}) 149 | r += "@" + host.to_s if host.to_s && host.to_s > " " 150 | r 151 | end 152 | 153 | # True if the address is already in the redacted state. 154 | def redacted? 155 | local.redacted? 156 | end 157 | 158 | # Returns the munged form of the address, by default "mailbox@domain.tld" 159 | # returns "ma*****@do*****". 160 | def munge 161 | [local.munge, host.munge].join("@") 162 | end 163 | 164 | # Returns and MD5 of the base address form. Some cross-system systems 165 | # use the email address MD5 instead of the actual address to refer to the 166 | # same shared user identity without exposing the actual address when it 167 | # is not known in common. 168 | def reference(form = :base) 169 | Digest::MD5.hexdigest(send(form)) 170 | end 171 | alias_method :md5, :reference 172 | 173 | # This returns the SHA1 digest (in a hex string) of the base email 174 | # address. See #md5 for more background. 175 | def sha1(form = :base) 176 | Digest::SHA1.hexdigest((send(form) || "") + (@config[:sha1_secret] || "")) 177 | end 178 | 179 | def sha256(form = :base) 180 | Digest::SHA256.hexdigest((send(form) || "") + (@config[:sha256_secret] || "")) 181 | end 182 | 183 | #--------------------------------------------------------------------------- 184 | # Comparisons & Matching 185 | #--------------------------------------------------------------------------- 186 | 187 | # Equal matches the normalized version of each address. Use the Threequal to check 188 | # for match on canonical or redacted versions of addresses 189 | def ==(other) 190 | to_s == other.to_s 191 | end 192 | alias_method :eql?, :== 193 | alias_method :equal?, :== 194 | 195 | # Return the <=> or CMP comparison operator result (-1, 0, +1) on the comparison 196 | # of this addres with another, using the canonical or redacted forms. 197 | def same_as?(other_email) 198 | if other_email.is_a?(String) 199 | other_email = Address.new(other_email) 200 | end 201 | 202 | canonical == other_email.canonical || 203 | redact == other_email.canonical || 204 | canonical == other_email.redact 205 | end 206 | alias_method :include?, :same_as? 207 | 208 | # Return the <=> or CMP comparison operator result (-1, 0, +1) on the comparison 209 | # of this addres with another, using the normalized form. 210 | def <=>(other) 211 | to_s <=> other.to_s 212 | end 213 | 214 | # Address matches one of these Matcher rule patterns 215 | def matches?(*rules) 216 | rules.flatten! 217 | match = local.matches?(rules) 218 | match ||= host.matches?(rules) 219 | return match if match 220 | 221 | # Does "root@*.com" match "root@example.com" domain name 222 | rules.each do |r| 223 | if /.+@.+/.match?(r) 224 | return r if File.fnmatch?(r, to_s) 225 | end 226 | end 227 | false 228 | end 229 | 230 | #--------------------------------------------------------------------------- 231 | # Validation 232 | #--------------------------------------------------------------------------- 233 | 234 | # Returns true if this address is considered valid according to the format 235 | # configured for its provider, It test the normalized form. 236 | def valid?(options = {}) 237 | @error = nil 238 | unless local.valid? 239 | return set_error local.error 240 | end 241 | unless host.valid? 242 | return set_error host.error_message 243 | end 244 | if @config[:address_size] && !@config[:address_size].include?(to_s.size) 245 | return set_error :exceeds_size 246 | end 247 | if @config[:address_validation].is_a?(Proc) 248 | unless @config[:address_validation].call(to_s) 249 | return set_error :not_allowed 250 | end 251 | else 252 | return false unless local.valid? 253 | return false unless host.valid? 254 | end 255 | true 256 | end 257 | 258 | # Connects to host to test if user can receive email. This should NOT be performed 259 | # as an email address check, but is provided to assist in problem resolution. 260 | # If you abuse this, you *could* be blocked by the ESP. 261 | # 262 | # NOTE: As of Ruby 3.1, Net::SMTP was moved from the standard library to the 263 | # 'net-smtp' gem. In order to avoid adding that dependency for this experimental 264 | # feature, please add the gem to your Gemfile and require it to use this feature. 265 | def connect 266 | smtp = Net::SMTP.new(host_name || ip_address) 267 | smtp.start(@config[:smtp_helo_name] || "localhost") 268 | smtp.mailfrom @config[:smtp_mail_from] || "postmaster@localhost" 269 | smtp.rcptto to_s 270 | # p [:connect] 271 | smtp.finish 272 | true 273 | rescue Net::SMTPUnknownError => e 274 | set_error(:address_unknown, e.to_s) 275 | rescue Net::SMTPFatalError => e 276 | set_error(:address_unknown, e.to_s) 277 | rescue SocketError => e 278 | set_error(:address_unknown, e.to_s) 279 | ensure 280 | if smtp&.started? 281 | smtp.finish 282 | end 283 | !!@error 284 | end 285 | 286 | def set_error(err, reason = nil) 287 | @error = err 288 | @reason = reason 289 | @error_message = Config.error_message(err, locale) 290 | false 291 | end 292 | 293 | attr_reader :error_message 294 | 295 | def error 296 | valid? ? nil : @error_message 297 | end 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /lib/email_address/local.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EmailAddress 4 | ############################################################################## 5 | # EmailAddress Local part consists of 6 | # - comments 7 | # - mailbox 8 | # - tag 9 | #----------------------------------------------------------------------------- 10 | # Parsing id provider-dependent, but RFC allows: 11 | # Chars: A-Z a-z 0-9 . ! # $ % ' * + - / = ? ^G _ { | } ~ 12 | # Quoted: space ( ) , : ; < > @ [ ] 13 | # Quoted-Backslash-Escaped: \ " 14 | # Quote local part or dot-separated sub-parts x."y".z 15 | # RFC-5321 warns "a host that expects to receive mail SHOULD avoid defining mailboxes 16 | # where the Local-part requires (or uses) the Quoted-string form". 17 | # (comment)mailbox | mailbox(comment) 18 | # . can not appear at beginning or end, or appear consecutively 19 | # 8-bit/UTF-8: allowed but mail-system defined 20 | # RFC 5321 also warns that "a host that expects to receive mail SHOULD avoid 21 | # defining mailboxes where the Local-part requires (or uses) the Quoted-string form". 22 | # Postmaster: must always be case-insensitive 23 | # Case: sensitive, but usually treated as equivalent 24 | # Local Parts: comment, mailbox tag 25 | # Length: up to 64 characters 26 | # Note: gmail does allow ".." against RFC because they are ignored. This will 27 | # be fixed by collapsing consecutive punctuation in conventional formats, 28 | # and consider them typos. 29 | ############################################################################## 30 | # RFC5322 Rules (Oct 2008): 31 | #--------------------------------------------------------------------------- 32 | # addr-spec = local-part "@" domain 33 | # local-part = dot-atom / quoted-string / obs-local-part 34 | # domain = dot-atom / domain-literal / obs-domain 35 | # domain-literal = [CFWS] "[" *([FWS] dtext) [FWS] "]" [CFWS] 36 | # dtext = %d33-90 / ; Printable US-ASCII 37 | # %d94-126 / ; characters not including 38 | # obs-dtext ; "[", "]", or "\" 39 | # atext = ALPHA / DIGIT / ; Printable US-ASCII 40 | # "!" / "#" / ; characters not including 41 | # "$" / "%" / ; specials. Used for atoms. 42 | # "&" / "'" / 43 | # "*" / "+" / 44 | # "-" / "/" / 45 | # "=" / "?" / 46 | # "^" / "_" / 47 | # "`" / "{" / 48 | # "|" / "}" / 49 | # "~" 50 | # atom = [CFWS] 1*atext [CFWS] 51 | # dot-atom-text = 1*atext *("." 1*atext) 52 | # dot-atom = [CFWS] dot-atom-text [CFWS] 53 | # specials = "(" / ")" / ; Special characters that do 54 | # "<" / ">" / ; not appear in atext 55 | # "[" / "]" / 56 | # ":" / ";" / 57 | # "@" / "\" / 58 | # "," / "." / 59 | # DQUOTE 60 | # qtext = %d33 / ; Printable US-ASCII 61 | # %d35-91 / ; characters not including 62 | # %d93-126 / ; "\" or the quote character 63 | # obs-qtext 64 | # qcontent = qtext / quoted-pair 65 | # quoted-string = [CFWS] 66 | # DQUOTE *([FWS] qcontent) [FWS] DQUOTE 67 | # [CFWS] 68 | ############################################################################ 69 | class Local 70 | attr_reader :local 71 | attr_accessor :mailbox, :comment, :tag, :config, :original 72 | attr_accessor :syntax, :locale 73 | 74 | # RFC-2142: MAILBOX NAMES FOR COMMON SERVICES, ROLES AND FUNCTIONS 75 | BUSINESS_MAILBOXES = %w[info marketing sales support] 76 | NETWORK_MAILBOXES = %w[abuse noc security] 77 | SERVICE_MAILBOXES = %w[postmaster hostmaster usenet news webmaster www uucp ftp] 78 | SYSTEM_MAILBOXES = %w[help mailer-daemon root] # Not from RFC-2142 79 | ROLE_MAILBOXES = %w[staff office orders billing careers jobs] # Not from RFC-2142 80 | SPECIAL_MAILBOXES = BUSINESS_MAILBOXES + NETWORK_MAILBOXES + SERVICE_MAILBOXES + 81 | SYSTEM_MAILBOXES + ROLE_MAILBOXES 82 | STANDARD_MAX_SIZE = 64 83 | 84 | # Conventional : word([.-+'_]word)* 85 | CONVENTIONAL_MAILBOX_REGEX = /\A [\p{L}\p{N}_]+ (?: [.\-+'_] [\p{L}\p{N}_]+ )* \z/x 86 | CONVENTIONAL_MAILBOX_WITHIN = /[\p{L}\p{N}_]+ (?: [.\-+'_] [\p{L}\p{N}_]+ )*/x 87 | 88 | # Relaxed: same characters, relaxed order 89 | RELAXED_MAILBOX_WITHIN = /[\p{L}\p{N}_]+ (?: [.\-+'_]+ [\p{L}\p{N}_]+ )*/x 90 | RELAXED_MAILBOX_REGEX = /\A [\p{L}\p{N}_]+ (?: [.\-+'_]+ [\p{L}\p{N}_]+ )* \z/x 91 | 92 | # RFC5322 Token: token."token".token (dot-separated tokens) 93 | # Quoted Token can also have: SPACE \" \\ ( ) , : ; < > @ [ \ ] . 94 | STANDARD_LOCAL_WITHIN = / 95 | (?: [\p{L}\p{N}!\#$%&'*+\-\/=?\^_`{|}~()]+ 96 | | " (?: \\[" \\] | [\x20-\x21\x23-\x2F\x3A-\x40\x5B\x5D-\x60\x7B-\x7E\p{L}\p{N}] )+ " ) 97 | (?: \. (?: [\p{L}\p{N}!\#$%&'*+\-\/=?\^_`{|}~()]+ 98 | | " (?: \\[" \\] | [\x20-\x21\x23-\x2F\x3A-\x40\x5B\x5D-\x60\x7B-\x7E\p{L}\p{N}] )+ " ) )* /x 99 | 100 | STANDARD_LOCAL_REGEX = /\A #{STANDARD_LOCAL_WITHIN} \z/x 101 | 102 | REDACTED_REGEX = /\A \{ [0-9a-f]{40} \} \z/x # {sha1} 103 | 104 | # Conventional Tag: word ([,-+'='] word)... 105 | CONVENTIONAL_TAG_REGEX = /\A [\p{L}\p{N}_]+ (?: [.\-+'=] [\p{L}\p{N}_]+ )* \z/x # word(word)*... 106 | # Relexed Tag: token ( . token)... token is most punctuation, but not: . \ " space 107 | RELAXED_TAG_REGEX = %r/^[\w!\#$%&'*+\-\/=?\^`{|}~]+ (\.[\w!\#$%&'*+\-\/=?\^`{|}~])* $/ix 108 | 109 | def initialize(local, config = {}, host = nil, locale = "en") 110 | @config = config.is_a?(Hash) ? Config.new(config) : config 111 | self.local = local 112 | @host = host 113 | @locale = locale 114 | @error = @error_message = nil 115 | end 116 | 117 | def local=(raw) 118 | self.original = raw 119 | raw = raw.downcase if @config[:local_downcase].nil? || @config[:local_downcase] 120 | @local = raw 121 | 122 | if @config[:local_parse].is_a?(Proc) 123 | self.mailbox, self.tag, self.comment = @config[:local_parse].call(raw) 124 | else 125 | self.mailbox, self.tag, self.comment = parse(raw) 126 | end 127 | 128 | self.format 129 | end 130 | 131 | def parse(raw) 132 | if raw =~ /\A"(.*)"\z/ # Quoted 133 | raw = $1 134 | raw = raw.gsub(/\\(.)/, '\1') # Unescape 135 | elsif @config[:local_fix] && @config[:local_format] != :standard 136 | raw = raw.delete(" ") 137 | raw = raw.tr(",", ".") 138 | # raw.gsub!(/([^\p{L}\p{N}]{2,10})/) {|s| s[0] } # Stutter punctuation typo 139 | end 140 | raw, comment = parse_comment(raw) 141 | mailbox, tag = parse_tag(raw) 142 | mailbox ||= "" 143 | [mailbox, tag, comment] 144 | end 145 | 146 | # "(comment)mailbox" or "mailbox(comment)", only one comment 147 | # RFC Doesn't say what to do if 2 comments occur, so last wins 148 | def parse_comment(raw) 149 | c = nil 150 | if raw =~ /\A\((.+?)\)(.+)\z/ 151 | c, raw = [$2, $1] 152 | end 153 | if raw =~ /\A(.+)\((.+?)\)\z/ 154 | raw, c = [$1, $2] 155 | end 156 | [raw, c] 157 | end 158 | 159 | def parse_tag(raw) 160 | separator = @config[:tag_separator] ||= "+" 161 | raw.split(separator, 2) 162 | end 163 | 164 | # True if the the value contains only Latin characters (7-bit ASCII) 165 | def ascii? 166 | !unicode? 167 | end 168 | 169 | # True if the the value contains non-Latin Unicde characters 170 | def unicode? 171 | /[^\p{InBasicLatin}]/.match?(local) 172 | end 173 | 174 | # Returns true if the value matches the Redacted format 175 | def redacted? 176 | REDACTED_REGEX.match?(local) 177 | end 178 | 179 | # Returns true if the value matches the Redacted format 180 | def self.redacted?(local) 181 | REDACTED_REGEX.match?(local) 182 | end 183 | 184 | # Is the address for a common system or business role account? 185 | def special? 186 | SPECIAL_MAILBOXES.include?(mailbox) 187 | end 188 | 189 | def to_s 190 | self.format 191 | end 192 | 193 | # Builds the local string according to configurations 194 | def format(form = @config[:local_format] || :conventional) 195 | if @config[:local_format].is_a?(Proc) 196 | @config[:local_format].call(self) 197 | elsif form == :conventional 198 | conventional 199 | elsif form == :canonical 200 | canonical 201 | elsif form == :relaxed 202 | relax 203 | elsif form == :standard 204 | standard 205 | end 206 | end 207 | 208 | # Returns a conventional form of the address 209 | def conventional 210 | if tag 211 | [mailbox, tag].join(@config[:tag_separator]) 212 | else 213 | mailbox 214 | end 215 | end 216 | 217 | # Returns a canonical form of the address 218 | def canonical 219 | if @config[:mailbox_canonical] 220 | @config[:mailbox_canonical].call(mailbox) 221 | else 222 | mailbox.downcase 223 | end 224 | end 225 | 226 | # Relaxed format: mailbox and tag, no comment, no extended character set 227 | def relax 228 | form = mailbox 229 | form += @config[:tag_separator] + tag if tag 230 | form.gsub(/[ "(),:<>@\[\]\\]/, "") 231 | end 232 | 233 | # Returns a normalized version of the standard address parts. 234 | def standard 235 | form = mailbox 236 | form += @config[:tag_separator] + tag if tag 237 | form += "(" + comment + ")" if comment 238 | form = form.gsub(/([\\"])/, '\\\1') # Escape \ and " 239 | if /[ "(),:<>@\[\\\]]/.match?(form) # Space and "(),:;<>@[\] 240 | form = %("#{form}") 241 | end 242 | form 243 | end 244 | 245 | # Sets the part to be the conventional form 246 | def conventional! 247 | self.local = conventional 248 | end 249 | 250 | # Sets the part to be the canonical form 251 | def canonical! 252 | self.local = canonical 253 | end 254 | 255 | # Dropps unusual parts of Standard form to form a relaxed version. 256 | def relax! 257 | self.local = relax 258 | end 259 | 260 | # Returns the munged form of the address, like "ma*****" 261 | def munge 262 | to_s.sub(/\A(.{1,2}).*/) { |m| $1 + @config[:munge_string] } 263 | end 264 | 265 | # Mailbox with trailing numbers removed 266 | def root_name 267 | mailbox =~ /\A(.+?)\d+\z/ ? $1 : mailbox 268 | end 269 | 270 | ############################################################################ 271 | # Validations 272 | ############################################################################ 273 | 274 | # True if the part is valid according to the configurations 275 | def valid?(format = @config[:local_format] || :conventional) 276 | if @config[:mailbox_validator].is_a?(Proc) 277 | @config[:mailbox_validator].call(mailbox, tag) 278 | elsif format.is_a?(Proc) 279 | format.call(self) 280 | elsif format == :conventional 281 | conventional? 282 | elsif format == :relaxed 283 | relaxed? 284 | elsif format == :redacted 285 | redacted? 286 | elsif format == :standard 287 | standard? 288 | elsif format == :none 289 | true 290 | else 291 | raise "Unknown format #{format}" 292 | end 293 | end 294 | 295 | # Returns the format of the address 296 | def format? 297 | # if :custom 298 | if conventional? 299 | :conventional 300 | elsif relaxed? 301 | :relax 302 | elsif redacted? 303 | :redacted 304 | elsif standard? 305 | :standard 306 | else 307 | :invalid 308 | end 309 | end 310 | 311 | def valid_size? 312 | return set_error(:local_size_long) if local.size > STANDARD_MAX_SIZE 313 | if @host&.hosted_service? 314 | return false if @config[:local_private_size] && !valid_size_checks(@config[:local_private_size]) 315 | elsif @config[:local_size] && !valid_size_checks(@config[:local_size]) 316 | return false 317 | end 318 | return false if @config[:mailbox_size] && !valid_size_checks(@config[:mailbox_size]) 319 | true 320 | end 321 | 322 | def valid_size_checks(range) 323 | return set_error(:local_size_short) if mailbox.size < range.first 324 | return set_error(:local_size_long) if mailbox.size > range.last 325 | true 326 | end 327 | 328 | def valid_encoding?(enc = @config[:local_encoding] || :ascii) 329 | return false if enc == :ascii && unicode? 330 | true 331 | end 332 | 333 | # True if the part matches the conventional format 334 | def conventional? 335 | self.syntax = :invalid 336 | return false if tag && tag !~ CONVENTIONAL_TAG_REGEX 337 | return false unless mailbox =~ CONVENTIONAL_MAILBOX_REGEX 338 | return false if comment 339 | return false unless valid_size? 340 | return false unless valid_encoding? 341 | self.syntax = :conventional 342 | true 343 | end 344 | 345 | # Relaxed conventional is not so strict about character order. 346 | def relaxed? 347 | self.syntax = :invalid 348 | return false if tag && tag !~ RELAXED_TAG_REGEX 349 | return false unless mailbox =~ RELAXED_MAILBOX_REGEX 350 | return false if comment 351 | return false unless valid_size? 352 | return false unless valid_encoding? 353 | self.syntax = :relaxed 354 | true 355 | end 356 | 357 | # True if the part matches the RFC standard format 358 | def standard? 359 | self.syntax = :invalid 360 | return false unless valid_size? 361 | return false unless STANDARD_LOCAL_REGEX.match?(local) 362 | return false unless valid_encoding? 363 | self.syntax = :standard 364 | true 365 | end 366 | 367 | # Matches configured formated form against File glob strings given. 368 | # Rules must end in @ to distinguish themselves from other email part matches. 369 | def matches?(*rules) 370 | rules.flatten.each do |r| 371 | if r =~ /(.+)@\z/ 372 | return r if File.fnmatch?($1, local) 373 | end 374 | end 375 | false 376 | end 377 | 378 | def set_error(err, reason = nil) 379 | @error = err 380 | @reason = reason 381 | @error_message = Config.error_message(err, locale) 382 | false 383 | end 384 | 385 | attr_reader :error_message 386 | 387 | def error 388 | valid? ? nil : (@error || :local_invalid) 389 | end 390 | end 391 | end 392 | -------------------------------------------------------------------------------- /lib/email_address/host.rb: -------------------------------------------------------------------------------- 1 | require "simpleidn" 2 | require "resolv" 3 | 4 | module EmailAddress 5 | ############################################################################## 6 | # The EmailAddress Host is found on the right-hand side of the "@" symbol. 7 | # It can be: 8 | # * Host name (domain name with optional subdomain) 9 | # * International Domain Name, in Unicode (Display) or Punycode (DNS) format 10 | # * IP Address format, either IPv4 or IPv6, enclosed in square brackets. 11 | # This is not Conventionally supported, but is part of the specification. 12 | # * It can contain an optional comment, enclosed in parenthesis, either at 13 | # beginning or ending of the host name. This is not well defined, so it not 14 | # supported here, expect to parse it off, if found. 15 | # 16 | # For matching and query capabilities, the host name is parsed into these 17 | # parts (with example data for "subdomain.example.co.uk"): 18 | # * host_name: "subdomain.example.co.uk" 19 | # * dns_name: punycode("subdomain.example.co.uk") 20 | # * subdomain: "subdomain" 21 | # * registration_name: "example" 22 | # * domain_name: "example.co.uk" 23 | # * tld: "uk" 24 | # * tld2: "co.uk" (the 1 or 2 term TLD we could guess) 25 | # * ip_address: nil or "ipaddress" used in [ipaddress] syntax 26 | # 27 | # The provider (Email Service Provider or ESP) is looked up according to the 28 | # provider configuration rules, setting the config attribute to values of 29 | # that provider. 30 | ############################################################################## 31 | class Host 32 | attr_reader :host_name 33 | attr_accessor :dns_name, :domain_name, :registration_name, 34 | :tld, :tld2, :subdomains, :ip_address, :config, :provider, 35 | :comment, :error_message, :reason, :locale 36 | MAX_HOST_LENGTH = 255 37 | 38 | # Sometimes, you just need a Regexp... 39 | DNS_HOST_REGEX = / [\p{L}\p{N}]+ (?: (?: -{1,3} | \.) [\p{L}\p{N}]+ )*/x 40 | 41 | # The IPv4 and IPv6 were lifted from Resolv::IPv?::Regex and tweaked to not 42 | # \A...\z anchor at the edges. 43 | IPV6_HOST_REGEX = /\[IPv6: 44 | (?: (?:(?x-mi: 45 | (?:[0-9A-Fa-f]{1,4}:){7} 46 | [0-9A-Fa-f]{1,4} 47 | )) | 48 | (?:(?x-mi: 49 | (?: (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) :: 50 | (?: (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) 51 | )) | 52 | (?:(?x-mi: 53 | (?: (?:[0-9A-Fa-f]{1,4}:){6,6}) 54 | (?: \d+)\.(?: \d+)\.(?: \d+)\.(?: \d+) 55 | )) | 56 | (?:(?x-mi: 57 | (?: (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) :: 58 | (?: (?:[0-9A-Fa-f]{1,4}:)*) 59 | (?: \d+)\.(?: \d+)\.(?: \d+)\.(?: \d+) 60 | )))\]/ix 61 | 62 | IPV4_HOST_REGEX = /\[((?x-mi:0 63 | |1(?:[0-9][0-9]?)? 64 | |2(?:[0-4][0-9]?|5[0-5]?|[6-9])? 65 | |[3-9][0-9]?))\.((?x-mi:0 66 | |1(?:[0-9][0-9]?)? 67 | |2(?:[0-4][0-9]?|5[0-5]?|[6-9])? 68 | |[3-9][0-9]?))\.((?x-mi:0 69 | |1(?:[0-9][0-9]?)? 70 | |2(?:[0-4][0-9]?|5[0-5]?|[6-9])? 71 | |[3-9][0-9]?))\.((?x-mi:0 72 | |1(?:[0-9][0-9]?)? 73 | |2(?:[0-4][0-9]?|5[0-5]?|[6-9])? 74 | |[3-9][0-9]?))\]/x 75 | 76 | # Matches conventional host name and punycode: domain.tld, x--punycode.tld 77 | CANONICAL_HOST_REGEX = /\A #{DNS_HOST_REGEX} \z/x 78 | 79 | # Matches Host forms: DNS name, IPv4, or IPv6 formats 80 | STANDARD_HOST_REGEX = /\A (?: #{DNS_HOST_REGEX} 81 | | #{IPV4_HOST_REGEX} | #{IPV6_HOST_REGEX}) \z/ix 82 | 83 | # host name - 84 | # * host type - :email for an email host, :mx for exchanger host 85 | def initialize(host_name, config = {}, locale = "en") 86 | @original = host_name ||= "" 87 | @locale = locale 88 | config[:host_type] ||= :email 89 | @config = config.is_a?(Hash) ? Config.new(config) : config 90 | @error = @error_message = nil 91 | parse(host_name) 92 | end 93 | 94 | # Returns the String representation of the host name (or IP) 95 | def name 96 | if ipv4? 97 | "[#{ip_address}]" 98 | elsif ipv6? 99 | "[IPv6:#{ip_address}]" 100 | elsif @config[:host_encoding] && @config[:host_encoding] == :unicode 101 | ::SimpleIDN.to_unicode(host_name) 102 | else 103 | dns_name 104 | end 105 | end 106 | alias_method :to_s, :name 107 | 108 | # The canonical host name is the simplified, DNS host name 109 | def canonical 110 | dns_name 111 | end 112 | 113 | # Returns the munged version of the name, replacing everything after the 114 | # initial two characters with "*****" or the configured "munge_string". 115 | def munge 116 | host_name.sub(/\A(.{1,2}).*/) { |m| $1 + @config[:munge_string] } 117 | end 118 | 119 | ############################################################################ 120 | # Parsing 121 | ############################################################################ 122 | 123 | def parse(host) # :nodoc: 124 | host = parse_comment(host) 125 | 126 | if host =~ /\A\[IPv6:(.+)\]/i 127 | self.ip_address = $1 128 | elsif host =~ /\A\[(\d{1,3}(\.\d{1,3}){3})\]/ # IPv4 129 | self.ip_address = $1 130 | else 131 | self.host_name = host 132 | end 133 | end 134 | 135 | def parse_comment(host) # :nodoc: 136 | if host =~ /\A\((.+?)\)(.+)/ # (comment)domain.tld 137 | self.comment, host = $1, $2 138 | end 139 | if host =~ /\A(.+)\((.+?)\)\z/ # domain.tld(comment) 140 | host, self.comment = $1, $2 141 | end 142 | host 143 | end 144 | 145 | def host_name=(name) 146 | name = fully_qualified_domain_name(name.downcase) 147 | @host_name = name 148 | if @config[:host_remove_spaces] 149 | @host_name = @host_name.delete(" ") 150 | end 151 | @dns_name = if /[^[:ascii:]]/.match?(host_name) 152 | ::SimpleIDN.to_ascii(host_name) 153 | else 154 | host_name 155 | end 156 | 157 | # Subdomain only (root@localhost) 158 | if name.index(".").nil? 159 | self.subdomains = name 160 | 161 | # Split sub.domain from .tld: *.com, *.xx.cc, *.cc 162 | elsif name =~ /\A(.+)\.(\w{3,10})\z/ || 163 | name =~ /\A(.+)\.(\w{1,3}\.\w\w)\z/ || 164 | name =~ /\A(.+)\.(\w\w)\z/ 165 | 166 | sub_and_domain, self.tld2 = [$1, $2] # sub+domain, com || co.uk 167 | self.tld = tld2.sub(/\A.+\./, "") # co.uk => uk 168 | if sub_and_domain =~ /\A(.+)\.(.+)\z/ # is subdomain? sub.example [.tld2] 169 | self.subdomains = $1 170 | self.registration_name = $2 171 | else 172 | self.registration_name = sub_and_domain 173 | # self.domain_name = sub_and_domain + '.' + self.tld2 174 | end 175 | self.domain_name = registration_name + "." + tld2 176 | find_provider 177 | else # Bad format 178 | self.subdomains = self.tld = self.tld2 = "" 179 | self.domain_name = self.registration_name = name 180 | end 181 | end 182 | 183 | def fully_qualified_domain_name(host_part) 184 | dn = @config[:address_fqdn_domain] 185 | if !dn 186 | if (host_part.nil? || host_part <= " ") && @config[:host_local] && @config[:host_auto_append] 187 | "localhost" 188 | else 189 | host_part 190 | end 191 | elsif host_part.nil? || host_part <= " " 192 | dn 193 | elsif !host_part.include?(".") 194 | host_part + "." + dn 195 | else 196 | host_part 197 | end 198 | end 199 | 200 | # True if host is hosted at the provider, not a public provider host name 201 | def hosted_service? 202 | return false unless registration_name 203 | find_provider 204 | return false unless config[:host_match] 205 | !matches?(config[:host_match]) 206 | end 207 | 208 | def find_provider # :nodoc: 209 | return provider if provider 210 | 211 | Config.providers.each do |provider, config| 212 | if config[:host_match] && matches?(config[:host_match]) 213 | return set_provider(provider, config) 214 | end 215 | end 216 | 217 | return set_provider(:default) unless dns_enabled? 218 | 219 | self.provider ||= set_provider(:default) 220 | end 221 | 222 | def set_provider(name, provider_config = {}) # :nodoc: 223 | config.configure(provider_config) 224 | @provider = name 225 | end 226 | 227 | # Returns a hash of the parts of the host name after parsing. 228 | def parts 229 | {host_name: host_name, dns_name: dns_name, subdomain: subdomains, 230 | registration_name: registration_name, domain_name: domain_name, 231 | tld2: tld2, tld: tld, ip_address: ip_address} 232 | end 233 | 234 | def hosted_provider 235 | Exchanger.cached(dns_name).provider 236 | end 237 | 238 | ############################################################################ 239 | # Access and Queries 240 | ############################################################################ 241 | 242 | # Is this a fully-qualified domain name? 243 | def fqdn? 244 | tld ? true : false 245 | end 246 | 247 | def ip? 248 | !!ip_address 249 | end 250 | 251 | def ipv4? 252 | ip? && ip_address.include?(".") 253 | end 254 | 255 | def ipv6? 256 | ip? && ip_address.include?(":") 257 | end 258 | 259 | ############################################################################ 260 | # Matching 261 | ############################################################################ 262 | 263 | # Takes a email address string, returns true if it matches a rule 264 | # Rules of the follow formats are evaluated: 265 | # * "example." => registration name 266 | # * ".com" => top-level domain name 267 | # * "google" => email service provider designation 268 | # * "@goog*.com" => Glob match 269 | # * IPv4 or IPv6 or CIDR Address 270 | def matches?(rules) 271 | rules = Array(rules) 272 | return false if rules.empty? 273 | rules.each do |rule| 274 | return rule if rule == domain_name || rule == dns_name 275 | return rule if registration_name_matches?(rule) 276 | return rule if tld_matches?(rule) 277 | return rule if domain_matches?(rule) 278 | return rule if self.provider && provider_matches?(rule) 279 | return rule if ip_matches?(rule) 280 | end 281 | false 282 | end 283 | 284 | # Does "example." match any tld? 285 | def registration_name_matches?(rule) 286 | rule == "#{registration_name}." 287 | end 288 | 289 | # Does "sub.example.com" match ".com" and ".example.com" top level names? 290 | # Matches TLD (uk) or TLD2 (co.uk) 291 | def tld_matches?(rule) 292 | rule.match(/\A\.(.+)\z/) && ($1 == tld || $1 == tld2) # ? true : false 293 | end 294 | 295 | def provider_matches?(rule) 296 | rule.to_s =~ /\A[\w\-]*\z/ && self.provider && self.provider == rule.to_sym 297 | end 298 | 299 | # Does domain == rule or glob matches? (also tests the DNS (punycode) name) 300 | # Requires optionally starts with a "@". 301 | def domain_matches?(rule) 302 | rule = $1 if rule =~ /\A@(.+)/ 303 | return rule if domain_name && File.fnmatch?(rule, domain_name) 304 | return rule if dns_name && File.fnmatch?(rule, dns_name) 305 | false 306 | end 307 | 308 | # True if the host is an IP Address form, and that address matches 309 | # the passed CIDR string ("10.9.8.0/24" or "2001:..../64") 310 | def ip_matches?(cidr) 311 | return false unless ip_address 312 | net = IPAddr.new(cidr) 313 | net.include?(IPAddr.new(ip_address)) 314 | end 315 | 316 | ############################################################################ 317 | # DNS 318 | ############################################################################ 319 | 320 | # True if the :dns_lookup setting is enabled 321 | def dns_enabled? 322 | return false if @config[:dns_lookup] == :off 323 | return false if @config[:host_validation] == :syntax 324 | true 325 | end 326 | 327 | # Returns: [official_hostname, alias_hostnames, address_family, *address_list] 328 | def dns_a_record 329 | @_dns_a_record = "0.0.0.0" if @config[:dns_lookup] == :off 330 | @_dns_a_record ||= Addrinfo.getaddrinfo(dns_name, 80) # Port 80 for A rec, 25 for MX 331 | rescue SocketError # not found, but could also mean network not work 332 | @_dns_a_record ||= [] 333 | end 334 | 335 | # Returns an array of Exchanger hosts configured in DNS. 336 | # The array will be empty if none are configured. 337 | def exchangers 338 | # return nil if @config[:host_type] != :email || !self.dns_enabled? 339 | @_exchangers ||= Exchanger.cached(dns_name, @config) 340 | end 341 | 342 | # Returns a DNS TXT Record 343 | def txt(alternate_host = nil) 344 | return nil unless dns_enabled? 345 | Resolv::DNS.open do |dns| 346 | dns.timeouts = @config[:dns_timeout] if @config[:dns_timeout] 347 | records = begin 348 | dns.getresources(alternate_host || dns_name, 349 | Resolv::DNS::Resource::IN::TXT) 350 | rescue Resolv::ResolvTimeout 351 | [] 352 | end 353 | 354 | records.empty? ? nil : records.map(&:data).join(" ") 355 | end 356 | end 357 | 358 | # Parses TXT record pairs into a hash 359 | def txt_hash(alternate_host = nil) 360 | fields = {} 361 | record = txt(alternate_host) 362 | return fields unless record 363 | 364 | record.split(/\s*;\s*/).each do |pair| 365 | (n, v) = pair.split(/\s*=\s*/) 366 | fields[n.to_sym] = v 367 | end 368 | fields 369 | end 370 | 371 | # Returns a hash of the domain's DMARC (https://en.wikipedia.org/wiki/DMARC) 372 | # settings. 373 | def dmarc 374 | dns_name ? txt_hash("_dmarc." + dns_name) : {} 375 | end 376 | 377 | ############################################################################ 378 | # Validation 379 | ############################################################################ 380 | 381 | # Returns true if the host name is valid according to the current configuration 382 | def valid?(rules = {}) 383 | host_validation = rules[:host_validation] || @config[:host_validation] || :mx 384 | dns_lookup = rules[:dns_lookup] || @config[:dns_lookup] || host_validation 385 | self.error_message = nil 386 | if host_name && !host_name.empty? && !@config[:host_size].include?(host_name.size) 387 | return set_error(:invalid_host) 388 | end 389 | if ip_address 390 | valid_ip? 391 | elsif !valid_format? 392 | false 393 | elsif dns_lookup == :connect 394 | valid_mx? && connect 395 | elsif dns_lookup == :a || host_validation == :a 396 | valid_dns? 397 | elsif dns_lookup == :mx 398 | valid_mx? 399 | else 400 | true 401 | end 402 | end 403 | 404 | # True if the host name has a DNS A Record 405 | def valid_dns? 406 | return true unless dns_enabled? 407 | dns_a_record.size > 0 || set_error(:domain_unknown) 408 | end 409 | 410 | # True if the host name has valid MX servers configured in DNS 411 | def valid_mx? 412 | return true unless dns_enabled? 413 | if exchangers.nil? 414 | set_error(:domain_unknown) 415 | elsif exchangers.mx_ips.size > 0 416 | if localhost? && !@config[:host_local] 417 | set_error(:domain_no_localhost) 418 | else 419 | true 420 | end 421 | elsif @config[:dns_timeout].nil? && valid_dns? 422 | set_error(:domain_does_not_accept_email) 423 | else 424 | set_error(:domain_unknown) 425 | end 426 | end 427 | 428 | # True if the host_name passes Regular Expression match and size limits. 429 | def valid_format? 430 | if host_name =~ CANONICAL_HOST_REGEX && to_s.size <= MAX_HOST_LENGTH 431 | if localhost? 432 | return @config[:host_local] ? true : set_error(:domain_no_localhost) 433 | end 434 | 435 | return true if !@config[:host_fqdn] 436 | return true if host_name.include?(".") # require FQDN 437 | end 438 | set_error(:domain_invalid) 439 | end 440 | 441 | # Returns true if the IP address given in that form of the host name 442 | # is a potentially valid IP address. It does not check if the address 443 | # is reachable. 444 | def valid_ip? 445 | if !@config[:host_allow_ip] 446 | bool = set_error(:ip_address_forbidden) 447 | elsif ip_address.include?(":") 448 | bool = ip_address.match(Resolv::IPv6::Regex) ? true : set_error(:ipv6_address_invalid) 449 | elsif ip_address.include?(".") 450 | bool = ip_address.match(Resolv::IPv4::Regex) ? true : set_error(:ipv4_address_invalid) 451 | end 452 | if bool && (localhost? && !@config[:host_local]) 453 | bool = set_error(:ip_address_no_localhost) 454 | end 455 | bool 456 | end 457 | 458 | def localhost? 459 | return true if host_name == "localhost" 460 | return false unless ip_address 461 | IPAddr.new(ip_address).loopback? 462 | end 463 | 464 | # Connects to host to test it can receive email. This should NOT be performed 465 | # as an email address check, but is provided to assist in problem resolution. 466 | # If you abuse this, you *could* be blocked by the ESP. 467 | # 468 | # timeout is the number of seconds to wait before timing out the request and 469 | # returns false as the connection was unsuccessful. 470 | # 471 | # > NOTE: As of Ruby 3.1, Net::SMTP was moved from the standard library to the 472 | # > 'net-smtp' gem. In order to avoid adding that dependency for this *experimental* 473 | # > feature, please add the gem to your Gemfile and require it to use this feature. 474 | def connect(timeout = nil) 475 | smtp = Net::SMTP.new(host_name || ip_address) 476 | smtp.open_timeout = timeout || @config[:host_timeout] 477 | smtp.start(@config[:helo_name] || "localhost") 478 | smtp.finish 479 | true 480 | rescue Net::SMTPFatalError => e 481 | set_error(:server_not_available, e.to_s) 482 | rescue SocketError => e 483 | set_error(:server_not_available, e.to_s) 484 | rescue Net::OpenTimeout => e 485 | set_error(:server_not_available, e.to_s) 486 | ensure 487 | smtp.finish if smtp&.started? 488 | end 489 | 490 | def set_error(err, reason = nil) 491 | @error = err 492 | @reason = reason 493 | @error_message = Config.error_message(err, locale) 494 | false 495 | end 496 | 497 | # The inverse of valid? -- Returns nil (falsey) if valid, otherwise error message 498 | def error 499 | valid? ? nil : @error_message 500 | end 501 | end 502 | end 503 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Email Address 2 | 3 | [![Gem Version](https://badge.fury.io/rb/email_address.svg)](http://rubygems.org/gems/email_address) 4 | [![CI Build](https://github.com/afair/email_address/actions/workflows/ci.yml/badge.svg)](https://github.com/afair/email_address/actions/workflows/ci.yml) 5 | [![Code Climate](https://codeclimate.com/github/afair/email_address/badges/gpa.svg)](https://codeclimate.com/github/afair/email_address) 6 | 7 | The `email_address` ruby gem is an opinionated validation library for 8 | email addresses. The [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322#section-3.4) 9 | address specification defines them as extensions to the email 10 | header syntax, not as a useful method for creating email transport 11 | systems with user accounts, mailboxes, and routing. 12 | 13 | The library follows "real world" email address patterns for end-user addresses. 14 | 15 | - "Conventional" format (the default) fits most user email accounts 16 | as created by major email service providers and software. 17 | Only 7-bit ASCII characters are supported in the local (left) part. 18 | - "Relaxed" format loosely follows conventional, allowing a looser 19 | punctuation format. 20 | - "Standard" format follows the RFC. This is provided for non-user 21 | addresses, such as uniquely-generated destinations for 22 | consumption between automated systems. 23 | 24 | RFC "Standard" Addresses allow syntaxes that most developers do not want: 25 | 26 | - Mailboxes are case-sensitive. 27 | - Double-quoted tokens can contain spaces, "@" symbols, and unusual punctuation. 28 | - Parenthetical comment fields can appear at the beginning or end 29 | of the local (left) part. 30 | - Addresses do not have to have fully-qualified domain names 31 | - The Host part (after the "@") can be an IP Address 32 | 33 | Additionally, this library respects "address tags", a convention 34 | not specified by the RFC, with which email providers and software 35 | append an identifier or route to the mailbox, usually after a "+" symbol. 36 | 37 | Configuration options include specialized address formats for the largest 38 | ESP (Email service providers) to validate against their formats. 39 | 40 | If you have false negatives with "conventional" format, try the 41 | `local_format: :relaxed` option. To validate to the RFC only, use the 42 | `local_format: :standard` option. When possible, confirm the address 43 | with the user if conventional check fails but relaxed succeeds. 44 | 45 | Remember: the only true way to validate an email address is to successfully 46 | send email to it. SMTP checks can help, but should only be done politely 47 | to avoid blacklisting your application. Several (unaffiliated) services 48 | exist to do this for you. 49 | 50 | Finally, there are conveniences to handle storage and management of 51 | address digests for PII removal or sharing addresses without revealing them. 52 | 53 | The gem requires ruby only, but includes a, optional Ruby on Rails helper for 54 | those who need to use it with ActiveRecord. 55 | 56 | Looking for a Javascript version of this library? Check out the 57 | [email_address](https://www.npmjs.com/package/email_address) npm module. 58 | 59 | ## Quick Start 60 | 61 | Install the gem to your project with bundler: 62 | 63 | bundle add email_address 64 | 65 | or with the gem command: 66 | 67 | To quickly validate email addresses, use the valid? and error helpers. 68 | `valid?` returns a boolean, and `error` returns nil if valid, otherwise 69 | a basic error message. 70 | 71 | ```ruby 72 | EmailAddress.valid? "allen@google.com" #=> true 73 | EmailAddress.error "allen@bad-d0main.com" #=> "Invalid Host/Domain Name" 74 | ``` 75 | 76 | `EmailAddress` deeply validates your email addresses. It checks: 77 | 78 | - Host name format and DNS setup 79 | - Mailbox format according to "conventional" form. This matches most used user 80 | email accounts, but is a subset of the RFC specification. 81 | 82 | It does not check: 83 | 84 | - The mail server is configured to accept connections 85 | - The mailbox is valid and accepts email. 86 | 87 | By default, MX records are required in DNS. MX or "mail exchanger" records 88 | tell where to deliver email for the domain. Many domains run their 89 | website on one provider (ISP, Heroku, etc.), and email on a different 90 | provider (such as G Suite). Note that `example.com`, while 91 | a valid domain name, does not have MX records. 92 | 93 | ```ruby 94 | EmailAddress.valid? "allen@example.com" #=> false 95 | EmailAddress.valid? "allen@example.com", host_validation: :syntax #=> true 96 | ``` 97 | 98 | Most mail servers do not yet support Unicode mailboxes, so the default here is ASCII. 99 | 100 | ```ruby 101 | EmailAddress.error "Pelé@google.com" #=> "Invalid Recipient/Mailbox" 102 | EmailAddress.valid? "Pelé@google.com", local_encoding: :unicode #=> true 103 | ``` 104 | 105 | ## Background 106 | 107 | The email address specification is complex and often not what you want 108 | when working with personal email addresses in applications. This library 109 | introduces terms to distinguish types of email addresses. 110 | 111 | - _Normal_ - The edited form of any input email address. Typically, it 112 | is lower-cased and minor "fixes" can be performed, depending on the 113 | configurations and email address provider. 114 | 115 | => 116 | 117 | - _Conventional_ - Most personal account addresses are in this basic 118 | format, one or more "words" separated by a single simple punctuation 119 | character. It consists of a mailbox (user name or role account) and 120 | an optional address "tag" assigned by the user. 121 | 122 | miles.o' 123 | 124 | - _Relaxed_ - A less strict form of Conventional, same character set, 125 | must begin and end with an alpha-numeric character, but order within 126 | is not enforced. 127 | 128 | 129 | 130 | - _Standard_ - The RFC-Compliant syntax of an email address. This is 131 | useful when working with software-generated addresses or handling 132 | existing email addresses, but otherwise not useful for personal 133 | addresses. 134 | 135 | madness!."()<>[]:,;@\\\"!#$%&'\*+-/=?^\_`{}| ~.a(comment )"@example.org 136 | 137 | - _Base_ - A unique mailbox without tags. For gmail, is uses the incoming 138 | punctation, essential when building an MD5, SHA1, or SHA256 to match services 139 | like Gravatar, and email address digest interchange. 140 | 141 | - _Canonical_ - An unique account address, lower-cased, without the 142 | tag, and with irrelevant characters stripped. 143 | 144 | => 145 | 146 | - _Reference_ - The MD5 of the Base format, used to share account 147 | references without exposing the private email address directly. 148 | 149 | => 150 | => 1429a1dfc797d6e93075fef011c373fb 151 | 152 | - _Redacted_ - A form of the email address where it is replaced by 153 | a SHA1-based version to remove the original address from the 154 | database, or to store the address privately, yet still keep it 155 | accessible at query time by converting the queried address to 156 | the redacted form. 157 | 158 | => {bea3f3560a757f8142d38d212a931237b218eb5e}@gmail.com 159 | 160 | - _Munged_ - An obfuscated version of the email address suitable for 161 | publishing on the internet, where email address harvesting 162 | could occur. 163 | 164 | => cl\*\*\*\*\*@gm\*\*\*\*\* 165 | 166 | Other terms: 167 | 168 | - _Local_ - The left-hand side of the "@", representing the user, 169 | mailbox, or role, and an optional "tag". 170 | 171 | ; Local part: mailbox+tag 172 | 173 | - _Mailbox_ - The destination user account or role account. 174 | - _Tag_ - A parameter added after the mailbox, usually after the 175 | "+" symbol, set by the user for mail filtering and sub-accounts. 176 | Not all mail systems support this. 177 | - _Host_ (sometimes called _Domain_) - The right-hand side of the "@" 178 | indicating the domain or host name server to delivery the email. 179 | If missing, "localhost" is assumed, or if not a fully-qualified 180 | domain name, it assumed another computer on the same network, but 181 | this is increasingly rare. 182 | - _Provider_ - The Email Service Provider (ESP) providing the email 183 | service. Each provider may have its own email address validation 184 | and canonicalization rules. 185 | - _Punycode_ - A host name with Unicode characters (International 186 | Domain Name or IDN) needs conversion to this ASCII-encoded format 187 | for DNS lookup. 188 | 189 | "HIRO@こんにちは世界.com" => "" 190 | 191 | Wikipedia has a great article on 192 | [Email Addresses](https://en.wikipedia.org/wiki/Email_address), 193 | much more readable than the section within 194 | [RFC 5322](https://tools.ietf.org/html/rfc5322#section-3.4) 195 | 196 | ## Usage 197 | 198 | Use `EmailAddress` to do transformations and validations. You can also 199 | instantiate an object to inspect the address. 200 | 201 | These top-level helpers return edited email addresses and validation 202 | check. 203 | 204 | ```ruby 205 | address = "Clark.Kent+scoops@gmail.com" 206 | EmailAddress.valid?(address) #=> true 207 | EmailAddress.normal(address) #=> "clark.kent+scoops@gmail.com" 208 | EmailAddress.canonical(address) #=> "clarkkent@gmail.com" 209 | EmailAddress.reference(address) #=> "c5be3597c391169a5ad2870f9ca51901" 210 | EmailAddress.redact(address) #=> "{bea3f3560a757f8142d38d212a931237b218eb5e}@gmail.com" 211 | EmailAddress.munge(address) #=> "cl*****@gm*****" 212 | EmailAddress.matches?(address, 'google') #=> 'google' (true) 213 | EmailAddress.error("#bad@example.com") #=> "Invalid Mailbox" 214 | ``` 215 | 216 | Or you can create an instance of the email address to work with it. 217 | 218 | ```ruby 219 | email = EmailAddress.new(address) #=> # 220 | email.normal #=> "clark.kent+scoops@gmail.com" 221 | email.canonical #=> "clarkkent@gmail.com" 222 | email.original #=> "Clark.Kent+scoops@gmail.com" 223 | email.valid? #=> true 224 | ``` 225 | 226 | Here are some other methods that are available. 227 | 228 | ````ruby 229 | email.redact #=> "{bea3f3560a757f8142d38d212a931237b218eb5e}@gmail.com" 230 | email.sha1 #=> "bea3f3560a757f8142d38d212a931237b218eb5e" 231 | email.sha256 #=> "9e2a0270f2d6778e5f647fc9eaf6992705ca183c23d1ed1166586fd54e859f75" 232 | email.md5 #=> "c5be3597c391169a5ad2870f9ca51901" 233 | email.host_name #=> "gmail.com" 234 | email.provider #=> :google 235 | email.mailbox #=> "clark.kent" 236 | email.tag #=> "scoops" 237 | 238 | email.host.exchangers.first[:ip] #=> "2a00:1450:400b:c02::1a" 239 | email.host.txt_hash #=> {:v=>"spf1", :redirect=>"\_spf.google.com"} 240 | 241 | EmailAddress.normal("HIRO@こんにちは世界.com") 242 | #=> "hiro@xn--28j2a3ar1pp75ovm7c.com" 243 | EmailAddress.normal("hiro@xn--28j2a3ar1pp75ovm7c.com", host_encoding: :unicode) 244 | #=> "hiro@こんにちは世界.com" 245 | 246 | #### Rails Validator 247 | 248 | For Rails' ActiveRecord classes, EmailAddress provides an ActiveRecordValidator. 249 | Specify your email address attributes with `field: :user_email`, or 250 | `fields: [:email1, :email2]`. If neither is given, it assumes to use the 251 | `email` or `email_address` attribute. 252 | 253 | ```ruby 254 | class User < ActiveRecord::Base 255 | validates_with EmailAddress::ActiveRecordValidator, field: :email 256 | end 257 | ```` 258 | 259 | #### Rails I18n 260 | 261 | Copy and adapt `lib/email_address/messages.yaml` into your locales and 262 | create an after initialization callback: 263 | 264 | ```ruby 265 | # config/initializers/email_address.rb 266 | 267 | Rails.application.config.after_initialize do 268 | I18n.available_locales.each do |locale| 269 | translations = I18n.t(:email_address, locale: locale) 270 | 271 | next unless translations.is_a? Hash 272 | 273 | EmailAddress::Config.error_messages translations.transform_keys(&:to_s), locale.to_s 274 | end 275 | end 276 | ``` 277 | 278 | #### Rails Email Address Type Attribute 279 | 280 | Initial support is provided for Active Record 5.0 attributes API. 281 | 282 | First, you need to register the type in 283 | `config/initializers/email_address.rb` along with any global 284 | configurations you want. 285 | 286 | ```ruby 287 | ActiveRecord::Type.register(:email_address, EmailAddress::EmailAddressType) 288 | ActiveRecord::Type.register(:canonical_email_address, 289 | EmailAddress::CanonicalEmailAddressType) 290 | ``` 291 | 292 | Assume the Users table contains the columns "email" and "canonical_email". 293 | We want to normalize the address in "email" and store the canonical/unique 294 | version in "canonical_email". This code will set the canonical_email when 295 | the email attribute is assigned. With the canonical_email column, 296 | we can look up the User, even it the given email address didn't exactly 297 | match the registered version. 298 | 299 | ```ruby 300 | class User < ApplicationRecord 301 | attribute :email, :email_address 302 | attribute :canonical_email, :canonical_email_address 303 | 304 | validates_with EmailAddress::ActiveRecordValidator, 305 | fields: %i(email canonical_email) 306 | 307 | def email=(email_address) 308 | self[:canonical_email] = email_address 309 | self[:email] = email_address 310 | end 311 | 312 | def self.find_by_email(email) 313 | user = self.find_by(email: EmailAddress.normal(email)) 314 | user ||= self.find_by(canonical_email: EmailAddress.canonical(email)) 315 | user ||= self.find_by(canonical_email: EmailAddress.redacted(email)) 316 | user 317 | end 318 | 319 | def redact! 320 | self[:canonical_email] = EmailAddress.redact(self.canonical_email) 321 | self[:email] = self[:canonical_email] 322 | end 323 | end 324 | ``` 325 | 326 | Here is how the User model works: 327 | 328 | ```ruby 329 | user = User.create(email:"Pat.Smith+registrations@gmail.com") 330 | user.email #=> "pat.smith+registrations@gmail.com" 331 | user.canonical_email #=> "patsmith@gmail.com" 332 | User.find_by_email("PAT.SMITH@GMAIL.COM") 333 | #=> # 334 | ``` 335 | 336 | The `find_by_email` method looks up a given email address by the 337 | normalized form (lower case), then by the canonical form, then finally 338 | by the redacted form. 339 | 340 | #### Validation 341 | 342 | The only true validation is to send a message to the email address and 343 | have the user (or process) verify it has been received. Syntax checks 344 | help prevent erroneous input. Even sent messages can be silently 345 | dropped, or bounced back after acceptance. Conditions such as a 346 | "Mailbox Full" can mean the email address is known, but abandoned. 347 | 348 | There are different levels of validations you can perform. By default, it will 349 | validate to the "Provider" (if known), or "Conventional" format defined as the 350 | "default" provider. You may pass a a list of parameters to select 351 | which syntax and network validations to perform. 352 | 353 | #### Comparison 354 | 355 | You can compare email addresses: 356 | 357 | ```ruby 358 | e1 = EmailAddress.new("Clark.Kent@Gmail.com") 359 | e2 = EmailAddress.new("clark.kent+Superman@Gmail.com") 360 | e3 = EmailAddress.new(e2.redact) 361 | e1.to_s #=> "clark.kent@gmail.com" 362 | e2.to_s #=> "clark.kent+superman@gmail.com" 363 | e3.to_s #=> "{bea3f3560a757f8142d38d212a931237b218eb5e}@gmail.com" 364 | 365 | e1 == e2 #=> false (Matches by normalized address) 366 | e1.same_as?(e2) #=> true (Matches as canonical address) 367 | e1.same_as?(e3) #=> true (Matches as redacted address) 368 | e1 < e2 #=> true (Compares using normalized address) 369 | ``` 370 | 371 | #### Matching 372 | 373 | Matching addresses by simple patterns: 374 | 375 | - Top-Level-Domain: .org 376 | - Domain Name: example.com 377 | - Registration Name: hotmail. (matches any TLD) 378 | - Domain Glob: \*.exampl?.com 379 | - Provider Name: google 380 | - Mailbox Name or Glob: user00\*@ 381 | - Address or Glob: postmaster@domain\*.com 382 | - Provider or Registration: msn 383 | 384 | Usage: 385 | 386 | ```ruby 387 | e = EmailAddress.new("Clark.Kent@Gmail.com") 388 | e.matches?("gmail.com") #=> true 389 | e.matches?("google") #=> true 390 | e.matches?(".org") #=> false 391 | e.matches?("g*com") #=> true 392 | e.matches?("gmail.") #=> true 393 | e.matches?("*kent*@") #=> true 394 | ``` 395 | 396 | ### Configuration 397 | 398 | You can pass an options hash on the `.new()` and helper class methods to 399 | control how the library treats that address. These can also be 400 | configured during initialization by provider and default (see below). 401 | 402 | ```ruby 403 | EmailAddress.new("clark.kent@gmail.com", 404 | host_validation: :syntax, host_encoding: :unicode) 405 | ``` 406 | 407 | Globally, you can change and query configuration options: 408 | 409 | ```ruby 410 | EmailAddress::Config.setting(:host_validation, :mx) 411 | EmailAddress::Config.setting(:host_validation) #=> :mx 412 | ``` 413 | 414 | Or set multiple settings at once: 415 | 416 | ```ruby 417 | EmailAddress::Config.configure(local_downcase: false, host_validation: :syntax) 418 | ``` 419 | 420 | You can add special rules by domain or provider. It takes the options 421 | above and adds the :domain_match and :exchanger_match rules. 422 | 423 | ```ruby 424 | EmailAddress.define_provider('google', 425 | domain_match: %w(gmail.com googlemail.com), 426 | exchanger_match: %w(google.com), # Requires host_validation==:mx 427 | local_size: 5..64, 428 | mailbox_canonical: ->(m) {m.gsub('.','')}) 429 | ``` 430 | 431 | The library ships with the most common set of provider rules. It is not meant 432 | to house a database of all providers, but a separate `email_address-providers` 433 | gem may be created to hold this data for those who need more complete rules. 434 | 435 | Personal and Corporate email systems are not intended for either solution. 436 | Any of these email systems may be configured locally. 437 | 438 | Pre-configured email address providers include: Google (gmail), AOL, MSN 439 | (hotmail, live, outlook), and Yahoo. Any address not matching one of 440 | those patterns use the "default" provider rule set. Exchanger matches 441 | matches against the Mail Exchanger (SMTP receivers) hosts defined in 442 | DNS. If you specify an exchanger pattern, but requires a DNS MX lookup. 443 | 444 | For Rails application, create an initializer file with your default 445 | configuration options. 446 | EmailAddress::Config.setting takes a single setting name and value, 447 | while EmailAddress::Config.configure takes a hash of multiple settings. 448 | 449 | ```ruby 450 | # ./config/initializers/email_address.rb 451 | EmailAddress::Config.setting( :local_format, :relaxed ) 452 | EmailAddress::Config.configure( local_format: :relaxed, ... ) 453 | EmailAddress::Config.provider(:github, 454 | host_match: %w(github.com), local_format: :standard) 455 | ``` 456 | 457 | #### Override Error Messaegs 458 | 459 | You can override the default error messages as follows: 460 | 461 | ```ruby 462 | EmailAddress::Config.error_messages({ 463 | invalid_address: "Invalid Email Address", 464 | invalid_mailbox: "Invalid Recipient/Mailbox", 465 | invalid_host: "Invalid Host/Domain Name", 466 | exceeds_size: "Address too long", 467 | not_allowed: "Address is not allowed", 468 | incomplete_domain: "Domain name is incomplete"}, 'en') 469 | ``` 470 | 471 | Complete settings and methods are found in the config.rb file within. 472 | 473 | ## Notes 474 | 475 | #### Internationalization 476 | 477 | The industry is moving to support Unicode characters in the local part 478 | of the email address. Currently, SMTP supports only 7-bit ASCII, but a 479 | new `SMTPUTF8` standard is available, but not yet widely implemented. 480 | To work properly, global Email systems must be converted to UTF-8 481 | encoded databases and upgraded to the new email standards. 482 | 483 | The problem with i18n email addresses is that support outside of the 484 | given locale becomes hard to enter addresses on keyboards for another 485 | locale. Because of this, internationalized local parts are not yet 486 | supported by default. They are more likely to be erroneous. 487 | 488 | Proper personal identity can still be provided using 489 | [MIME Encoded-Words](http://en.wikipedia.org/wiki/MIME#Encoded-Word) 490 | in Email headers. 491 | 492 | #### Email Addresses as Sensitive Data 493 | 494 | Like Social Security and Credit Card Numbers, email addresses are 495 | becoming more important as a personal identifier on the internet. 496 | Increasingly, we should treat email addresses as sensitive data. If your 497 | site/database becomes compromised by hackers, these email addresses can 498 | be stolen and used to spam your users and to try to gain access to their 499 | accounts. You should not be storing passwords in plain text; perhaps you 500 | don't need to store email addresses un-encoded either. 501 | 502 | Consider this: upon registration, store the redacted email address for 503 | the user, and of course, the salted, encrypted password. 504 | When the user logs in, compute the redacted email address from 505 | the user-supplied one and look up the record. Store the original address 506 | in the session for the user, which goes away when the user logs out. 507 | 508 | Sometimes, users demand you strike their information from the database. 509 | Instead of deleting their account, you can "redact" their email 510 | address, retaining the state of the account to prevent future 511 | access. Given the original email address again, the redacted account can 512 | be identified if necessary. 513 | 514 | Because of these use cases, the **redact** method on the email address 515 | instance has been provided. 516 | 517 | ## Contributing 518 | 519 | 1. Fork it 520 | 2. Create your feature branch (`git checkout -b my-new-feature`) 521 | 3. Commit your changes (`git commit -am 'Add some feature'`) 522 | 4. Push to the branch (`git push origin my-new-feature`) 523 | 5. Create new Pull Request 524 | 525 | #### Project 526 | 527 | This project lives at [https://github.com/afair/email_address/](https://github.com/afair/email_address/) 528 | 529 | #### Authors 530 | 531 | - [Allen Fair](https://github.com/afair) ([@allenfair](https://twitter.com/allenfair)): 532 | I've worked with email-based applications and email addresses since 1999. 533 | --------------------------------------------------------------------------------