# frozen_string_literal: true require 'resolv' # Validates that an email address is well-formed, is deliverable, and is not a # disposable or throwaway email address. Also normalizes equivalent addresses to # a single canonical form, so that users can't use different forms of the same # address to register multiple accounts. module EmailValidator module_function # https://www.regular-expressions.info/email.html EMAIL_REGEX = /\A[a-z0-9._%+-]+@(?:[a-z0-9][a-z0-9-]{0,61}\.)+[a-z]{2,}\z/i # Sites that ignore dots in email addresses, e.g. where `te.st@gmail.com` is # the same as `test@gmail.com`. IGNORE_DOTS = %w[gmail.com] # Sites that allow plus addressing, e.g. `test+nospam@gmail.com`. # @see https://en.wikipedia.org/wiki/Email_address#Subaddressing IGNORE_PLUS_ADDRESSING = %w[gmail.com hotmail.com outlook.com live.com] IGNORE_MINUS_ADDRESSING = %w[yahoo.com] # Sites that have multiple domains mapping to the same logical email address. CANONICAL_DOMAINS = { "googlemail.com" => "gmail.com", "hotmail.com.ar" => "outlook.com", "hotmail.com.br" => "outlook.com", "hotmail.com.hk" => "outlook.com", "hotmail.com.tw" => "outlook.com", "hotmail.co.uk" => "outlook.com", "hotmail.co.jp" => "outlook.com", "hotmail.co.th" => "outlook.com", "hotmail.com" => "outlook.com", "hotmail.be" => "outlook.com", "hotmail.ca" => "outlook.com", "hotmail.de" => "outlook.com", "hotmail.dk" => "outlook.com", "hotmail.es" => "outlook.com", "hotmail.fi" => "outlook.com", "hotmail.fr" => "outlook.com", "hotmail.hu" => "outlook.com", "hotmail.it" => "outlook.com", "hotmail.my" => "outlook.com", "hotmail.nl" => "outlook.com", "hotmail.no" => "outlook.com", "hotmail.se" => "outlook.com", "live.com.au" => "outlook.com", "live.com.ar" => "outlook.com", "live.com.mx" => "outlook.com", "live.com.pt" => "outlook.com", "live.co.uk" => "outlook.com", "live.com" => "outlook.com", "live.at" => "outlook.com", "live.ca" => "outlook.com", "live.cl" => "outlook.com", "live.cn" => "outlook.com", "live.de" => "outlook.com", "live.dk" => "outlook.com", "live.fr" => "outlook.com", "live.it" => "outlook.com", "live.jp" => "outlook.com", "live.nl" => "outlook.com", "live.se" => "outlook.com", "msn.com" => "outlook.com", "outlook.com.ar" => "outlook.com", "outlook.com.au" => "outlook.com", "outlook.com.br" => "outlook.com", "outlook.co.id" => "outlook.com", "outlook.co.uk" => "outlook.com", "outlook.co.jp" => "outlook.com", "outlook.co.nz" => "outlook.com", "outlook.co.th" => "outlook.com", "outlook.at" => "outlook.com", "outlook.be" => "outlook.com", "outlook.ca" => "outlook.com", "outlook.cl" => "outlook.com", "outlook.cn" => "outlook.com", "outlook.de" => "outlook.com", "outlook.dk" => "outlook.com", "outlook.fr" => "outlook.com", "outlook.ie" => "outlook.com", "outlook.it" => "outlook.com", "outlook.kr" => "outlook.com", "outlook.jp" => "outlook.com", "outlook.nl" => "outlook.com", "outlook.pt" => "outlook.com", "outlook.ru" => "outlook.com", "outlook.sa" => "outlook.com", "outlook.se" => "outlook.com", "yahoo.com.au" => "yahoo.com", "yahoo.com.ar" => "yahoo.com", "yahoo.com.br" => "yahoo.com", "yahoo.com.cn" => "yahoo.com", "yahoo.com.hk" => "yahoo.com", "yahoo.com.mx" => "yahoo.com", "yahoo.com.ph" => "yahoo.com", "yahoo.com.sg" => "yahoo.com", "yahoo.com.tw" => "yahoo.com", "yahoo.com.vn" => "yahoo.com", "yahoo.co.id" => "yahoo.com", "yahoo.co.kr" => "yahoo.com", "yahoo.co.jp" => "yahoo.com", "yahoo.co.nz" => "yahoo.com", "yahoo.co.uk" => "yahoo.com", "yahoo.ne.jp" => "yahoo.com", "yahoo.ca" => "yahoo.com", "yahoo.cn" => "yahoo.com", "yahoo.de" => "yahoo.com", "yahoo.es" => "yahoo.com", "yahoo.fr" => "yahoo.com", "yahoo.it" => "yahoo.com", "ymail.com" => "yahoo.com", "126.com" => "163.com", "aim.com" => "aol.com", "gmx.com" => "gmx.net", "gmx.at" => "gmx.net", "gmx.ch" => "gmx.net", "gmx.de" => "gmx.net", "gmx.fr" => "gmx.net", "gmx.us" => "gmx.net", "pm.me" => "protonmail.com", "protonmail.ch" => "protonmail.com", "tuta.io" => "tutanota.com", "email.com" => "mail.com", "me.com" => "icloud.com", "ya.ru" => "yandex.ru", "yandex.com" => "yandex.ru", "yandex.by" => "yandex.ru", "yandex.ua" => "yandex.ru", "yandex.kz" => "yandex.ru", "inbox.ru" => "mail.ru", "bk.ru" => "mail.ru", "list.ru" => "mail.ru", "internet.ru" => "mail.ru", "hanmail.net" => "daum.net", } # A list of domains known not to be disposable. A user's email must be on # this list to unrestrict their account. If a user is Restricted and their # email is not in this list, then it's assumed to be disposable and can't be # used to unrestrict their account even if they verify their email address. # # https://www.mailboxvalidator.com/domain NONDISPOSABLE_DOMAINS = %w[ gmail.com outlook.com yahoo.com aol.com comcast.net att.net bellsouth.net cox.net sbcglobal.net verizon.net icloud.com rocketmail.com windowslive.com qq.com vip.qq.com sina.com naver.com 163.com daum.net mail.goo.ne.jp nate.com mail.com protonmail.com gmx.net web.de freenet.de o2.pl op.pl wp.pl interia.pl mail.ru yandex.ru rambler.ru abv.bg seznam.cz libero.it laposte.net free.fr orange.fr citromail.hu ukr.net t-online.de inbox.lv luukku.com lycos.com tlen.pl infoseek.jp excite.co.jp mac.com wanadoo.fr ezweb.ne.jp arcor.de docomo.ne.jp earthlink.net charter.net hushmail.com inbox.com juno.com shaw.ca walla.com tutanota.com foxmail.com vivaldi.net fastmail.com relay.firefox.com ] # Returns true if it's okay to connect to port 25. Disabled outside of # production because many home ISPs blackhole port 25. def smtp_enabled? Rails.env.production? end # Normalize an email address by stripping out plus addressing and dots, if # applicable, and rewriting the domain to a canonical domain. # @param address [String] the email address to normalize # @return [String] the normalized address def normalize(address) return nil unless address.count("@") == 1 name, domain = address.downcase.split("@") domain = CANONICAL_DOMAINS.fetch(domain, domain) name = name.delete(".") if domain.in?(IGNORE_DOTS) name = name.gsub(/\+.*\z/, "") if domain.in?(IGNORE_PLUS_ADDRESSING) name = name.gsub(/-.*\z/, "") if domain.in?(IGNORE_MINUS_ADDRESSING) "#{name}@#{domain}" end # Returns true if the email address is correctly formatted. # @param [String] the email address # @return [Boolean] def is_valid?(address) address.match?(EMAIL_REGEX) end # Returns true if the email is a throwaway or disposable email address. # @param [String] the email address # @return [Boolean] def is_restricted?(address) domain = Mail::Address.new(address).domain !domain.in?(NONDISPOSABLE_DOMAINS) rescue Mail::Field::IncompleteParseError true end # Returns true if the email can't be delivered. Checks if the domain has an MX # record and responds to the RCPT TO command. # @param to_address [String] the email address to check # @param from_address [String] the email address to check from # @return [Boolean] def undeliverable?(to_address, from_address: Danbooru.config.contact_email, timeout: 3) mail_server = mx_domain(to_address, timeout: timeout) mail_server.nil? || rcpt_to_failed?(to_address, from_address, mail_server, timeout: timeout) rescue false end # Returns true if the email can't be delivered. Sends a RCPT TO command over # port 25 to check if the mailbox exists. # @param to_address [String] the email address to check # @param from_address [String] the email address to check from # @param mail_server [String] the DNS name of the SMTP server # @param timeout [Integer] the network timeout # @return [Boolean] def rcpt_to_failed?(to_address, from_address, mail_server, timeout: nil) return false unless smtp_enabled? from_domain = Mail::Address.new(from_address).domain smtp = Net::SMTP.new(mail_server) smtp.read_timeout = timeout smtp.open_timeout = timeout smtp.start(from_domain) do |conn| conn.mailfrom(from_address) # Net::SMTPFatalError is raised if RCPT TO returns a 5xx error. response = conn.rcptto(to_address) rescue $! return response.is_a?(Net::SMTPFatalError) end end # Does a DNS MX record lookup of the domain in the email address and returns the # name of the mail server, if it exists. # @param to_address [String] the email address to check # @param timeout [Integer] the network timeout # @return [String] the DNS name of the mail server def mx_domain(to_address, timeout: nil) domain = Mail::Address.new(to_address).domain dns = Resolv::DNS.new dns.timeouts = timeout response = dns.getresource(domain, Resolv::DNS::Resource::IN::MX) response.exchange.to_s rescue Resolv::ResolvError nil end end