*/ protected $messageTemplates = [ self::INVALID => "Invalid type given. String expected", self::INVALID_FORMAT => "The input is not a valid email address. Use the basic format local-part@hostname", self::INVALID_HOSTNAME => "'%hostname%' is not a valid hostname for the email address", self::INVALID_MX_RECORD => "'%hostname%' does not appear to have any valid MX or A records for the email address", self::INVALID_SEGMENT => "'%hostname%' is not in a routable network segment. The email address should not be resolved from public network", self::DOT_ATOM => "'%localPart%' can not be matched against dot-atom format", self::QUOTED_STRING => "'%localPart%' can not be matched against quoted-string format", self::INVALID_LOCAL_PART => "'%localPart%' is not a valid local part for the email address", self::LENGTH_EXCEEDED => "The input exceeds the allowed length", ]; // phpcs:enable /** @var array */ protected $messageVariables = [ 'hostname' => 'hostname', 'localPart' => 'localPart', ]; /** @var string */ protected $hostname; /** @var string */ protected $localPart; /** * Returns the found mx record information * * @var array */ protected $mxRecord = []; /** * Internal options array * * @var array */ protected $options = [ 'useMxCheck' => false, 'useDeepMxCheck' => false, 'useDomainCheck' => true, 'allow' => Hostname::ALLOW_DNS, 'strict' => true, 'hostnameValidator' => null, ]; /** * Instantiates hostname validator for local use * * The following additional option keys are supported: * 'hostnameValidator' => A hostname validator, see Laminas\Validator\Hostname * 'allow' => Options for the hostname validator, see Laminas\Validator\Hostname::ALLOW_* * 'strict' => Whether to adhere to strictest requirements in the spec * 'useMxCheck' => If MX check should be enabled, boolean * 'useDeepMxCheck' => If a deep MX check should be done, boolean * * @param array|Traversable $options OPTIONAL */ public function __construct($options = []) { if (! is_array($options)) { $options = func_get_args(); $temp['allow'] = array_shift($options); if (! empty($options)) { $temp['useMxCheck'] = array_shift($options); } if (! empty($options)) { $temp['hostnameValidator'] = array_shift($options); } $options = $temp; } parent::__construct($options); } /** * Sets the validation failure message template for a particular key * Adds the ability to set messages to the attached hostname validator * * @param string $messageString * @param string $messageKey OPTIONAL * @return AbstractValidator Provides a fluent interface */ public function setMessage($messageString, $messageKey = null) { if ($messageKey === null) { $this->getHostnameValidator()->setMessage($messageString); parent::setMessage($messageString); return $this; } if (! isset($this->messageTemplates[$messageKey])) { $this->getHostnameValidator()->setMessage($messageString, $messageKey); } else { parent::setMessage($messageString, $messageKey); } return $this; } /** * Returns the set hostname validator * * If was not previously set then lazy load a new one * * @return Hostname */ public function getHostnameValidator() { if (! isset($this->options['hostnameValidator'])) { $this->options['hostnameValidator'] = new Hostname($this->getAllow()); } return $this->options['hostnameValidator']; } /** * @param Hostname $hostnameValidator OPTIONAL * @return $this Provides a fluent interface */ public function setHostnameValidator(?Hostname $hostnameValidator = null) { $this->options['hostnameValidator'] = $hostnameValidator; return $this; } /** * Returns the allow option of the attached hostname validator * * @return int */ public function getAllow() { return $this->options['allow']; } /** * Sets the allow option of the hostname validator to use * * @param int $allow * @return $this Provides a fluent interface */ public function setAllow($allow) { $this->options['allow'] = $allow; if (isset($this->options['hostnameValidator'])) { $this->options['hostnameValidator']->setAllow($allow); } return $this; } /** * Whether MX checking via getmxrr is supported or not * * @return bool */ public function isMxSupported() { return function_exists('getmxrr'); } /** * Returns the set validateMx option * * @return bool */ public function getMxCheck() { return $this->options['useMxCheck']; } /** * Set whether we check for a valid MX record via DNS * * This only applies when DNS hostnames are validated * * @param bool $mx Set allowed to true to validate for MX records, and false to not validate them * @return $this Fluid Interface */ public function useMxCheck($mx) { $this->options['useMxCheck'] = (bool) $mx; return $this; } /** * Returns the set deepMxCheck option * * @return bool */ public function getDeepMxCheck() { return $this->options['useDeepMxCheck']; } /** * Use deep validation for MX records * * @param bool $deep Set deep to true to perform a deep validation process for MX records * @return $this Fluid Interface */ public function useDeepMxCheck($deep) { $this->options['useDeepMxCheck'] = (bool) $deep; return $this; } /** * Returns the set domainCheck option * * @return bool */ public function getDomainCheck() { return $this->options['useDomainCheck']; } /** * Sets if the domain should also be checked * or only the local part of the email address * * @param bool $domain * @return $this Fluid Interface */ public function useDomainCheck($domain = true) { $this->options['useDomainCheck'] = (bool) $domain; return $this; } /** * Returns if the given host is reserved * * The following addresses are seen as reserved * '0.0.0.0/8', '10.0.0.0/8', '127.0.0.0/8' * '100.64.0.0/10' * '172.16.0.0/12' * '198.18.0.0/15' * '169.254.0.0/16', '192.168.0.0/16' * '192.0.2.0/24', '192.88.99.0/24', '198.51.100.0/24', '203.0.113.0/24' * '224.0.0.0/4', '240.0.0.0/4' * * @see http://en.wikipedia.org/wiki/Reserved_IP_addresses * * As of RFC5753 (JAN 2010), the following blocks are no longer reserved: * - 128.0.0.0/16 * - 191.255.0.0/16 * - 223.255.255.0/24 * @see http://tools.ietf.org/html/rfc5735#page-6 * * As of RFC6598 (APR 2012), the following blocks are now reserved: * - 100.64.0.0/10 * @see http://tools.ietf.org/html/rfc6598#section-7 * * @param string $host * @return bool Returns false when minimal one of the given addresses is not reserved */ protected function isReserved($host) { if (! preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $host)) { $host = gethostbynamel($host); } else { $host = [$host]; } if (! is_array($host) || $host === []) { return false; } foreach ($host as $server) { // @codingStandardsIgnoreStart // Search for 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8 if (!preg_match('/^(0|10|127)(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){3}$/', $server) && // Search for 100.64.0.0/10 !preg_match('/^100\.(6[0-4]|[7-9][0-9]|1[0-1][0-9]|12[0-7])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) && // Search for 172.16.0.0/12 !preg_match('/^172\.(1[6-9]|2[0-9]|3[0-1])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) && // Search for 198.18.0.0/15 !preg_match('/^198\.(1[8-9])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) && // Search for 169.254.0.0/16, 192.168.0.0/16 !preg_match('/^(169\.254|192\.168)(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) && // Search for 192.0.2.0/24, 192.88.99.0/24, 198.51.100.0/24, 203.0.113.0/24 !preg_match('/^(192\.0\.2|192\.88\.99|198\.51\.100|203\.0\.113)\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))$/', $server) && // Search for 224.0.0.0/4, 240.0.0.0/4 !preg_match('/^(2(2[4-9]|[3-4][0-9]|5[0-5]))(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){3}$/', $server) ) { return false; } // @codingStandardsIgnoreEnd } return true; } /** * Internal method to validate the local part of the email address * * @return bool */ protected function validateLocalPart() { // First try to match the local part on the common dot-atom format // Dot-atom characters are: 1*atext *("." 1*atext) // atext: ALPHA / DIGIT / and "!", "#", "$", "%", "&", "'", "*", // "+", "-", "/", "=", "?", "^", "_", "`", "{", "|", "}", "~" $atext = 'a-zA-Z0-9\x21\x23\x24\x25\x26\x27\x2a\x2b\x2d\x2f\x3d\x3f\x5e\x5f\x60\x7b\x7c\x7d\x7e'; if (preg_match('/^[' . $atext . ']+(\x2e+[' . $atext . ']+)*$/', $this->localPart)) { return true; } if ($this->validateInternationalizedLocalPart($this->localPart)) { return true; } // Try quoted string format (RFC 5321 Chapter 4.1.2) // Quoted-string characters are: DQUOTE *(qtext/quoted-pair) DQUOTE $qtext = '\x20-\x21\x23-\x5b\x5d-\x7e'; // %d32-33 / %d35-91 / %d93-126 $quotedPair = '\x20-\x7e'; // %d92 %d32-126 if (preg_match('/^"([' . $qtext . ']|\x5c[' . $quotedPair . '])*"$/', $this->localPart)) { return true; } $this->error(self::DOT_ATOM); $this->error(self::QUOTED_STRING); $this->error(self::INVALID_LOCAL_PART); return false; } /** * @param string $localPart Address local part to validate. * @return bool */ protected function validateInternationalizedLocalPart($localPart) { if ( extension_loaded('intl') && false === UConverter::transcode($localPart, 'UTF-8', 'UTF-8') ) { // invalid utf? return false; } $atext = 'a-zA-Z0-9\x21\x23\x24\x25\x26\x27\x2a\x2b\x2d\x2f\x3d\x3f\x5e\x5f\x60\x7b\x7c\x7d\x7e'; // RFC 6532 extends atext to include non-ascii utf // @see https://tools.ietf.org/html/rfc6532#section-3.1 $uatext = $atext . '\x{80}-\x{FFFF}'; return (bool) preg_match('/^[' . $uatext . ']+(\x2e+[' . $uatext . ']+)*$/u', $localPart); } /** * Returns the found MX Record information after validation including weight for further processing * * @return array */ public function getMXRecord() { return $this->mxRecord; } /** * Internal method to validate the servers MX records * * @return bool|string[] * @psalm-return bool|list */ protected function validateMXRecords() { $mxHosts = []; $weight = []; $result = getmxrr($this->hostname, $mxHosts, $weight); if (! empty($mxHosts) && ! empty($weight)) { $this->mxRecord = array_combine($mxHosts, $weight) ?: []; } else { $this->mxRecord = []; } arsort($this->mxRecord); // Fallback to IPv4 hosts if no MX record found (RFC 2821 SS 5). if (! $result) { $result = gethostbynamel($this->hostname); if (is_array($result)) { $this->mxRecord = array_flip($result); } } if ($result === false) { $this->error(self::INVALID_MX_RECORD); return false; } if (! $this->options['useDeepMxCheck']) { return $result; } $validAddress = false; $reserved = true; foreach (array_keys($this->mxRecord) as $hostname) { $res = $this->isReserved($hostname); if (! $res) { $reserved = false; } if (trim($hostname) === '') { continue; } if ( ! $res && (checkdnsrr($hostname, 'A') || checkdnsrr($hostname, 'AAAA') || checkdnsrr($hostname, 'A6')) ) { $validAddress = true; break; } } if (! $validAddress) { $result = false; $error = $reserved ? self::INVALID_SEGMENT : self::INVALID_MX_RECORD; $this->error($error); } return $result; } /** * Internal method to validate the hostname part of the email address * * @return bool|string[] * @psalm-return bool|list */ protected function validateHostnamePart() { $hostname = $this->getHostnameValidator()->setTranslator($this->getTranslator()) ->isValid($this->hostname); if (! $hostname) { $this->error(self::INVALID_HOSTNAME); // Get messages and errors from hostnameValidator foreach ($this->getHostnameValidator()->getMessages() as $code => $message) { $this->abstractOptions['messages'][$code] = $message; } } elseif ($this->options['useMxCheck']) { // MX check on hostname $hostname = $this->validateMXRecords(); } return $hostname; } /** * Splits the given value in hostname and local part of the email address * * @param string $value Email address to be split * @return bool Returns false when the email can not be split */ protected function splitEmailParts($value) { $value = is_string($value) ? $value : ''; // Split email address up and disallow '..' if ( str_contains($value, '..') || ! preg_match('/^(.+)@([^@]+)$/', $value, $matches) ) { return false; } $this->localPart = $matches[1]; $this->hostname = $this->idnToAscii($matches[2]); return true; } /** * Defined by Laminas\Validator\ValidatorInterface * * Returns true if and only if $value is a valid email address * according to RFC2822 * * @link http://www.ietf.org/rfc/rfc2822.txt RFC2822 * @link http://www.columbia.edu/kermit/ascii.html US-ASCII characters * * @param string $value * @return bool */ public function isValid($value) { if (! is_string($value)) { $this->error(self::INVALID); return false; } $length = true; $this->setValue($value); // Split email address up and disallow '..' if (! $this->splitEmailParts($this->getValue())) { $this->error(self::INVALID_FORMAT); return false; } if ($this->getOption('strict') && (strlen($this->localPart) > 64) || (strlen($this->hostname) > 255)) { $length = false; $this->error(self::LENGTH_EXCEEDED); } // Match hostname part $hostname = false; if ($this->options['useDomainCheck']) { $hostname = $this->validateHostnamePart(); } $local = $this->validateLocalPart(); // If both parts valid, return true return ($local && $length) && (! $this->options['useDomainCheck'] || $hostname !== false); } /** * Safely convert UTF-8 encoded domain name to ASCII * * @param string $email the UTF-8 encoded email * @return string */ protected function idnToAscii($email) { if (extension_loaded('intl')) { if (defined('INTL_IDNA_VARIANT_UTS46')) { $value = idn_to_ascii($email, 0, INTL_IDNA_VARIANT_UTS46); return $value !== false ? $value : $email; } $value = idn_to_ascii($email); return $value !== false ? $value : $email; } return $email; } /** * Safely convert ASCII encoded domain name to UTF-8 * * @param string $email the ASCII encoded email * @return string */ protected function idnToUtf8($email) { if (strlen($email) === 0) { return $email; } if (extension_loaded('intl')) { // The documentation does not clarify what kind of failure // can happen in idn_to_utf8. One can assume if the source // is not IDN encoded, it would fail, but it usually returns // the source string in those cases. // But not when the source string is long enough. // Thus we default to source string ourselves. if (defined('INTL_IDNA_VARIANT_UTS46')) { $value = idn_to_utf8($email, 0, INTL_IDNA_VARIANT_UTS46); return $value !== false ? $value : $email; } $value = idn_to_utf8($email); return $value !== false ? $value : $email; } return $email; } }