├── CONTRIBUTING.md ├── LICENSE ├── composer.json └── src ├── EmailLexer.php ├── EmailParser.php ├── EmailValidator.php ├── MessageIDParser.php ├── Parser.php ├── Parser ├── Comment.php ├── CommentStrategy │ ├── CommentStrategy.php │ ├── DomainComment.php │ └── LocalComment.php ├── DomainLiteral.php ├── DomainPart.php ├── DoubleQuote.php ├── FoldingWhiteSpace.php ├── IDLeftPart.php ├── IDRightPart.php ├── LocalPart.php └── PartParser.php ├── Result ├── InvalidEmail.php ├── MultipleErrors.php ├── Reason │ ├── AtextAfterCFWS.php │ ├── CRLFAtTheEnd.php │ ├── CRLFX2.php │ ├── CRNoLF.php │ ├── CharNotAllowed.php │ ├── CommaInDomain.php │ ├── CommentsInIDRight.php │ ├── ConsecutiveAt.php │ ├── ConsecutiveDot.php │ ├── DetailedReason.php │ ├── DomainAcceptsNoMail.php │ ├── DomainHyphened.php │ ├── DomainTooLong.php │ ├── DotAtEnd.php │ ├── DotAtStart.php │ ├── EmptyReason.php │ ├── ExceptionFound.php │ ├── ExpectingATEXT.php │ ├── ExpectingCTEXT.php │ ├── ExpectingDTEXT.php │ ├── ExpectingDomainLiteralClose.php │ ├── LabelTooLong.php │ ├── LocalOrReservedDomain.php │ ├── NoDNSRecord.php │ ├── NoDomainPart.php │ ├── NoLocalPart.php │ ├── RFCWarnings.php │ ├── Reason.php │ ├── SpoofEmail.php │ ├── UnOpenedComment.php │ ├── UnableToGetDNSRecord.php │ ├── UnclosedComment.php │ ├── UnclosedQuotedString.php │ └── UnusualElements.php ├── Result.php ├── SpoofEmail.php └── ValidEmail.php ├── Validation ├── DNSCheckValidation.php ├── DNSGetRecordWrapper.php ├── DNSRecords.php ├── EmailValidation.php ├── Exception │ └── EmptyValidationList.php ├── Extra │ └── SpoofCheckValidation.php ├── MessageIDValidation.php ├── MultipleValidationWithAnd.php ├── NoRFCWarningsValidation.php └── RFCValidation.php └── Warning ├── AddressLiteral.php ├── CFWSNearAt.php ├── CFWSWithFWS.php ├── Comment.php ├── DeprecatedComment.php ├── DomainLiteral.php ├── EmailTooLong.php ├── IPV6BadChar.php ├── IPV6ColonEnd.php ├── IPV6ColonStart.php ├── IPV6Deprecated.php ├── IPV6DoubleColon.php ├── IPV6GroupCount.php ├── IPV6MaxGroups.php ├── LocalTooLong.php ├── NoDNSMXRecord.php ├── ObsoleteDTEXT.php ├── QuotedPart.php ├── QuotedString.php ├── TLD.php └── Warning.php /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository make sure to follow the Pull request process below. 4 | Reduce to the minimum 3rd party dependencies. 5 | 6 | Please note we have a [code of conduct](#Code of Conduct), please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | When doing a PR to v2 remember that you also have to do the PR port to v3, or tests confirming the bug is not reproducible. 11 | 12 | 1. Supported version is v3. If you are fixing a bug in v2, please port to v3 13 | 2. Use the title as a brief description of the changes 14 | 3. Describe the changes you are proposing 15 | 1. If adding an extra validation state the benefits of adding it and the problem is solving 16 | 2. Document in the readme, by adding it to the list 17 | 4. Provide appropriate tests for the code you are submitting: aim to keep the existing coverage percentage. 18 | 5. Add your Twitter handle (if you have) so we can thank you there. 19 | 20 | ## License 21 | By contributing, you agree that your contributions will be licensed under its MIT License. 22 | 23 | ## Code of Conduct 24 | 25 | ### Our Pledge 26 | 27 | We as members, contributors, and leaders pledge to make participation in our 28 | community a harassment-free experience for everyone, regardless of age, body 29 | size, visible or invisible disability, ethnicity, sex characteristics, gender 30 | identity and expression, level of experience, education, socio-economic status, 31 | nationality, personal appearance, race, religion, or sexual identity 32 | and orientation. 33 | 34 | We pledge to act and interact in ways that contribute to an open, welcoming, 35 | diverse, inclusive, and healthy community. 36 | 37 | ### Our Standards 38 | 39 | Examples of behavior that contributes to a positive environment for our 40 | community include: 41 | 42 | * Demonstrating empathy and kindness toward other people 43 | * Being respectful of differing opinions, viewpoints, and experiences 44 | * Giving and gracefully accepting constructive feedback 45 | * Accepting responsibility and apologizing to those affected by our mistakes, 46 | and learning from the experience 47 | * Focusing on what is best not just for us as individuals, but for the 48 | overall community 49 | 50 | Examples of unacceptable behavior include: 51 | 52 | * The use of sexualized language or imagery, and sexual attention or 53 | advances of any kind 54 | * Trolling, insulting or derogatory comments, and personal or political attacks 55 | * Public or private harassment 56 | * Publishing others' private information, such as a physical or email 57 | address, without their explicit permission 58 | * Other conduct which could reasonably be considered inappropriate in a 59 | professional setting 60 | 61 | ### Enforcement Responsibilities 62 | 63 | Community leaders are responsible for clarifying and enforcing our standards of 64 | acceptable behavior and will take appropriate and fair corrective action in 65 | response to any behavior that they deem inappropriate, threatening, offensive, 66 | or harmful. 67 | 68 | Community leaders have the right and responsibility to remove, edit, or reject 69 | comments, commits, code, wiki edits, issues, and other contributions that are 70 | not aligned to this Code of Conduct, and will communicate reasons for moderation 71 | decisions when appropriate. 72 | 73 | ### Scope 74 | 75 | This Code of Conduct applies within all community spaces, and also applies when 76 | an individual is officially representing the community in public spaces. 77 | Examples of representing our community include using an official e-mail address, 78 | posting via an official social media account, or acting as an appointed 79 | representative at an online or offline event. 80 | 81 | ### Enforcement 82 | 83 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 84 | reported to the community leaders responsible for enforcement at . 85 | All complaints will be reviewed and investigated promptly and fairly. 86 | 87 | All community leaders are obligated to respect the privacy and security of the 88 | reporter of any incident. 89 | 90 | #### Enforcement Guidelines 91 | 92 | Community leaders will follow these Community Impact Guidelines in determining 93 | the consequences for any action they deem in violation of this Code of Conduct: 94 | 95 | #### 1. Correction 96 | 97 | **Community Impact**: Use of inappropriate language or other behavior deemed 98 | unprofessional or unwelcome in the community. 99 | 100 | **Consequence**: A private, written warning from community leaders, providing 101 | clarity around the nature of the violation and an explanation of why the 102 | behavior was inappropriate. A public apology may be requested. 103 | 104 | #### 2. Warning 105 | 106 | **Community Impact**: A violation through a single incident or series 107 | of actions. 108 | 109 | **Consequence**: A warning with consequences for continued behavior. No 110 | interaction with the people involved, including unsolicited interaction with 111 | those enforcing the Code of Conduct, for a specified period of time. This 112 | includes avoiding interactions in community spaces as well as external channels 113 | like social media. Violating these terms may lead to a temporary or 114 | permanent ban. 115 | 116 | #### 3. Temporary Ban 117 | 118 | **Community Impact**: A serious violation of community standards, including 119 | sustained inappropriate behavior. 120 | 121 | **Consequence**: A temporary ban from any sort of interaction or public 122 | communication with the community for a specified period of time. No public or 123 | private interaction with the people involved, including unsolicited interaction 124 | with those enforcing the Code of Conduct, is allowed during this period. 125 | Violating these terms may lead to a permanent ban. 126 | 127 | #### 4. Permanent Ban 128 | 129 | **Community Impact**: Demonstrating a pattern of violation of community 130 | standards, including sustained inappropriate behavior, harassment of an 131 | individual, or aggression toward or disparagement of classes of individuals. 132 | 133 | **Consequence**: A permanent ban from any sort of public interaction within 134 | the community. 135 | 136 | ### Attribution 137 | 138 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 139 | version 2.0, available at 140 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 141 | 142 | Community Impact Guidelines were inspired by 143 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 144 | 145 | For answers to common questions about this code of conduct, see the FAQ at 146 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 147 | at [https://www.contributor-covenant.org/translations][translations]. 148 | 149 | [homepage]: https://www.contributor-covenant.org 150 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 151 | [Mozilla CoC]: https://github.com/mozilla/diversity 152 | [FAQ]: https://www.contributor-covenant.org/faq 153 | [translations]: https://www.contributor-covenant.org/translations 154 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2023 Eduardo Gulias Davis 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "egulias/email-validator", 3 | "description": "A library for validating emails against several RFCs", 4 | "homepage": "https://github.com/egulias/EmailValidator", 5 | "keywords": ["email", "validation", "validator", "emailvalidation", "emailvalidator"], 6 | "license": "MIT", 7 | "authors": [ 8 | {"name": "Eduardo Gulias Davis"} 9 | ], 10 | "extra": { 11 | "branch-alias": { 12 | "dev-master": "4.0.x-dev" 13 | } 14 | }, 15 | "require": { 16 | "php": ">=8.1", 17 | "doctrine/lexer": "^2.0 || ^3.0", 18 | "symfony/polyfill-intl-idn": "^1.26" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "^10.2", 22 | "vimeo/psalm": "^5.12" 23 | }, 24 | "suggest": { 25 | "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Egulias\\EmailValidator\\": "src" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Egulias\\EmailValidator\\Tests\\": "tests" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/EmailLexer.php: -------------------------------------------------------------------------------- 1 | */ 9 | class EmailLexer extends AbstractLexer 10 | { 11 | //ASCII values 12 | public const S_EMPTY = -1; 13 | public const C_NUL = 0; 14 | public const S_HTAB = 9; 15 | public const S_LF = 10; 16 | public const S_CR = 13; 17 | public const S_SP = 32; 18 | public const EXCLAMATION = 33; 19 | public const S_DQUOTE = 34; 20 | public const NUMBER_SIGN = 35; 21 | public const DOLLAR = 36; 22 | public const PERCENTAGE = 37; 23 | public const AMPERSAND = 38; 24 | public const S_SQUOTE = 39; 25 | public const S_OPENPARENTHESIS = 40; 26 | public const S_CLOSEPARENTHESIS = 41; 27 | public const ASTERISK = 42; 28 | public const S_PLUS = 43; 29 | public const S_COMMA = 44; 30 | public const S_HYPHEN = 45; 31 | public const S_DOT = 46; 32 | public const S_SLASH = 47; 33 | public const S_COLON = 58; 34 | public const S_SEMICOLON = 59; 35 | public const S_LOWERTHAN = 60; 36 | public const S_EQUAL = 61; 37 | public const S_GREATERTHAN = 62; 38 | public const QUESTIONMARK = 63; 39 | public const S_AT = 64; 40 | public const S_OPENBRACKET = 91; 41 | public const S_BACKSLASH = 92; 42 | public const S_CLOSEBRACKET = 93; 43 | public const CARET = 94; 44 | public const S_UNDERSCORE = 95; 45 | public const S_BACKTICK = 96; 46 | public const S_OPENCURLYBRACES = 123; 47 | public const S_PIPE = 124; 48 | public const S_CLOSECURLYBRACES = 125; 49 | public const S_TILDE = 126; 50 | public const C_DEL = 127; 51 | public const INVERT_QUESTIONMARK = 168; 52 | public const INVERT_EXCLAMATION = 173; 53 | public const GENERIC = 300; 54 | public const S_IPV6TAG = 301; 55 | public const INVALID = 302; 56 | public const CRLF = 1310; 57 | public const S_DOUBLECOLON = 5858; 58 | public const ASCII_INVALID_FROM = 127; 59 | public const ASCII_INVALID_TO = 199; 60 | 61 | /** 62 | * US-ASCII visible characters not valid for atext (@link http://tools.ietf.org/html/rfc5322#section-3.2.3) 63 | * 64 | * @var array 65 | */ 66 | protected $charValue = [ 67 | '{' => self::S_OPENCURLYBRACES, 68 | '}' => self::S_CLOSECURLYBRACES, 69 | '(' => self::S_OPENPARENTHESIS, 70 | ')' => self::S_CLOSEPARENTHESIS, 71 | '<' => self::S_LOWERTHAN, 72 | '>' => self::S_GREATERTHAN, 73 | '[' => self::S_OPENBRACKET, 74 | ']' => self::S_CLOSEBRACKET, 75 | ':' => self::S_COLON, 76 | ';' => self::S_SEMICOLON, 77 | '@' => self::S_AT, 78 | '\\' => self::S_BACKSLASH, 79 | '/' => self::S_SLASH, 80 | ',' => self::S_COMMA, 81 | '.' => self::S_DOT, 82 | "'" => self::S_SQUOTE, 83 | "`" => self::S_BACKTICK, 84 | '"' => self::S_DQUOTE, 85 | '-' => self::S_HYPHEN, 86 | '::' => self::S_DOUBLECOLON, 87 | ' ' => self::S_SP, 88 | "\t" => self::S_HTAB, 89 | "\r" => self::S_CR, 90 | "\n" => self::S_LF, 91 | "\r\n" => self::CRLF, 92 | 'IPv6' => self::S_IPV6TAG, 93 | '' => self::S_EMPTY, 94 | '\0' => self::C_NUL, 95 | '*' => self::ASTERISK, 96 | '!' => self::EXCLAMATION, 97 | '&' => self::AMPERSAND, 98 | '^' => self::CARET, 99 | '$' => self::DOLLAR, 100 | '%' => self::PERCENTAGE, 101 | '~' => self::S_TILDE, 102 | '|' => self::S_PIPE, 103 | '_' => self::S_UNDERSCORE, 104 | '=' => self::S_EQUAL, 105 | '+' => self::S_PLUS, 106 | '¿' => self::INVERT_QUESTIONMARK, 107 | '?' => self::QUESTIONMARK, 108 | '#' => self::NUMBER_SIGN, 109 | '¡' => self::INVERT_EXCLAMATION, 110 | ]; 111 | 112 | public const INVALID_CHARS_REGEX = "/[^\p{S}\p{C}\p{Cc}]+/iu"; 113 | 114 | public const VALID_UTF8_REGEX = '/\p{Cc}+/u'; 115 | 116 | public const CATCHABLE_PATTERNS = [ 117 | '[a-zA-Z]+[46]?', //ASCII and domain literal 118 | '[^\x00-\x7F]', //UTF-8 119 | '[0-9]+', 120 | '\r\n', 121 | '::', 122 | '\s+?', 123 | '.', 124 | ]; 125 | 126 | public const NON_CATCHABLE_PATTERNS = [ 127 | '[\xA0-\xff]+', 128 | ]; 129 | 130 | public const MODIFIERS = 'iu'; 131 | 132 | /** @var bool */ 133 | protected $hasInvalidTokens = false; 134 | 135 | /** 136 | * @var Token 137 | */ 138 | protected Token $previous; 139 | 140 | /** 141 | * The last matched/seen token. 142 | * 143 | * @var Token 144 | */ 145 | public Token $current; 146 | 147 | /** 148 | * @var Token 149 | */ 150 | private Token $nullToken; 151 | 152 | /** @var string */ 153 | private $accumulator = ''; 154 | 155 | /** @var bool */ 156 | private $hasToRecord = false; 157 | 158 | public function __construct() 159 | { 160 | /** @var Token $nullToken */ 161 | $nullToken = new Token('', self::S_EMPTY, 0); 162 | $this->nullToken = $nullToken; 163 | 164 | $this->current = $this->previous = $this->nullToken; 165 | $this->lookahead = null; 166 | } 167 | 168 | public function reset(): void 169 | { 170 | $this->hasInvalidTokens = false; 171 | parent::reset(); 172 | $this->current = $this->previous = $this->nullToken; 173 | } 174 | 175 | /** 176 | * @param int $type 177 | * @throws \UnexpectedValueException 178 | * @return boolean 179 | * 180 | */ 181 | public function find($type): bool 182 | { 183 | $search = clone $this; 184 | $search->skipUntil($type); 185 | 186 | if (!$search->lookahead) { 187 | throw new \UnexpectedValueException($type . ' not found'); 188 | } 189 | return true; 190 | } 191 | 192 | /** 193 | * moveNext 194 | * 195 | * @return boolean 196 | */ 197 | public function moveNext(): bool 198 | { 199 | if ($this->hasToRecord && $this->previous === $this->nullToken) { 200 | $this->accumulator .= $this->current->value; 201 | } 202 | 203 | $this->previous = $this->current; 204 | 205 | if ($this->lookahead === null) { 206 | $this->lookahead = $this->nullToken; 207 | } 208 | 209 | $hasNext = parent::moveNext(); 210 | $this->current = $this->token ?? $this->nullToken; 211 | 212 | if ($this->hasToRecord) { 213 | $this->accumulator .= $this->current->value; 214 | } 215 | 216 | return $hasNext; 217 | } 218 | 219 | /** 220 | * Retrieve token type. Also processes the token value if necessary. 221 | * 222 | * @param string $value 223 | * @throws \InvalidArgumentException 224 | * @return integer 225 | */ 226 | protected function getType(&$value): int 227 | { 228 | $encoded = $value; 229 | 230 | if (mb_detect_encoding($value, 'auto', true) !== 'UTF-8') { 231 | $encoded = mb_convert_encoding($value, 'UTF-8', 'Windows-1252'); 232 | } 233 | 234 | if ($this->isValid($encoded)) { 235 | return $this->charValue[$encoded]; 236 | } 237 | 238 | if ($this->isNullType($encoded)) { 239 | return self::C_NUL; 240 | } 241 | 242 | if ($this->isInvalidChar($encoded)) { 243 | $this->hasInvalidTokens = true; 244 | return self::INVALID; 245 | } 246 | 247 | return self::GENERIC; 248 | } 249 | 250 | protected function isValid(string $value): bool 251 | { 252 | return isset($this->charValue[$value]); 253 | } 254 | 255 | protected function isNullType(string $value): bool 256 | { 257 | return $value === "\0"; 258 | } 259 | 260 | protected function isInvalidChar(string $value): bool 261 | { 262 | return !preg_match(self::INVALID_CHARS_REGEX, $value); 263 | } 264 | 265 | protected function isUTF8Invalid(string $value): bool 266 | { 267 | return preg_match(self::VALID_UTF8_REGEX, $value) !== false; 268 | } 269 | 270 | public function hasInvalidTokens(): bool 271 | { 272 | return $this->hasInvalidTokens; 273 | } 274 | 275 | /** 276 | * getPrevious 277 | * 278 | * @return Token 279 | */ 280 | public function getPrevious(): Token 281 | { 282 | return $this->previous; 283 | } 284 | 285 | /** 286 | * Lexical catchable patterns. 287 | * 288 | * @return string[] 289 | */ 290 | protected function getCatchablePatterns(): array 291 | { 292 | return self::CATCHABLE_PATTERNS; 293 | } 294 | 295 | /** 296 | * Lexical non-catchable patterns. 297 | * 298 | * @return string[] 299 | */ 300 | protected function getNonCatchablePatterns(): array 301 | { 302 | return self::NON_CATCHABLE_PATTERNS; 303 | } 304 | 305 | protected function getModifiers(): string 306 | { 307 | return self::MODIFIERS; 308 | } 309 | 310 | public function getAccumulatedValues(): string 311 | { 312 | return $this->accumulator; 313 | } 314 | 315 | public function startRecording(): void 316 | { 317 | $this->hasToRecord = true; 318 | } 319 | 320 | public function stopRecording(): void 321 | { 322 | $this->hasToRecord = false; 323 | } 324 | 325 | public function clearRecorded(): void 326 | { 327 | $this->accumulator = ''; 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /src/EmailParser.php: -------------------------------------------------------------------------------- 1 | addLongEmailWarning($this->localPart, $this->domainPart); 32 | 33 | return $result; 34 | } 35 | 36 | protected function preLeftParsing(): Result 37 | { 38 | if (!$this->hasAtToken()) { 39 | return new InvalidEmail(new NoLocalPart(), $this->lexer->current->value); 40 | } 41 | return new ValidEmail(); 42 | } 43 | 44 | protected function parseLeftFromAt(): Result 45 | { 46 | return $this->processLocalPart(); 47 | } 48 | 49 | protected function parseRightFromAt(): Result 50 | { 51 | return $this->processDomainPart(); 52 | } 53 | 54 | private function processLocalPart(): Result 55 | { 56 | $localPartParser = new LocalPart($this->lexer); 57 | $localPartResult = $localPartParser->parse(); 58 | $this->localPart = $localPartParser->localPart(); 59 | $this->warnings = [...$localPartParser->getWarnings(), ...$this->warnings]; 60 | 61 | return $localPartResult; 62 | } 63 | 64 | private function processDomainPart(): Result 65 | { 66 | $domainPartParser = new DomainPart($this->lexer); 67 | $domainPartResult = $domainPartParser->parse(); 68 | $this->domainPart = $domainPartParser->domainPart(); 69 | $this->warnings = [...$domainPartParser->getWarnings(), ...$this->warnings]; 70 | 71 | return $domainPartResult; 72 | } 73 | 74 | public function getDomainPart(): string 75 | { 76 | return $this->domainPart; 77 | } 78 | 79 | public function getLocalPart(): string 80 | { 81 | return $this->localPart; 82 | } 83 | 84 | private function addLongEmailWarning(string $localPart, string $parsedDomainPart): void 85 | { 86 | if (strlen($localPart . '@' . $parsedDomainPart) > self::EMAIL_MAX_LENGTH) { 87 | $this->warnings[EmailTooLong::CODE] = new EmailTooLong(); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/EmailValidator.php: -------------------------------------------------------------------------------- 1 | lexer = new EmailLexer(); 28 | } 29 | 30 | /** 31 | * @param string $email 32 | * @param EmailValidation $emailValidation 33 | * @return bool 34 | */ 35 | public function isValid(string $email, EmailValidation $emailValidation) 36 | { 37 | $isValid = $emailValidation->isValid($email, $this->lexer); 38 | $this->warnings = $emailValidation->getWarnings(); 39 | $this->error = $emailValidation->getError(); 40 | 41 | return $isValid; 42 | } 43 | 44 | /** 45 | * @return boolean 46 | */ 47 | public function hasWarnings() 48 | { 49 | return !empty($this->warnings); 50 | } 51 | 52 | /** 53 | * @return array 54 | */ 55 | public function getWarnings() 56 | { 57 | return $this->warnings; 58 | } 59 | 60 | /** 61 | * @return InvalidEmail|null 62 | */ 63 | public function getError() 64 | { 65 | return $this->error; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/MessageIDParser.php: -------------------------------------------------------------------------------- 1 | addLongEmailWarning($this->idLeft, $this->idRight); 33 | 34 | return $result; 35 | } 36 | 37 | protected function preLeftParsing(): Result 38 | { 39 | if (!$this->hasAtToken()) { 40 | return new InvalidEmail(new NoLocalPart(), $this->lexer->current->value); 41 | } 42 | return new ValidEmail(); 43 | } 44 | 45 | protected function parseLeftFromAt(): Result 46 | { 47 | return $this->processIDLeft(); 48 | } 49 | 50 | protected function parseRightFromAt(): Result 51 | { 52 | return $this->processIDRight(); 53 | } 54 | 55 | private function processIDLeft(): Result 56 | { 57 | $localPartParser = new IDLeftPart($this->lexer); 58 | $localPartResult = $localPartParser->parse(); 59 | $this->idLeft = $localPartParser->localPart(); 60 | $this->warnings = [...$localPartParser->getWarnings(), ...$this->warnings]; 61 | 62 | return $localPartResult; 63 | } 64 | 65 | private function processIDRight(): Result 66 | { 67 | $domainPartParser = new IDRightPart($this->lexer); 68 | $domainPartResult = $domainPartParser->parse(); 69 | $this->idRight = $domainPartParser->domainPart(); 70 | $this->warnings = [...$domainPartParser->getWarnings(), ...$this->warnings]; 71 | 72 | return $domainPartResult; 73 | } 74 | 75 | public function getLeftPart(): string 76 | { 77 | return $this->idLeft; 78 | } 79 | 80 | public function getRightPart(): string 81 | { 82 | return $this->idRight; 83 | } 84 | 85 | private function addLongEmailWarning(string $localPart, string $parsedDomainPart): void 86 | { 87 | if (strlen($localPart . '@' . $parsedDomainPart) > self::EMAILID_MAX_LENGTH) { 88 | $this->warnings[EmailTooLong::CODE] = new EmailTooLong(); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | lexer = $lexer; 33 | } 34 | 35 | public function parse(string $str): Result 36 | { 37 | $this->lexer->setInput($str); 38 | 39 | if ($this->lexer->hasInvalidTokens()) { 40 | return new InvalidEmail(new ExpectingATEXT("Invalid tokens found"), $this->lexer->current->value); 41 | } 42 | 43 | $preParsingResult = $this->preLeftParsing(); 44 | if ($preParsingResult->isInvalid()) { 45 | return $preParsingResult; 46 | } 47 | 48 | $localPartResult = $this->parseLeftFromAt(); 49 | 50 | if ($localPartResult->isInvalid()) { 51 | return $localPartResult; 52 | } 53 | 54 | $domainPartResult = $this->parseRightFromAt(); 55 | 56 | if ($domainPartResult->isInvalid()) { 57 | return $domainPartResult; 58 | } 59 | 60 | return new ValidEmail(); 61 | } 62 | 63 | /** 64 | * @return Warning\Warning[] 65 | */ 66 | public function getWarnings(): array 67 | { 68 | return $this->warnings; 69 | } 70 | 71 | protected function hasAtToken(): bool 72 | { 73 | $this->lexer->moveNext(); 74 | $this->lexer->moveNext(); 75 | 76 | return !$this->lexer->current->isA(EmailLexer::S_AT); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Parser/Comment.php: -------------------------------------------------------------------------------- 1 | lexer = $lexer; 29 | $this->commentStrategy = $commentStrategy; 30 | } 31 | 32 | public function parse(): Result 33 | { 34 | if ($this->lexer->current->isA(EmailLexer::S_OPENPARENTHESIS)) { 35 | $this->openedParenthesis++; 36 | if ($this->noClosingParenthesis()) { 37 | return new InvalidEmail(new UnclosedComment(), $this->lexer->current->value); 38 | } 39 | } 40 | 41 | if ($this->lexer->current->isA(EmailLexer::S_CLOSEPARENTHESIS)) { 42 | return new InvalidEmail(new UnOpenedComment(), $this->lexer->current->value); 43 | } 44 | 45 | $this->warnings[WarningComment::CODE] = new WarningComment(); 46 | 47 | $moreTokens = true; 48 | while ($this->commentStrategy->exitCondition($this->lexer, $this->openedParenthesis) && $moreTokens) { 49 | 50 | if ($this->lexer->isNextToken(EmailLexer::S_OPENPARENTHESIS)) { 51 | $this->openedParenthesis++; 52 | } 53 | $this->warnEscaping(); 54 | if ($this->lexer->isNextToken(EmailLexer::S_CLOSEPARENTHESIS)) { 55 | $this->openedParenthesis--; 56 | } 57 | $moreTokens = $this->lexer->moveNext(); 58 | } 59 | 60 | if ($this->openedParenthesis >= 1) { 61 | return new InvalidEmail(new UnclosedComment(), $this->lexer->current->value); 62 | } 63 | if ($this->openedParenthesis < 0) { 64 | return new InvalidEmail(new UnOpenedComment(), $this->lexer->current->value); 65 | } 66 | 67 | $finalValidations = $this->commentStrategy->endOfLoopValidations($this->lexer); 68 | 69 | $this->warnings = [...$this->warnings, ...$this->commentStrategy->getWarnings()]; 70 | 71 | return $finalValidations; 72 | } 73 | 74 | 75 | /** 76 | * @return void 77 | */ 78 | private function warnEscaping(): void 79 | { 80 | //Backslash found 81 | if (!$this->lexer->current->isA(EmailLexer::S_BACKSLASH)) { 82 | return; 83 | } 84 | 85 | if (!$this->lexer->isNextTokenAny(array(EmailLexer::S_SP, EmailLexer::S_HTAB, EmailLexer::C_DEL))) { 86 | return; 87 | } 88 | 89 | $this->warnings[QuotedPart::CODE] = 90 | new QuotedPart($this->lexer->getPrevious()->type, $this->lexer->current->type); 91 | } 92 | 93 | private function noClosingParenthesis(): bool 94 | { 95 | try { 96 | $this->lexer->find(EmailLexer::S_CLOSEPARENTHESIS); 97 | return false; 98 | } catch (\RuntimeException $e) { 99 | return true; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Parser/CommentStrategy/CommentStrategy.php: -------------------------------------------------------------------------------- 1 | isNextToken(EmailLexer::S_DOT)); 16 | } 17 | 18 | public function endOfLoopValidations(EmailLexer $lexer): Result 19 | { 20 | //test for end of string 21 | if (!$lexer->isNextToken(EmailLexer::S_DOT)) { 22 | return new InvalidEmail(new ExpectingATEXT('DOT not found near CLOSEPARENTHESIS'), $lexer->current->value); 23 | } 24 | //add warning 25 | //Address is valid within the message but cannot be used unmodified for the envelope 26 | return new ValidEmail(); 27 | } 28 | 29 | public function getWarnings(): array 30 | { 31 | return []; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Parser/CommentStrategy/LocalComment.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private $warnings = []; 19 | 20 | public function exitCondition(EmailLexer $lexer, int $openedParenthesis): bool 21 | { 22 | return !$lexer->isNextToken(EmailLexer::S_AT); 23 | } 24 | 25 | public function endOfLoopValidations(EmailLexer $lexer): Result 26 | { 27 | if (!$lexer->isNextToken(EmailLexer::S_AT)) { 28 | return new InvalidEmail(new ExpectingATEXT('ATEX is not expected after closing comments'), $lexer->current->value); 29 | } 30 | $this->warnings[CFWSNearAt::CODE] = new CFWSNearAt(); 31 | return new ValidEmail(); 32 | } 33 | 34 | public function getWarnings(): array 35 | { 36 | return $this->warnings; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Parser/DomainLiteral.php: -------------------------------------------------------------------------------- 1 | addTagWarnings(); 38 | 39 | $IPv6TAG = false; 40 | $addressLiteral = ''; 41 | 42 | do { 43 | if ($this->lexer->current->isA(EmailLexer::C_NUL)) { 44 | return new InvalidEmail(new ExpectingDTEXT(), $this->lexer->current->value); 45 | } 46 | 47 | $this->addObsoleteWarnings(); 48 | 49 | if ($this->lexer->isNextTokenAny(array(EmailLexer::S_OPENBRACKET, EmailLexer::S_OPENBRACKET))) { 50 | return new InvalidEmail(new ExpectingDTEXT(), $this->lexer->current->value); 51 | } 52 | 53 | if ($this->lexer->isNextTokenAny( 54 | array(EmailLexer::S_HTAB, EmailLexer::S_SP, EmailLexer::CRLF) 55 | )) { 56 | $this->warnings[CFWSWithFWS::CODE] = new CFWSWithFWS(); 57 | $this->parseFWS(); 58 | } 59 | 60 | if ($this->lexer->isNextToken(EmailLexer::S_CR)) { 61 | return new InvalidEmail(new CRNoLF(), $this->lexer->current->value); 62 | } 63 | 64 | if ($this->lexer->current->isA(EmailLexer::S_BACKSLASH)) { 65 | return new InvalidEmail(new UnusualElements($this->lexer->current->value), $this->lexer->current->value); 66 | } 67 | if ($this->lexer->current->isA(EmailLexer::S_IPV6TAG)) { 68 | $IPv6TAG = true; 69 | } 70 | 71 | if ($this->lexer->current->isA(EmailLexer::S_CLOSEBRACKET)) { 72 | break; 73 | } 74 | 75 | $addressLiteral .= $this->lexer->current->value; 76 | } while ($this->lexer->moveNext()); 77 | 78 | 79 | //Encapsulate 80 | $addressLiteral = str_replace('[', '', $addressLiteral); 81 | $isAddressLiteralIPv4 = $this->checkIPV4Tag($addressLiteral); 82 | 83 | if (!$isAddressLiteralIPv4) { 84 | return new ValidEmail(); 85 | } 86 | 87 | $addressLiteral = $this->convertIPv4ToIPv6($addressLiteral); 88 | 89 | if (!$IPv6TAG) { 90 | $this->warnings[WarningDomainLiteral::CODE] = new WarningDomainLiteral(); 91 | return new ValidEmail(); 92 | } 93 | 94 | $this->warnings[AddressLiteral::CODE] = new AddressLiteral(); 95 | 96 | $this->checkIPV6Tag($addressLiteral); 97 | 98 | return new ValidEmail(); 99 | } 100 | 101 | /** 102 | * @param string $addressLiteral 103 | * @param int $maxGroups 104 | */ 105 | public function checkIPV6Tag($addressLiteral, $maxGroups = 8): void 106 | { 107 | $prev = $this->lexer->getPrevious(); 108 | if ($prev->isA(EmailLexer::S_COLON)) { 109 | $this->warnings[IPV6ColonEnd::CODE] = new IPV6ColonEnd(); 110 | } 111 | 112 | $IPv6 = substr($addressLiteral, 5); 113 | //Daniel Marschall's new IPv6 testing strategy 114 | $matchesIP = explode(':', $IPv6); 115 | $groupCount = count($matchesIP); 116 | $colons = strpos($IPv6, '::'); 117 | 118 | if (count(preg_grep('/^[0-9A-Fa-f]{0,4}$/', $matchesIP, PREG_GREP_INVERT)) !== 0) { 119 | $this->warnings[IPV6BadChar::CODE] = new IPV6BadChar(); 120 | } 121 | 122 | if ($colons === false) { 123 | // We need exactly the right number of groups 124 | if ($groupCount !== $maxGroups) { 125 | $this->warnings[IPV6GroupCount::CODE] = new IPV6GroupCount(); 126 | } 127 | return; 128 | } 129 | 130 | if ($colons !== strrpos($IPv6, '::')) { 131 | $this->warnings[IPV6DoubleColon::CODE] = new IPV6DoubleColon(); 132 | return; 133 | } 134 | 135 | if ($colons === 0 || $colons === (strlen($IPv6) - 2)) { 136 | // RFC 4291 allows :: at the start or end of an address 137 | //with 7 other groups in addition 138 | ++$maxGroups; 139 | } 140 | 141 | if ($groupCount > $maxGroups) { 142 | $this->warnings[IPV6MaxGroups::CODE] = new IPV6MaxGroups(); 143 | } elseif ($groupCount === $maxGroups) { 144 | $this->warnings[IPV6Deprecated::CODE] = new IPV6Deprecated(); 145 | } 146 | } 147 | 148 | public function convertIPv4ToIPv6(string $addressLiteralIPv4): string 149 | { 150 | $matchesIP = []; 151 | $IPv4Match = preg_match(self::IPV4_REGEX, $addressLiteralIPv4, $matchesIP); 152 | 153 | // Extract IPv4 part from the end of the address-literal (if there is one) 154 | if ($IPv4Match > 0) { 155 | $index = (int) strrpos($addressLiteralIPv4, $matchesIP[0]); 156 | //There's a match but it is at the start 157 | if ($index > 0) { 158 | // Convert IPv4 part to IPv6 format for further testing 159 | return substr($addressLiteralIPv4, 0, $index) . '0:0'; 160 | } 161 | } 162 | 163 | return $addressLiteralIPv4; 164 | } 165 | 166 | /** 167 | * @param string $addressLiteral 168 | * 169 | * @return bool 170 | */ 171 | protected function checkIPV4Tag($addressLiteral): bool 172 | { 173 | $matchesIP = []; 174 | $IPv4Match = preg_match(self::IPV4_REGEX, $addressLiteral, $matchesIP); 175 | 176 | // Extract IPv4 part from the end of the address-literal (if there is one) 177 | 178 | if ($IPv4Match > 0) { 179 | $index = strrpos($addressLiteral, $matchesIP[0]); 180 | //There's a match but it is at the start 181 | if ($index === 0) { 182 | $this->warnings[AddressLiteral::CODE] = new AddressLiteral(); 183 | return false; 184 | } 185 | } 186 | 187 | return true; 188 | } 189 | 190 | private function addObsoleteWarnings(): void 191 | { 192 | if (in_array($this->lexer->current->type, self::OBSOLETE_WARNINGS)) { 193 | $this->warnings[ObsoleteDTEXT::CODE] = new ObsoleteDTEXT(); 194 | } 195 | } 196 | 197 | private function addTagWarnings(): void 198 | { 199 | if ($this->lexer->isNextToken(EmailLexer::S_COLON)) { 200 | $this->warnings[IPV6ColonStart::CODE] = new IPV6ColonStart(); 201 | } 202 | if ($this->lexer->isNextToken(EmailLexer::S_IPV6TAG)) { 203 | $lexer = clone $this->lexer; 204 | $lexer->moveNext(); 205 | if ($lexer->isNextToken(EmailLexer::S_DOUBLECOLON)) { 206 | $this->warnings[IPV6ColonStart::CODE] = new IPV6ColonStart(); 207 | } 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Parser/DomainPart.php: -------------------------------------------------------------------------------- 1 | lexer->clearRecorded(); 44 | $this->lexer->startRecording(); 45 | 46 | $this->lexer->moveNext(); 47 | 48 | $domainChecks = $this->performDomainStartChecks(); 49 | if ($domainChecks->isInvalid()) { 50 | return $domainChecks; 51 | } 52 | 53 | if ($this->lexer->current->isA(EmailLexer::S_AT)) { 54 | return new InvalidEmail(new ConsecutiveAt(), $this->lexer->current->value); 55 | } 56 | 57 | $result = $this->doParseDomainPart(); 58 | if ($result->isInvalid()) { 59 | return $result; 60 | } 61 | 62 | $end = $this->checkEndOfDomain(); 63 | if ($end->isInvalid()) { 64 | return $end; 65 | } 66 | 67 | $this->lexer->stopRecording(); 68 | $this->domainPart = $this->lexer->getAccumulatedValues(); 69 | 70 | $length = strlen($this->domainPart); 71 | if ($length > self::DOMAIN_MAX_LENGTH) { 72 | return new InvalidEmail(new DomainTooLong(), $this->lexer->current->value); 73 | } 74 | 75 | return new ValidEmail(); 76 | } 77 | 78 | private function checkEndOfDomain(): Result 79 | { 80 | $prev = $this->lexer->getPrevious(); 81 | if ($prev->isA(EmailLexer::S_DOT)) { 82 | return new InvalidEmail(new DotAtEnd(), $this->lexer->current->value); 83 | } 84 | if ($prev->isA(EmailLexer::S_HYPHEN)) { 85 | return new InvalidEmail(new DomainHyphened('Hypen found at the end of the domain'), $prev->value); 86 | } 87 | 88 | if ($this->lexer->current->isA(EmailLexer::S_SP)) { 89 | return new InvalidEmail(new CRLFAtTheEnd(), $prev->value); 90 | } 91 | return new ValidEmail(); 92 | } 93 | 94 | private function performDomainStartChecks(): Result 95 | { 96 | $invalidTokens = $this->checkInvalidTokensAfterAT(); 97 | if ($invalidTokens->isInvalid()) { 98 | return $invalidTokens; 99 | } 100 | 101 | $missingDomain = $this->checkEmptyDomain(); 102 | if ($missingDomain->isInvalid()) { 103 | return $missingDomain; 104 | } 105 | 106 | if ($this->lexer->current->isA(EmailLexer::S_OPENPARENTHESIS)) { 107 | $this->warnings[DeprecatedComment::CODE] = new DeprecatedComment(); 108 | } 109 | return new ValidEmail(); 110 | } 111 | 112 | private function checkEmptyDomain(): Result 113 | { 114 | $thereIsNoDomain = $this->lexer->current->isA(EmailLexer::S_EMPTY) || 115 | ($this->lexer->current->isA(EmailLexer::S_SP) && 116 | !$this->lexer->isNextToken(EmailLexer::GENERIC)); 117 | 118 | if ($thereIsNoDomain) { 119 | return new InvalidEmail(new NoDomainPart(), $this->lexer->current->value); 120 | } 121 | 122 | return new ValidEmail(); 123 | } 124 | 125 | private function checkInvalidTokensAfterAT(): Result 126 | { 127 | if ($this->lexer->current->isA(EmailLexer::S_DOT)) { 128 | return new InvalidEmail(new DotAtStart(), $this->lexer->current->value); 129 | } 130 | if ($this->lexer->current->isA(EmailLexer::S_HYPHEN)) { 131 | return new InvalidEmail(new DomainHyphened('After AT'), $this->lexer->current->value); 132 | } 133 | return new ValidEmail(); 134 | } 135 | 136 | protected function parseComments(): Result 137 | { 138 | $commentParser = new Comment($this->lexer, new DomainComment()); 139 | $result = $commentParser->parse(); 140 | $this->warnings = [...$this->warnings, ...$commentParser->getWarnings()]; 141 | 142 | return $result; 143 | } 144 | 145 | protected function doParseDomainPart(): Result 146 | { 147 | $tldMissing = true; 148 | $hasComments = false; 149 | $domain = ''; 150 | do { 151 | $prev = $this->lexer->getPrevious(); 152 | 153 | $notAllowedChars = $this->checkNotAllowedChars($this->lexer->current); 154 | if ($notAllowedChars->isInvalid()) { 155 | return $notAllowedChars; 156 | } 157 | 158 | if ( 159 | $this->lexer->current->isA(EmailLexer::S_OPENPARENTHESIS) || 160 | $this->lexer->current->isA(EmailLexer::S_CLOSEPARENTHESIS) 161 | ) { 162 | $hasComments = true; 163 | $commentsResult = $this->parseComments(); 164 | 165 | //Invalid comment parsing 166 | if ($commentsResult->isInvalid()) { 167 | return $commentsResult; 168 | } 169 | } 170 | 171 | $dotsResult = $this->checkConsecutiveDots(); 172 | if ($dotsResult->isInvalid()) { 173 | return $dotsResult; 174 | } 175 | 176 | if ($this->lexer->current->isA(EmailLexer::S_OPENBRACKET)) { 177 | $literalResult = $this->parseDomainLiteral(); 178 | 179 | $this->addTLDWarnings($tldMissing); 180 | return $literalResult; 181 | } 182 | 183 | $labelCheck = $this->checkLabelLength(); 184 | if ($labelCheck->isInvalid()) { 185 | return $labelCheck; 186 | } 187 | 188 | $FwsResult = $this->parseFWS(); 189 | if ($FwsResult->isInvalid()) { 190 | return $FwsResult; 191 | } 192 | 193 | $domain .= $this->lexer->current->value; 194 | 195 | if ($this->lexer->current->isA(EmailLexer::S_DOT) && $this->lexer->isNextToken(EmailLexer::GENERIC)) { 196 | $tldMissing = false; 197 | } 198 | 199 | $exceptionsResult = $this->checkDomainPartExceptions($prev, $hasComments); 200 | if ($exceptionsResult->isInvalid()) { 201 | return $exceptionsResult; 202 | } 203 | $this->lexer->moveNext(); 204 | } while (!$this->lexer->current->isA(EmailLexer::S_EMPTY)); 205 | 206 | $labelCheck = $this->checkLabelLength(true); 207 | if ($labelCheck->isInvalid()) { 208 | return $labelCheck; 209 | } 210 | $this->addTLDWarnings($tldMissing); 211 | 212 | $this->domainPart = $domain; 213 | return new ValidEmail(); 214 | } 215 | 216 | /** 217 | * @param Token $token 218 | * 219 | * @return Result 220 | */ 221 | private function checkNotAllowedChars(Token $token): Result 222 | { 223 | $notAllowed = [EmailLexer::S_BACKSLASH => true, EmailLexer::S_SLASH => true]; 224 | if (isset($notAllowed[$token->type])) { 225 | return new InvalidEmail(new CharNotAllowed(), $token->value); 226 | } 227 | return new ValidEmail(); 228 | } 229 | 230 | /** 231 | * @return Result 232 | */ 233 | protected function parseDomainLiteral(): Result 234 | { 235 | try { 236 | $this->lexer->find(EmailLexer::S_CLOSEBRACKET); 237 | } catch (\RuntimeException $e) { 238 | return new InvalidEmail(new ExpectingDomainLiteralClose(), $this->lexer->current->value); 239 | } 240 | 241 | $domainLiteralParser = new DomainLiteralParser($this->lexer); 242 | $result = $domainLiteralParser->parse(); 243 | $this->warnings = [...$this->warnings, ...$domainLiteralParser->getWarnings()]; 244 | return $result; 245 | } 246 | 247 | /** 248 | * @param Token $prev 249 | * @param bool $hasComments 250 | * 251 | * @return Result 252 | */ 253 | protected function checkDomainPartExceptions(Token $prev, bool $hasComments): Result 254 | { 255 | if ($this->lexer->current->isA(EmailLexer::S_OPENBRACKET) && $prev->type !== EmailLexer::S_AT) { 256 | return new InvalidEmail(new ExpectingATEXT('OPENBRACKET not after AT'), $this->lexer->current->value); 257 | } 258 | 259 | if ($this->lexer->current->isA(EmailLexer::S_HYPHEN) && $this->lexer->isNextToken(EmailLexer::S_DOT)) { 260 | return new InvalidEmail(new DomainHyphened('Hypen found near DOT'), $this->lexer->current->value); 261 | } 262 | 263 | if ( 264 | $this->lexer->current->isA(EmailLexer::S_BACKSLASH) 265 | && $this->lexer->isNextToken(EmailLexer::GENERIC) 266 | ) { 267 | return new InvalidEmail(new ExpectingATEXT('Escaping following "ATOM"'), $this->lexer->current->value); 268 | } 269 | 270 | return $this->validateTokens($hasComments); 271 | } 272 | 273 | protected function validateTokens(bool $hasComments): Result 274 | { 275 | $validDomainTokens = array( 276 | EmailLexer::GENERIC => true, 277 | EmailLexer::S_HYPHEN => true, 278 | EmailLexer::S_DOT => true, 279 | ); 280 | 281 | if ($hasComments) { 282 | $validDomainTokens[EmailLexer::S_OPENPARENTHESIS] = true; 283 | $validDomainTokens[EmailLexer::S_CLOSEPARENTHESIS] = true; 284 | } 285 | 286 | if (!isset($validDomainTokens[$this->lexer->current->type])) { 287 | return new InvalidEmail(new ExpectingATEXT('Invalid token in domain: ' . $this->lexer->current->value), $this->lexer->current->value); 288 | } 289 | 290 | return new ValidEmail(); 291 | } 292 | 293 | private function checkLabelLength(bool $isEndOfDomain = false): Result 294 | { 295 | if ($this->lexer->current->isA(EmailLexer::S_DOT) || $isEndOfDomain) { 296 | if ($this->isLabelTooLong($this->label)) { 297 | return new InvalidEmail(new LabelTooLong(), $this->lexer->current->value); 298 | } 299 | $this->label = ''; 300 | } 301 | $this->label .= $this->lexer->current->value; 302 | return new ValidEmail(); 303 | } 304 | 305 | 306 | private function isLabelTooLong(string $label): bool 307 | { 308 | if (preg_match('/[^\x00-\x7F]/', $label)) { 309 | idn_to_ascii($label, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46, $idnaInfo); 310 | /** @psalm-var array{errors: int, ...} $idnaInfo */ 311 | return (bool) ($idnaInfo['errors'] & IDNA_ERROR_LABEL_TOO_LONG); 312 | } 313 | return strlen($label) > self::LABEL_MAX_LENGTH; 314 | } 315 | 316 | private function addTLDWarnings(bool $isTLDMissing): void 317 | { 318 | if ($isTLDMissing) { 319 | $this->warnings[TLD::CODE] = new TLD(); 320 | } 321 | } 322 | 323 | public function domainPart(): string 324 | { 325 | return $this->domainPart; 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/Parser/DoubleQuote.php: -------------------------------------------------------------------------------- 1 | checkDQUOTE(); 20 | if ($validQuotedString->isInvalid()) { 21 | return $validQuotedString; 22 | } 23 | 24 | $special = [ 25 | EmailLexer::S_CR => true, 26 | EmailLexer::S_HTAB => true, 27 | EmailLexer::S_LF => true 28 | ]; 29 | 30 | $invalid = [ 31 | EmailLexer::C_NUL => true, 32 | EmailLexer::S_HTAB => true, 33 | EmailLexer::S_CR => true, 34 | EmailLexer::S_LF => true 35 | ]; 36 | 37 | $setSpecialsWarning = true; 38 | 39 | $this->lexer->moveNext(); 40 | 41 | while (!$this->lexer->current->isA(EmailLexer::S_DQUOTE) && !$this->lexer->current->isA(EmailLexer::S_EMPTY)) { 42 | if (isset($special[$this->lexer->current->type]) && $setSpecialsWarning) { 43 | $this->warnings[CFWSWithFWS::CODE] = new CFWSWithFWS(); 44 | $setSpecialsWarning = false; 45 | } 46 | if ($this->lexer->current->isA(EmailLexer::S_BACKSLASH) && $this->lexer->isNextToken(EmailLexer::S_DQUOTE)) { 47 | $this->lexer->moveNext(); 48 | } 49 | 50 | $this->lexer->moveNext(); 51 | 52 | if (!$this->escaped() && isset($invalid[$this->lexer->current->type])) { 53 | return new InvalidEmail(new ExpectingATEXT("Expecting ATEXT between DQUOTE"), $this->lexer->current->value); 54 | } 55 | } 56 | 57 | $prev = $this->lexer->getPrevious(); 58 | 59 | if ($prev->isA(EmailLexer::S_BACKSLASH)) { 60 | $validQuotedString = $this->checkDQUOTE(); 61 | if ($validQuotedString->isInvalid()) { 62 | return $validQuotedString; 63 | } 64 | } 65 | 66 | if (!$this->lexer->isNextToken(EmailLexer::S_AT) && !$prev->isA(EmailLexer::S_BACKSLASH)) { 67 | return new InvalidEmail(new ExpectingATEXT("Expecting ATEXT between DQUOTE"), $this->lexer->current->value); 68 | } 69 | 70 | return new ValidEmail(); 71 | } 72 | 73 | protected function checkDQUOTE(): Result 74 | { 75 | $previous = $this->lexer->getPrevious(); 76 | 77 | if ($this->lexer->isNextToken(EmailLexer::GENERIC) && $previous->isA(EmailLexer::GENERIC)) { 78 | $description = 'https://tools.ietf.org/html/rfc5322#section-3.2.4 - quoted string should be a unit'; 79 | return new InvalidEmail(new ExpectingATEXT($description), $this->lexer->current->value); 80 | } 81 | 82 | try { 83 | $this->lexer->find(EmailLexer::S_DQUOTE); 84 | } catch (\Exception $e) { 85 | return new InvalidEmail(new UnclosedQuotedString(), $this->lexer->current->value); 86 | } 87 | $this->warnings[QuotedString::CODE] = new QuotedString($previous->value, $this->lexer->current->value); 88 | 89 | return new ValidEmail(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Parser/FoldingWhiteSpace.php: -------------------------------------------------------------------------------- 1 | isFWS()) { 30 | return new ValidEmail(); 31 | } 32 | 33 | $previous = $this->lexer->getPrevious(); 34 | 35 | $resultCRLF = $this->checkCRLFInFWS(); 36 | if ($resultCRLF->isInvalid()) { 37 | return $resultCRLF; 38 | } 39 | 40 | if ($this->lexer->current->isA(EmailLexer::S_CR)) { 41 | return new InvalidEmail(new CRNoLF(), $this->lexer->current->value); 42 | } 43 | 44 | if ($this->lexer->isNextToken(EmailLexer::GENERIC) && !$previous->isA(EmailLexer::S_AT)) { 45 | return new InvalidEmail(new AtextAfterCFWS(), $this->lexer->current->value); 46 | } 47 | 48 | if ($this->lexer->current->isA(EmailLexer::S_LF) || $this->lexer->current->isA(EmailLexer::C_NUL)) { 49 | return new InvalidEmail(new ExpectingCTEXT(), $this->lexer->current->value); 50 | } 51 | 52 | if ($this->lexer->isNextToken(EmailLexer::S_AT) || $previous->isA(EmailLexer::S_AT)) { 53 | $this->warnings[CFWSNearAt::CODE] = new CFWSNearAt(); 54 | } else { 55 | $this->warnings[CFWSWithFWS::CODE] = new CFWSWithFWS(); 56 | } 57 | 58 | return new ValidEmail(); 59 | } 60 | 61 | protected function checkCRLFInFWS(): Result 62 | { 63 | if (!$this->lexer->current->isA(EmailLexer::CRLF)) { 64 | return new ValidEmail(); 65 | } 66 | 67 | if (!$this->lexer->isNextTokenAny(array(EmailLexer::S_SP, EmailLexer::S_HTAB))) { 68 | return new InvalidEmail(new CRLFX2(), $this->lexer->current->value); 69 | } 70 | 71 | //this has no coverage. Condition is repeated from above one 72 | if (!$this->lexer->isNextTokenAny(array(EmailLexer::S_SP, EmailLexer::S_HTAB))) { 73 | return new InvalidEmail(new CRLFAtTheEnd(), $this->lexer->current->value); 74 | } 75 | 76 | return new ValidEmail(); 77 | } 78 | 79 | protected function isFWS(): bool 80 | { 81 | if ($this->escaped()) { 82 | return false; 83 | } 84 | 85 | return in_array($this->lexer->current->type, self::FWS_TYPES); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Parser/IDLeftPart.php: -------------------------------------------------------------------------------- 1 | lexer->current->value); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Parser/IDRightPart.php: -------------------------------------------------------------------------------- 1 | true, 17 | EmailLexer::S_SQUOTE => true, 18 | EmailLexer::S_BACKTICK => true, 19 | EmailLexer::S_SEMICOLON => true, 20 | EmailLexer::S_GREATERTHAN => true, 21 | EmailLexer::S_LOWERTHAN => true, 22 | ]; 23 | 24 | if (isset($invalidDomainTokens[$this->lexer->current->type])) { 25 | return new InvalidEmail(new ExpectingATEXT('Invalid token in domain: ' . $this->lexer->current->value), $this->lexer->current->value); 26 | } 27 | return new ValidEmail(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Parser/LocalPart.php: -------------------------------------------------------------------------------- 1 | EmailLexer::S_COMMA, 20 | EmailLexer::S_CLOSEBRACKET => EmailLexer::S_CLOSEBRACKET, 21 | EmailLexer::S_OPENBRACKET => EmailLexer::S_OPENBRACKET, 22 | EmailLexer::S_GREATERTHAN => EmailLexer::S_GREATERTHAN, 23 | EmailLexer::S_LOWERTHAN => EmailLexer::S_LOWERTHAN, 24 | EmailLexer::S_COLON => EmailLexer::S_COLON, 25 | EmailLexer::S_SEMICOLON => EmailLexer::S_SEMICOLON, 26 | EmailLexer::INVALID => EmailLexer::INVALID 27 | ]; 28 | 29 | /** 30 | * @var string 31 | */ 32 | private $localPart = ''; 33 | 34 | 35 | public function parse(): Result 36 | { 37 | $this->lexer->clearRecorded(); 38 | $this->lexer->startRecording(); 39 | 40 | while (!$this->lexer->current->isA(EmailLexer::S_AT) && !$this->lexer->current->isA(EmailLexer::S_EMPTY)) { 41 | if ($this->hasDotAtStart()) { 42 | return new InvalidEmail(new DotAtStart(), $this->lexer->current->value); 43 | } 44 | 45 | if ($this->lexer->current->isA(EmailLexer::S_DQUOTE)) { 46 | $dquoteParsingResult = $this->parseDoubleQuote(); 47 | 48 | //Invalid double quote parsing 49 | if ($dquoteParsingResult->isInvalid()) { 50 | return $dquoteParsingResult; 51 | } 52 | } 53 | 54 | if ( 55 | $this->lexer->current->isA(EmailLexer::S_OPENPARENTHESIS) || 56 | $this->lexer->current->isA(EmailLexer::S_CLOSEPARENTHESIS) 57 | ) { 58 | $commentsResult = $this->parseComments(); 59 | 60 | //Invalid comment parsing 61 | if ($commentsResult->isInvalid()) { 62 | return $commentsResult; 63 | } 64 | } 65 | 66 | if ($this->lexer->current->isA(EmailLexer::S_DOT) && $this->lexer->isNextToken(EmailLexer::S_DOT)) { 67 | return new InvalidEmail(new ConsecutiveDot(), $this->lexer->current->value); 68 | } 69 | 70 | if ( 71 | $this->lexer->current->isA(EmailLexer::S_DOT) && 72 | $this->lexer->isNextToken(EmailLexer::S_AT) 73 | ) { 74 | return new InvalidEmail(new DotAtEnd(), $this->lexer->current->value); 75 | } 76 | 77 | $resultEscaping = $this->validateEscaping(); 78 | if ($resultEscaping->isInvalid()) { 79 | return $resultEscaping; 80 | } 81 | 82 | $resultToken = $this->validateTokens(false); 83 | if ($resultToken->isInvalid()) { 84 | return $resultToken; 85 | } 86 | 87 | $resultFWS = $this->parseLocalFWS(); 88 | if ($resultFWS->isInvalid()) { 89 | return $resultFWS; 90 | } 91 | 92 | $this->lexer->moveNext(); 93 | } 94 | 95 | $this->lexer->stopRecording(); 96 | $this->localPart = rtrim($this->lexer->getAccumulatedValues(), '@'); 97 | if (strlen($this->localPart) > LocalTooLong::LOCAL_PART_LENGTH) { 98 | $this->warnings[LocalTooLong::CODE] = new LocalTooLong(); 99 | } 100 | 101 | return new ValidEmail(); 102 | } 103 | 104 | protected function validateTokens(bool $hasComments): Result 105 | { 106 | if (isset(self::INVALID_TOKENS[$this->lexer->current->type])) { 107 | return new InvalidEmail(new ExpectingATEXT('Invalid token found'), $this->lexer->current->value); 108 | } 109 | return new ValidEmail(); 110 | } 111 | 112 | public function localPart(): string 113 | { 114 | return $this->localPart; 115 | } 116 | 117 | private function parseLocalFWS(): Result 118 | { 119 | $foldingWS = new FoldingWhiteSpace($this->lexer); 120 | $resultFWS = $foldingWS->parse(); 121 | if ($resultFWS->isValid()) { 122 | $this->warnings = [...$this->warnings, ...$foldingWS->getWarnings()]; 123 | } 124 | return $resultFWS; 125 | } 126 | 127 | private function hasDotAtStart(): bool 128 | { 129 | return $this->lexer->current->isA(EmailLexer::S_DOT) && $this->lexer->getPrevious()->isA(EmailLexer::S_EMPTY); 130 | } 131 | 132 | private function parseDoubleQuote(): Result 133 | { 134 | $dquoteParser = new DoubleQuote($this->lexer); 135 | $parseAgain = $dquoteParser->parse(); 136 | $this->warnings = [...$this->warnings, ...$dquoteParser->getWarnings()]; 137 | 138 | return $parseAgain; 139 | } 140 | 141 | protected function parseComments(): Result 142 | { 143 | $commentParser = new Comment($this->lexer, new LocalComment()); 144 | $result = $commentParser->parse(); 145 | $this->warnings = [...$this->warnings, ...$commentParser->getWarnings()]; 146 | 147 | return $result; 148 | } 149 | 150 | private function validateEscaping(): Result 151 | { 152 | //Backslash found 153 | if (!$this->lexer->current->isA(EmailLexer::S_BACKSLASH)) { 154 | return new ValidEmail(); 155 | } 156 | 157 | if ($this->lexer->isNextToken(EmailLexer::GENERIC)) { 158 | return new InvalidEmail(new ExpectingATEXT('Found ATOM after escaping'), $this->lexer->current->value); 159 | } 160 | 161 | return new ValidEmail(); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Parser/PartParser.php: -------------------------------------------------------------------------------- 1 | lexer = $lexer; 27 | } 28 | 29 | abstract public function parse(): Result; 30 | 31 | /** 32 | * @return Warning[] 33 | */ 34 | public function getWarnings() 35 | { 36 | return $this->warnings; 37 | } 38 | 39 | protected function parseFWS(): Result 40 | { 41 | $foldingWS = new FoldingWhiteSpace($this->lexer); 42 | $resultFWS = $foldingWS->parse(); 43 | $this->warnings = [...$this->warnings, ...$foldingWS->getWarnings()]; 44 | return $resultFWS; 45 | } 46 | 47 | protected function checkConsecutiveDots(): Result 48 | { 49 | if ($this->lexer->current->isA(EmailLexer::S_DOT) && $this->lexer->isNextToken(EmailLexer::S_DOT)) { 50 | return new InvalidEmail(new ConsecutiveDot(), $this->lexer->current->value); 51 | } 52 | 53 | return new ValidEmail(); 54 | } 55 | 56 | protected function escaped(): bool 57 | { 58 | $previous = $this->lexer->getPrevious(); 59 | 60 | return $previous->isA(EmailLexer::S_BACKSLASH) 61 | && !$this->lexer->current->isA(EmailLexer::GENERIC); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Result/InvalidEmail.php: -------------------------------------------------------------------------------- 1 | token = $token; 22 | $this->reason = $reason; 23 | } 24 | 25 | public function isValid(): bool 26 | { 27 | return false; 28 | } 29 | 30 | public function isInvalid(): bool 31 | { 32 | return true; 33 | } 34 | 35 | public function description(): string 36 | { 37 | return $this->reason->description() . " in char " . $this->token; 38 | } 39 | 40 | public function code(): int 41 | { 42 | return $this->reason->code(); 43 | } 44 | 45 | public function reason(): Reason 46 | { 47 | return $this->reason; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Result/MultipleErrors.php: -------------------------------------------------------------------------------- 1 | reasons[$reason->code()] = $reason; 25 | } 26 | 27 | /** 28 | * @return Reason[] 29 | */ 30 | public function getReasons() : array 31 | { 32 | return $this->reasons; 33 | } 34 | 35 | public function reason() : Reason 36 | { 37 | return 0 !== count($this->reasons) 38 | ? current($this->reasons) 39 | : new EmptyReason(); 40 | } 41 | 42 | public function description() : string 43 | { 44 | $description = ''; 45 | foreach($this->reasons as $reason) { 46 | $description .= $reason->description() . PHP_EOL; 47 | } 48 | 49 | return $description; 50 | } 51 | 52 | public function code() : int 53 | { 54 | return 0; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Result/Reason/AtextAfterCFWS.php: -------------------------------------------------------------------------------- 1 | detailedDescription = $details; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Result/Reason/DomainAcceptsNoMail.php: -------------------------------------------------------------------------------- 1 | exception = $exception; 15 | 16 | } 17 | public function code() : int 18 | { 19 | return 999; 20 | } 21 | 22 | public function description() : string 23 | { 24 | return $this->exception->getMessage(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Result/Reason/ExpectingATEXT.php: -------------------------------------------------------------------------------- 1 | detailedDescription; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Result/Reason/ExpectingCTEXT.php: -------------------------------------------------------------------------------- 1 | element = $element; 15 | } 16 | 17 | public function code() : int 18 | { 19 | return 201; 20 | } 21 | 22 | public function description() : string 23 | { 24 | return 'Unusual element found, wourld render invalid in majority of cases. Element found: ' . $this->element; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Result/Result.php: -------------------------------------------------------------------------------- 1 | reason = new ReasonSpoofEmail(); 11 | parent::__construct($this->reason, ''); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Result/ValidEmail.php: -------------------------------------------------------------------------------- 1 | dnsGetRecord = $dnsGetRecord; 73 | } 74 | 75 | public function isValid(string $email, EmailLexer $emailLexer): bool 76 | { 77 | // use the input to check DNS if we cannot extract something similar to a domain 78 | $host = $email; 79 | 80 | // Arguable pattern to extract the domain. Not aiming to validate the domain nor the email 81 | if (false !== $lastAtPos = strrpos($email, '@')) { 82 | $host = substr($email, $lastAtPos + 1); 83 | } 84 | 85 | // Get the domain parts 86 | $hostParts = explode('.', $host); 87 | 88 | $isLocalDomain = count($hostParts) <= 1; 89 | $isReservedTopLevel = in_array($hostParts[(count($hostParts) - 1)], self::RESERVED_DNS_TOP_LEVEL_NAMES, true); 90 | 91 | // Exclude reserved top level DNS names 92 | if ($isLocalDomain || $isReservedTopLevel) { 93 | $this->error = new InvalidEmail(new LocalOrReservedDomain(), $host); 94 | return false; 95 | } 96 | 97 | return $this->checkDns($host); 98 | } 99 | 100 | public function getError(): ?InvalidEmail 101 | { 102 | return $this->error; 103 | } 104 | 105 | /** 106 | * @return Warning[] 107 | */ 108 | public function getWarnings(): array 109 | { 110 | return $this->warnings; 111 | } 112 | 113 | /** 114 | * @param string $host 115 | * 116 | * @return bool 117 | */ 118 | protected function checkDns($host) 119 | { 120 | $variant = INTL_IDNA_VARIANT_UTS46; 121 | 122 | $host = rtrim(idn_to_ascii($host, IDNA_DEFAULT, $variant), '.'); 123 | 124 | $hostParts = explode('.', $host); 125 | $host = array_pop($hostParts); 126 | 127 | while (count($hostParts) > 0) { 128 | $host = array_pop($hostParts) . '.' . $host; 129 | 130 | if ($this->validateDnsRecords($host)) { 131 | return true; 132 | } 133 | } 134 | 135 | return false; 136 | } 137 | 138 | 139 | /** 140 | * Validate the DNS records for given host. 141 | * 142 | * @param string $host A set of DNS records in the format returned by dns_get_record. 143 | * 144 | * @return bool True on success. 145 | */ 146 | private function validateDnsRecords($host): bool 147 | { 148 | $dnsRecordsResult = $this->dnsGetRecord->getRecords($host, DNS_A + DNS_MX); 149 | 150 | if ($dnsRecordsResult->withError()) { 151 | $this->error = new InvalidEmail(new UnableToGetDNSRecord(), ''); 152 | return false; 153 | } 154 | 155 | $dnsRecords = $dnsRecordsResult->getRecords(); 156 | 157 | // Combined check for A+MX+AAAA can fail with SERVFAIL, even in the presence of valid A/MX records 158 | $aaaaRecordsResult = $this->dnsGetRecord->getRecords($host, DNS_AAAA); 159 | 160 | if (! $aaaaRecordsResult->withError()) { 161 | $dnsRecords = array_merge($dnsRecords, $aaaaRecordsResult->getRecords()); 162 | } 163 | 164 | // No MX, A or AAAA DNS records 165 | if ($dnsRecords === []) { 166 | $this->error = new InvalidEmail(new ReasonNoDNSRecord(), ''); 167 | return false; 168 | } 169 | 170 | // For each DNS record 171 | foreach ($dnsRecords as $dnsRecord) { 172 | if (!$this->validateMXRecord($dnsRecord)) { 173 | // No MX records (fallback to A or AAAA records) 174 | if (empty($this->mxRecords)) { 175 | $this->warnings[NoDNSMXRecord::CODE] = new NoDNSMXRecord(); 176 | } 177 | return false; 178 | } 179 | } 180 | return true; 181 | } 182 | 183 | /** 184 | * Validate an MX record 185 | * 186 | * @param array $dnsRecord Given DNS record. 187 | * 188 | * @return bool True if valid. 189 | */ 190 | private function validateMxRecord($dnsRecord): bool 191 | { 192 | if (!isset($dnsRecord['type'])) { 193 | $this->error = new InvalidEmail(new ReasonNoDNSRecord(), ''); 194 | return false; 195 | } 196 | 197 | if ($dnsRecord['type'] !== 'MX') { 198 | return true; 199 | } 200 | 201 | // "Null MX" record indicates the domain accepts no mail (https://tools.ietf.org/html/rfc7505) 202 | if (empty($dnsRecord['target']) || $dnsRecord['target'] === '.') { 203 | $this->error = new InvalidEmail(new DomainAcceptsNoMail(), ""); 204 | return false; 205 | } 206 | 207 | $this->mxRecords[] = $dnsRecord; 208 | 209 | return true; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Validation/DNSGetRecordWrapper.php: -------------------------------------------------------------------------------- 1 | > $records 9 | * @param bool $error 10 | */ 11 | public function __construct(private readonly array $records, private readonly bool $error = false) 12 | { 13 | } 14 | 15 | /** 16 | * @return list> 17 | */ 18 | public function getRecords(): array 19 | { 20 | return $this->records; 21 | } 22 | 23 | public function withError(): bool 24 | { 25 | return $this->error; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Validation/EmailValidation.php: -------------------------------------------------------------------------------- 1 | setChecks(Spoofchecker::SINGLE_SCRIPT); 29 | 30 | if ($checker->isSuspicious($email)) { 31 | $this->error = new SpoofEmail(); 32 | } 33 | 34 | return $this->error === null; 35 | } 36 | 37 | public function getError() : ?InvalidEmail 38 | { 39 | return $this->error; 40 | } 41 | 42 | public function getWarnings() : array 43 | { 44 | return []; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Validation/MessageIDValidation.php: -------------------------------------------------------------------------------- 1 | parse($email); 29 | $this->warnings = $parser->getWarnings(); 30 | if ($result->isInvalid()) { 31 | /** @psalm-suppress PropertyTypeCoercion */ 32 | $this->error = $result; 33 | return false; 34 | } 35 | } catch (\Exception $invalid) { 36 | $this->error = new InvalidEmail(new ExceptionFound($invalid), ''); 37 | return false; 38 | } 39 | 40 | return true; 41 | } 42 | 43 | /** 44 | * @return Warning[] 45 | */ 46 | public function getWarnings(): array 47 | { 48 | return $this->warnings; 49 | } 50 | 51 | public function getError(): ?InvalidEmail 52 | { 53 | return $this->error; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Validation/MultipleValidationWithAnd.php: -------------------------------------------------------------------------------- 1 | validations as $validation) { 53 | $emailLexer->reset(); 54 | $validationResult = $validation->isValid($email, $emailLexer); 55 | $result = $result && $validationResult; 56 | $this->warnings = [...$this->warnings, ...$validation->getWarnings()]; 57 | if (!$validationResult) { 58 | $this->processError($validation); 59 | } 60 | 61 | if ($this->shouldStop($result)) { 62 | break; 63 | } 64 | } 65 | 66 | return $result; 67 | } 68 | 69 | private function initErrorStorage(): void 70 | { 71 | if (null === $this->error) { 72 | $this->error = new MultipleErrors(); 73 | } 74 | } 75 | 76 | private function processError(EmailValidation $validation): void 77 | { 78 | if (null !== $validation->getError()) { 79 | $this->initErrorStorage(); 80 | /** @psalm-suppress PossiblyNullReference */ 81 | $this->error->addReason($validation->getError()->reason()); 82 | } 83 | } 84 | 85 | private function shouldStop(bool $result): bool 86 | { 87 | return !$result && $this->mode === self::STOP_ON_ERROR; 88 | } 89 | 90 | /** 91 | * Returns the validation errors. 92 | */ 93 | public function getError(): ?InvalidEmail 94 | { 95 | return $this->error; 96 | } 97 | 98 | /** 99 | * @return Warning[] 100 | */ 101 | public function getWarnings(): array 102 | { 103 | return $this->warnings; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Validation/NoRFCWarningsValidation.php: -------------------------------------------------------------------------------- 1 | getWarnings())) { 26 | return true; 27 | } 28 | 29 | $this->error = new InvalidEmail(new RFCWarnings(), ''); 30 | 31 | return false; 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function getError() : ?InvalidEmail 38 | { 39 | return $this->error ?: parent::getError(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Validation/RFCValidation.php: -------------------------------------------------------------------------------- 1 | parse($email); 28 | $this->warnings = $parser->getWarnings(); 29 | if ($result->isInvalid()) { 30 | /** @psalm-suppress PropertyTypeCoercion */ 31 | $this->error = $result; 32 | return false; 33 | } 34 | } catch (\Exception $invalid) { 35 | $this->error = new InvalidEmail(new ExceptionFound($invalid), ''); 36 | return false; 37 | } 38 | 39 | return true; 40 | } 41 | 42 | public function getError(): ?InvalidEmail 43 | { 44 | return $this->error; 45 | } 46 | 47 | /** 48 | * @return Warning[] 49 | */ 50 | public function getWarnings(): array 51 | { 52 | return $this->warnings; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Warning/AddressLiteral.php: -------------------------------------------------------------------------------- 1 | message = 'Address literal in domain part'; 12 | $this->rfcNumber = 5321; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/CFWSNearAt.php: -------------------------------------------------------------------------------- 1 | message = "Deprecated folding white space near @"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Warning/CFWSWithFWS.php: -------------------------------------------------------------------------------- 1 | message = 'Folding whites space followed by folding white space'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Warning/Comment.php: -------------------------------------------------------------------------------- 1 | message = "Comments found in this email"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Warning/DeprecatedComment.php: -------------------------------------------------------------------------------- 1 | message = 'Deprecated comments'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Warning/DomainLiteral.php: -------------------------------------------------------------------------------- 1 | message = 'Domain Literal'; 12 | $this->rfcNumber = 5322; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/EmailTooLong.php: -------------------------------------------------------------------------------- 1 | message = 'Email is too long, exceeds ' . EmailParser::EMAIL_MAX_LENGTH; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Warning/IPV6BadChar.php: -------------------------------------------------------------------------------- 1 | message = 'Bad char in IPV6 domain literal'; 12 | $this->rfcNumber = 5322; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/IPV6ColonEnd.php: -------------------------------------------------------------------------------- 1 | message = ':: found at the end of the domain literal'; 12 | $this->rfcNumber = 5322; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/IPV6ColonStart.php: -------------------------------------------------------------------------------- 1 | message = ':: found at the start of the domain literal'; 12 | $this->rfcNumber = 5322; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/IPV6Deprecated.php: -------------------------------------------------------------------------------- 1 | message = 'Deprecated form of IPV6'; 12 | $this->rfcNumber = 5321; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/IPV6DoubleColon.php: -------------------------------------------------------------------------------- 1 | message = 'Double colon found after IPV6 tag'; 12 | $this->rfcNumber = 5322; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/IPV6GroupCount.php: -------------------------------------------------------------------------------- 1 | message = 'Group count is not IPV6 valid'; 12 | $this->rfcNumber = 5322; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/IPV6MaxGroups.php: -------------------------------------------------------------------------------- 1 | message = 'Reached the maximum number of IPV6 groups allowed'; 12 | $this->rfcNumber = 5321; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/LocalTooLong.php: -------------------------------------------------------------------------------- 1 | message = 'Local part is too long, exceeds 64 chars (octets)'; 13 | $this->rfcNumber = 5322; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Warning/NoDNSMXRecord.php: -------------------------------------------------------------------------------- 1 | message = 'No MX DSN record was found for this email'; 12 | $this->rfcNumber = 5321; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/ObsoleteDTEXT.php: -------------------------------------------------------------------------------- 1 | rfcNumber = 5322; 12 | $this->message = 'Obsolete DTEXT in domain literal'; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Warning/QuotedPart.php: -------------------------------------------------------------------------------- 1 | name; 19 | } 20 | 21 | if ($postToken instanceof UnitEnum) { 22 | $postToken = $postToken->name; 23 | } 24 | 25 | $this->message = "Deprecated Quoted String found between $prevToken and $postToken"; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Warning/QuotedString.php: -------------------------------------------------------------------------------- 1 | message = "Quoted String found between $prevToken and $postToken"; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Warning/TLD.php: -------------------------------------------------------------------------------- 1 | message = "RFC5321, TLD"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Warning/Warning.php: -------------------------------------------------------------------------------- 1 | message; 28 | } 29 | 30 | /** 31 | * @return int 32 | */ 33 | public function code() 34 | { 35 | return self::CODE; 36 | } 37 | 38 | /** 39 | * @return int 40 | */ 41 | public function RFCNumber() 42 | { 43 | return $this->rfcNumber; 44 | } 45 | 46 | /** 47 | * @return string 48 | */ 49 | public function __toString(): string 50 | { 51 | return $this->message() . " rfc: " . $this->rfcNumber . "internal code: " . static::CODE; 52 | } 53 | } 54 | --------------------------------------------------------------------------------