From 92afb1e991461a6a26ff06fa9b0e5c9aea7d5008 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Wed, 19 Aug 2026 23:17:17 -0700 Subject: [PATCH 01/11] refactor(parse): introduce ParseContext accumulator object Replace the ~24-key $emailAddress accumulator array threaded through parse() and its validation helpers with a typed ParseContext object. Property names mirror the former array keys so the change is a pure mechanical conversion with no behaviour change. A fresh ParseContext is created per parse() call and reset per address via resetAddress(); it is never stored on the Parse instance, preserving reentrancy across a localPartNormalizer callback. Type the addAddress() parameters (array/ParseContext/int) and drop the always-true isset() guard on the non-nullable domain property; refresh the PHPStan and Psalm baselines to drop the now-obsolete array-shape entries. --- phpstan-baseline.neon | 36 -- psalm-baseline.xml | 24 -- src/Parse.php | 777 ++++++++++++++++++++---------------------- src/ParseContext.php | 134 ++++++++ 4 files changed, 499 insertions(+), 472 deletions(-) create mode 100644 src/ParseContext.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 03204ec..4e2103a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -12,48 +12,12 @@ parameters: count: 1 path: src/Parse.php - - - message: '#^Method Email\\Parse\:\:addAddress\(\) has parameter \$emailAddress with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/Parse.php - - - - message: '#^Method Email\\Parse\:\:addAddress\(\) has parameter \$emailAddresses with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/Parse.php - - - - message: '#^Method Email\\Parse\:\:addAddress\(\) has parameter \$i with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/Parse.php - - - - message: '#^Method Email\\Parse\:\:buildEmailAddressArray\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Parse.php - - - - message: '#^Method Email\\Parse\:\:handleQuote\(\) has parameter \$emailAddress with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Parse.php - - message: '#^Method Email\\Parse\:\:parse\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Parse.php - - - message: '#^Method Email\\Parse\:\:validateLocalPart\(\) has parameter \$emailAddress with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Parse.php - - message: '#^Strict comparison using \=\=\= between 4 and 7 will always evaluate to false\.$#' identifier: identical.alwaysFalse diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 165f984..231599f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -6,30 +6,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Parse.php b/src/Parse.php index b61b2ac..925854f 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -274,8 +274,9 @@ public function parse(string $emails, bool $multiple = true, string $encoding = { $emailAddresses = []; - // Variables to be used during email address collection - $emailAddress = $this->buildEmailAddressArray(); + // Per-parse accumulator. A fresh instance (never an instance property) + // keeps parse() reentrant across a localPartNormalizer callback. + $emailAddress = new ParseContext(); $success = true; $reason = null; @@ -321,7 +322,7 @@ public function parse(string $emails, bool $multiple = true, string $encoding = if ($multiple && ($isWhitespaceSeparator || isset($separators[$curChar]))) { $state = self::STATE_END_ADDRESS; } else { - $emailAddress['original_address'] .= $curChar; + $emailAddress->original_address .= $curChar; } break; @@ -332,12 +333,12 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } else { $state = self::STATE_ADDRESS; if ('"' == $curChar) { - $emailAddress['original_address'] .= $curChar; + $emailAddress->original_address .= $curChar; $state = self::STATE_QUOTE; break; } elseif ('(' == $curChar) { - $emailAddress['original_address'] .= $curChar; + $emailAddress->original_address .= $curChar; $state = self::STATE_COMMENT; // A leading comment opens at nest level 1 (matches the // STATE_ADDRESS entry); without this an unbalanced nested @@ -351,29 +352,29 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // no break case self::STATE_ADDRESS: if (!isset($separators[$curChar]) || !$multiple) { - $emailAddress['original_address'] .= $curChar; + $emailAddress->original_address .= $curChar; } - if ($emailAddress['after_closing_quote']) { - $emailAddress['after_closing_quote'] = false; + if ($emailAddress->after_closing_quote) { + $emailAddress->after_closing_quote = false; // RFC 5322 §3.2.4: a quoted-string is a whole word. Only a dot // (obs word.word), '@', angle brackets, CFWS, or a separator may // follow it — atext or a second quote directly abutting it is invalid. if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; - $emailAddress['invalid_reason_code'] = Err::AtextAfterQuotedString; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; + $emailAddress->invalid_reason_code = Err::AtextAfterQuotedString; } } - if ($emailAddress['comment_after_local_atext']) { - $emailAddress['comment_after_local_atext'] = false; + if ($emailAddress->comment_after_local_atext) { + $emailAddress->comment_after_local_atext = false; // atext or a second quoted-string resuming the word after a comment. // Defer the verdict: it is only an error if this turns out to be an // addr-spec local part (resolved at '@'); in a display-name phrase // "word CFWS word" is legal and is cleared at '<'. if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $emailAddress['local_atom_split_by_comment'] = true; + $emailAddress->local_atom_split_by_comment = true; } } @@ -391,13 +392,13 @@ public function parse(string $emails, bool $multiple = true, string $encoding = break; } else { - $emailAddress['invalid'] = true; + $emailAddress->invalid = true; if ($multiple || ($i + 5) >= $len) { - $emailAddress['invalid_reason'] = 'Misplaced separator or missing "@" symbol'; - $emailAddress['invalid_reason_code'] = Err::MisplacedSeparator; + $emailAddress->invalid_reason = 'Misplaced separator or missing "@" symbol'; + $emailAddress->invalid_reason_code = Err::MisplacedSeparator; } else { - $emailAddress['invalid_reason'] = 'Separator not permitted - only one email address allowed'; - $emailAddress['invalid_reason_code'] = Err::SeparatorNotPermitted; + $emailAddress->invalid_reason = 'Separator not permitted - only one email address allowed'; + $emailAddress->invalid_reason_code = Err::SeparatorNotPermitted; } } } elseif (isset($allowedWhitespace[$curChar])) { @@ -435,23 +436,23 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Trailing CFWS of the local-part dot-atom: "local @domain". $cfwsAbsorbed = true; } elseif ( - $emailAddress['in_angle_addr'] - && $emailAddress['local_part_parsed'] === '' - && $emailAddress['address_temp'] === '' - && $emailAddress['quote_temp'] === '' + $emailAddress->in_angle_addr + && $emailAddress->local_part_parsed === '' + && $emailAddress->address_temp === '' + && $emailAddress->quote_temp === '' ) { // Leading CFWS inside angle-addr: "< local@domain>". $cfwsAbsorbed = true; } } elseif (self::STATE_DOMAIN === $subState) { - if ($emailAddress['domain'] === '' && $emailAddress['ip'] === '') { + if ($emailAddress->domain === '' && $emailAddress->ip === '') { // Leading CFWS of the domain dot-atom: "local@ domain". $cfwsAbsorbed = true; } } elseif ( self::STATE_START === $subState && '@' === $lookAheadChar - && $emailAddress['address_temp'] !== '' + && $emailAddress->address_temp !== '' ) { // Top-level addr-spec with no angle-addr: "local @domain". // The accumulated address_temp IS the local-part; absorb the @@ -466,12 +467,12 @@ public function parse(string $emails, bool $multiple = true, string $encoding = if (self::STATE_DOMAIN == $subState) { $subState = self::STATE_AFTER_DOMAIN; } elseif (self::STATE_LOCAL_PART == $subState) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Email address contains whitespace'; - $emailAddress['invalid_reason_code'] = Err::WhitespaceInAddress; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Email address contains whitespace'; + $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; } } elseif ( - $emailAddress['in_angle_addr'] + $emailAddress->in_angle_addr && self::STATE_DOMAIN == $subState && $lookAheadChar === '>' ) { @@ -499,9 +500,9 @@ public function parse(string $emails, bool $multiple = true, string $encoding = if (!$multiple) { for ($k = $i; $k < $len && isset(self::WHITESPACE[$chars[$k]]); ++$k) { if (!isset($allowedWhitespace[$chars[$k]])) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Disallowed whitespace after address'; - $emailAddress['invalid_reason_code'] = Err::WhitespaceInAddress; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Disallowed whitespace after address'; + $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; break; } @@ -512,33 +513,33 @@ public function parse(string $emails, bool $multiple = true, string $encoding = break; } else { if (self::STATE_LOCAL_PART == $subState) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Email address contains whitespace'; - $emailAddress['invalid_reason_code'] = Err::WhitespaceInAddress; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Email address contains whitespace'; + $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; } else { // Display-name phrase: absorb into name_parsed. $this->handleQuote($emailAddress); - $emailAddress['name_parsed'] .= $curChar; + $emailAddress->name_parsed .= $curChar; } } } elseif ('<' == $curChar) { // Start of the local part if (self::STATE_LOCAL_PART == $subState || self::STATE_DOMAIN == $subState) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; - $emailAddress['invalid_reason_code'] = Err::MultipleOpeningAngle; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; + $emailAddress->invalid_reason_code = Err::MultipleOpeningAngle; } else { // Here should be the start of the local part for sure everything else then is part of the name $subState = self::STATE_LOCAL_PART; - $emailAddress['special_char_in_substate'] = null; - $emailAddress['in_angle_addr'] = true; + $emailAddress->special_char_in_substate = null; + $emailAddress->in_angle_addr = true; // Any quote before `<` was the display name, not the local part; // clear the quoted flag the closing-quote handler set so the real // local-part inside the angle-addr starts unquoted. Likewise any // comment before `<` sat in the display-name phrase (legal there), // not an addr-spec local part — clear the deferred split marker. - $emailAddress['local_part_quoted'] = false; - $emailAddress['local_atom_split_by_comment'] = false; + $emailAddress->local_part_quoted = false; + $emailAddress->local_atom_split_by_comment = false; $this->handleQuote($emailAddress); } } elseif ('>' == $curChar) { @@ -549,60 +550,60 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // present, so `` / `` still fail. if (self::STATE_DOMAIN == $subState || (self::STATE_AFTER_DOMAIN == $subState - && ('' !== $emailAddress['domain'] || '' !== $emailAddress['ip']))) { + && ('' !== $emailAddress->domain || '' !== $emailAddress->ip))) { $subState = self::STATE_AFTER_DOMAIN; - $emailAddress['in_angle_addr'] = false; + $emailAddress->in_angle_addr = false; } else { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Did not find domain name before a closing '>'"; - $emailAddress['invalid_reason_code'] = Err::MissingDomainBeforeClosingAngle; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Did not find domain name before a closing '>'"; + $emailAddress->invalid_reason_code = Err::MissingDomainBeforeClosingAngle; } } elseif ('"' == $curChar) { // If we hit a quote - change to the quote state, unless it's in the domain, in which case it's error if (self::STATE_DOMAIN == $subState || self::STATE_AFTER_DOMAIN == $subState) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Quote \'"\' found where it shouldn\'t be'; - $emailAddress['invalid_reason_code'] = Err::MisplacedQuote; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Quote \'"\' found where it shouldn\'t be'; + $emailAddress->invalid_reason_code = Err::MisplacedQuote; } else { $state = self::STATE_QUOTE; } } elseif ('@' == $curChar) { // Handle '@' sign if (self::STATE_DOMAIN == $subState) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Multiple at '@' symbols in email address"; - $emailAddress['invalid_reason_code'] = Err::MultipleAtSymbols; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Multiple at '@' symbols in email address"; + $emailAddress->invalid_reason_code = Err::MultipleAtSymbols; } elseif (self::STATE_AFTER_DOMAIN == $subState) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Stray at '@' symbol found after domain name"; - $emailAddress['invalid_reason_code'] = Err::StrayAtAfterDomain; - } elseif (null !== $emailAddress['special_char_in_substate']) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Invalid character found in email address local part: '{$emailAddress['special_char_in_substate']}'"; - $emailAddress['invalid_reason_code'] = Err::InvalidCharacterInLocalPart; - } elseif ($emailAddress['local_atom_split_by_comment']) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Stray at '@' symbol found after domain name"; + $emailAddress->invalid_reason_code = Err::StrayAtAfterDomain; + } elseif (null !== $emailAddress->special_char_in_substate) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$emailAddress->special_char_in_substate}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; + } elseif ($emailAddress->local_atom_split_by_comment) { // The `@` confirms this was an addr-spec local part, so the comment // that split its atext (RFC 5322 §3.2.3) is invalid here. - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; - $emailAddress['invalid_reason_code'] = Err::AtextAfterComment; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; + $emailAddress->invalid_reason_code = Err::AtextAfterComment; } elseif ( $this->options->allowObsRoute - && $emailAddress['in_angle_addr'] - && $emailAddress['obs_route'] === '' - && $emailAddress['local_part_parsed'] === '' - && $emailAddress['quote_temp'] === '' - && $emailAddress['address_temp'] === '' + && $emailAddress->in_angle_addr + && $emailAddress->obs_route === '' + && $emailAddress->local_part_parsed === '' + && $emailAddress->quote_temp === '' + && $emailAddress->address_temp === '' // An empty *quoted* local part (`<""@host>`) is a real local // part, not the "no local part" that starts an obs-route. - && !$emailAddress['local_part_quoted'] + && !$emailAddress->local_part_quoted ) { // RFC 5322 §4.4 obs-route: first `@` seen inside `<...>` with no // preceding local-part starts the source-route prefix. Consume // the remainder until `:` via STATE_OBS_ROUTE, then resume // addr-spec parsing with local-part reset. $state = self::STATE_OBS_ROUTE; - $emailAddress['obs_route'] = '@'; + $emailAddress->obs_route = '@'; } else { $subState = self::STATE_DOMAIN; // A trailing quoted word after earlier words ("x"."y", x."y") @@ -610,21 +611,21 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // word *("." word), word = atom / quoted-string). Flush it onto // the accumulated local part, exactly as the dot handler flushes // earlier words — not a parser error. - if ($emailAddress['address_temp'] && $emailAddress['quote_temp']) { - $emailAddress['address_temp'] .= $emailAddress['quote_temp']; - $emailAddress['address_temp_quoted'] = true; - $emailAddress['quote_temp'] = ''; + if ($emailAddress->address_temp && $emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; } - if ($emailAddress['quote_temp']) { - $emailAddress['local_part_parsed'] = $emailAddress['quote_temp']; - $emailAddress['quote_temp'] = ''; - $emailAddress['local_part_quoted'] = true; - } elseif ($emailAddress['address_temp']) { - $emailAddress['local_part_parsed'] = $emailAddress['address_temp']; - $emailAddress['address_temp'] = ''; - $emailAddress['local_part_quoted'] = $emailAddress['address_temp_quoted']; - $emailAddress['address_temp_quoted'] = false; - $emailAddress['address_temp_period'] = 0; + if ($emailAddress->quote_temp) { + $emailAddress->local_part_parsed = $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->local_part_quoted = true; + } elseif ($emailAddress->address_temp) { + $emailAddress->local_part_parsed = $emailAddress->address_temp; + $emailAddress->address_temp = ''; + $emailAddress->local_part_quoted = $emailAddress->address_temp_quoted; + $emailAddress->address_temp_quoted = false; + $emailAddress->address_temp_period = 0; } } } elseif ('[' == $curChar) { @@ -634,13 +635,13 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // it mid-domain used to set both domain and ip and surface as an // internal "parser confusion" error. if (self::STATE_DOMAIN != $subState) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Invalid character '[' in email address"; - $emailAddress['invalid_reason_code'] = Err::InvalidOpeningBracket; - } elseif ('' !== $emailAddress['domain'] || '' !== $emailAddress['ip']) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; - $emailAddress['invalid_reason_code'] = Err::InvalidOpeningBracket; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character '[' in email address"; + $emailAddress->invalid_reason_code = Err::InvalidOpeningBracket; + } elseif ('' !== $emailAddress->domain || '' !== $emailAddress->ip) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; + $emailAddress->invalid_reason_code = Err::InvalidOpeningBracket; } else { $state = self::STATE_SQUARE_BRACKET; } @@ -648,110 +649,110 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Handle periods specially if ('.' == $prevChar && !$this->options->allowObsLocalPart) { // Consecutive dots only allowed when obs-local-part is enabled - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Email address should not contain two dots '.' in a row"; - $emailAddress['invalid_reason_code'] = Err::ConsecutiveDots; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Email address should not contain two dots '.' in a row"; + $emailAddress->invalid_reason_code = Err::ConsecutiveDots; } elseif (self::STATE_LOCAL_PART == $subState) { - if (!$emailAddress['local_part_parsed'] && !$this->options->allowObsLocalPart) { + if (!$emailAddress->local_part_parsed && !$this->options->allowObsLocalPart) { // Leading dots only allowed when obs-local-part is enabled - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Email address can not start with '.'"; - $emailAddress['invalid_reason_code'] = Err::LeadingDot; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Email address can not start with '.'"; + $emailAddress->invalid_reason_code = Err::LeadingDot; } else { - $emailAddress['local_part_parsed'] .= $curChar; + $emailAddress->local_part_parsed .= $curChar; } } elseif (self::STATE_DOMAIN == $subState) { - $emailAddress['domain'] .= $curChar; + $emailAddress->domain .= $curChar; } elseif (self::STATE_AFTER_DOMAIN == $subState) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Stray period '.' found after domain of email address"; - $emailAddress['invalid_reason_code'] = Err::StrayPeriodAfterDomain; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Stray period '.' found after domain of email address"; + $emailAddress->invalid_reason_code = Err::StrayPeriodAfterDomain; } elseif (self::STATE_START == $subState) { - if ($emailAddress['quote_temp']) { - $emailAddress['address_temp'] .= $emailAddress['quote_temp']; - $emailAddress['address_temp_quoted'] = true; - $emailAddress['quote_temp'] = ''; + if ($emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; } - $emailAddress['address_temp'] .= $curChar; - ++$emailAddress['address_temp_period']; + $emailAddress->address_temp .= $curChar; + ++$emailAddress->address_temp_period; } else { // RFC 5322 §3.4: a period is not an atext character and is not // valid in an unquoted display name or at the start of an address. - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; - $emailAddress['invalid_reason_code'] = Err::StrayPeriod; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; + $emailAddress->invalid_reason_code = Err::StrayPeriod; } } elseif (preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { // RFC 5322 §3.2.3: atext characters — valid in unquoted local-parts and display names if (isset($bannedChars[$curChar])) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; - $emailAddress['invalid_reason_code'] = Err::CharacterNotAllowed; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::CharacterNotAllowed; } elseif (('/' == $curChar || '|' == $curChar) && - !$emailAddress['local_part_parsed'] && !$emailAddress['address_temp'] && !$emailAddress['quote_temp'] && !$emailAddress['name_parsed']) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress['invalid_reason_code'] = Err::InvalidCharacterAtStart; + !$emailAddress->local_part_parsed && !$emailAddress->address_temp && !$emailAddress->quote_temp && !$emailAddress->name_parsed) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterAtStart; } elseif (self::STATE_LOCAL_PART == $subState) { // Legitimate character - Determine where to append based on the current 'substate' - if ($emailAddress['quote_temp']) { - $emailAddress['local_part_parsed'] .= $emailAddress['quote_temp']; - $emailAddress['quote_temp'] = ''; - $emailAddress['local_part_quoted'] = true; + if ($emailAddress->quote_temp) { + $emailAddress->local_part_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->local_part_quoted = true; } - $emailAddress['local_part_parsed'] .= $curChar; + $emailAddress->local_part_parsed .= $curChar; } elseif (self::STATE_NAME == $subState) { - if ($emailAddress['quote_temp']) { - $emailAddress['name_parsed'] .= $emailAddress['quote_temp']; - $emailAddress['quote_temp'] = ''; - $emailAddress['name_quoted'] = true; + if ($emailAddress->quote_temp) { + $emailAddress->name_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->name_quoted = true; } - $emailAddress['name_parsed'] .= $curChar; + $emailAddress->name_parsed .= $curChar; } elseif (self::STATE_DOMAIN == $subState) { - $emailAddress['domain'] .= $curChar; + $emailAddress->domain .= $curChar; } else { - if ($emailAddress['quote_temp']) { - $emailAddress['address_temp'] .= $emailAddress['quote_temp']; - $emailAddress['address_temp_quoted'] = true; - $emailAddress['quote_temp'] = ''; + if ($emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; } - $emailAddress['address_temp'] .= $curChar; + $emailAddress->address_temp .= $curChar; } } else { if (self::STATE_DOMAIN == $subState) { if ($this->isUtf8Char($curChar)) { - $emailAddress['domain'] .= $curChar; + $emailAddress->domain .= $curChar; } else { try { // Test by trying to encode the current character into Punycode // Punycode should match the traditional domain name subset of characters $punycoded = idn_to_ascii($curChar); if ($punycoded !== false && preg_match('/[a-z0-9\-]/', $punycoded)) { - $emailAddress['domain'] .= $curChar; + $emailAddress->domain .= $curChar; } else { - $emailAddress['invalid'] = true; + $emailAddress->invalid = true; } } catch (\Exception $e) { - $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$emailAddress['original_address']: {$emailAddress['original_address']}\n\$emails: {$emails}"); - $emailAddress['invalid'] = true; + $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$emailAddress->original_address: {$emailAddress->original_address}\n\$emails: {$emails}"); + $emailAddress->invalid = true; } - if ($emailAddress['invalid']) { - $emailAddress['invalid_reason'] = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress['invalid_reason_code'] = Err::InvalidCharacterInDomain; + if ($emailAddress->invalid) { + $emailAddress->invalid_reason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInDomain; } } } elseif (self::STATE_START === $subState || self::STATE_LOCAL_PART === $subState) { // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently - if ($subState === self::STATE_START && $emailAddress['quote_temp']) { - $emailAddress['address_temp'] .= $emailAddress['quote_temp']; - $emailAddress['address_temp_quoted'] = true; - $emailAddress['quote_temp'] = ''; - } elseif ($subState === self::STATE_LOCAL_PART && $emailAddress['quote_temp']) { - $emailAddress['local_part_parsed'] .= $emailAddress['quote_temp']; - $emailAddress['quote_temp'] = ''; - $emailAddress['local_part_quoted'] = true; + if ($subState === self::STATE_START && $emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; + } elseif ($subState === self::STATE_LOCAL_PART && $emailAddress->quote_temp) { + $emailAddress->local_part_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->local_part_quoted = true; } $isUtf8 = $this->isUtf8Char($curChar); @@ -759,57 +760,57 @@ public function parse(string $emails, bool $multiple = true, string $encoding = if ($isUtf8 && $this->options->allowUtf8LocalPart) { // UTF-8 character allowed if ($subState === self::STATE_START) { - $emailAddress['address_temp'] .= $curChar; + $emailAddress->address_temp .= $curChar; } else { - $emailAddress['local_part_parsed'] .= $curChar; + $emailAddress->local_part_parsed .= $curChar; } } elseif ($isUtf8) { // UTF-8 present but not allowed by rules — collect and reject in validateLocalPart() if ($subState === self::STATE_START) { - $emailAddress['address_temp'] .= $curChar; + $emailAddress->address_temp .= $curChar; // ??= preserves the first invalid character seen; later chars must not overwrite it - $emailAddress['special_char_in_substate'] ??= $curChar; + $emailAddress->special_char_in_substate ??= $curChar; } else { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Invalid character found in email address local part: '{$curChar}'"; - $emailAddress['invalid_reason_code'] = Err::InvalidCharacterInLocalPart; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; } } else { // Non-UTF-8, non-atext character if ($subState === self::STATE_START) { // ??= preserves the first invalid character seen; later chars must not overwrite it - $emailAddress['special_char_in_substate'] ??= $curChar; - $emailAddress['address_temp'] .= $curChar; + $emailAddress->special_char_in_substate ??= $curChar; + $emailAddress->address_temp .= $curChar; } else { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Invalid character found in email address local part: '{$curChar}'"; - $emailAddress['invalid_reason_code'] = Err::InvalidCharacterInLocalPart; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; } } } elseif (self::STATE_NAME === $subState) { - if ($emailAddress['quote_temp']) { - $emailAddress['name_parsed'] .= $emailAddress['quote_temp']; - $emailAddress['quote_temp'] = ''; - $emailAddress['name_quoted'] = true; + if ($emailAddress->quote_temp) { + $emailAddress->name_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->name_quoted = true; } - $emailAddress['special_char_in_substate'] = $curChar; - $emailAddress['name_parsed'] .= $curChar; + $emailAddress->special_char_in_substate = $curChar; + $emailAddress->name_parsed .= $curChar; } else { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress['invalid_reason_code'] = Err::InvalidCharacterInAddress; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInAddress; } } break; case self::STATE_SQUARE_BRACKET: // Handle square bracketed IP addresses such as [10.0.10.2] - $emailAddress['original_address'] .= $curChar; + $emailAddress->original_address .= $curChar; if (']' == $curChar) { $subState = self::STATE_AFTER_DOMAIN; $state = self::STATE_ADDRESS; } else { - $emailAddress['ip'] .= $curChar; + $emailAddress->ip .= $curChar; } break; @@ -819,26 +820,26 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // On `:` terminator, resume normal addr-spec parsing with // local-part state cleared. An unterminated obs-route // (end of input or `>` before `:`) is an invalid address. - $emailAddress['original_address'] .= $curChar; + $emailAddress->original_address .= $curChar; if (':' == $curChar) { $state = self::STATE_ADDRESS; $subState = self::STATE_LOCAL_PART; } elseif ('>' == $curChar) { // `<@host>` without a colon — incomplete obs-route. - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Incomplete obs-route: missing colon before closing angle-bracket'; - $emailAddress['invalid_reason_code'] = Err::IncompleteAddress; - $emailAddress['in_angle_addr'] = false; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Incomplete obs-route: missing colon before closing angle-bracket'; + $emailAddress->invalid_reason_code = Err::IncompleteAddress; + $emailAddress->in_angle_addr = false; $state = self::STATE_ADDRESS; $subState = self::STATE_AFTER_DOMAIN; } else { - $emailAddress['obs_route'] .= $curChar; + $emailAddress->obs_route .= $curChar; } break; case self::STATE_QUOTE: // Handle quoted strings - $emailAddress['original_address'] .= $curChar; + $emailAddress->original_address .= $curChar; if ('"' == $curChar) { // RFC 5322 §3.2.4 / RFC 5321 §4.1.2: detect escaped quote by counting // consecutive backslashes immediately before this position. An odd count @@ -854,7 +855,7 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } if ($backslashCount && 1 == $backslashCount % 2) { // Odd number of backslashes = this quote is escaped - $emailAddress['quote_temp'] .= $curChar; + $emailAddress->quote_temp .= $curChar; } else { // Even backslashes (or zero) = this is the real closing quote. // Record that a quote was seen so an *empty* quoted local-part @@ -863,38 +864,38 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // display-name quote self-corrects: the real local-part resets // this flag from address_temp_quoted when '@' is reached. $state = self::STATE_ADDRESS; - $emailAddress['local_part_quoted'] = true; - $emailAddress['after_closing_quote'] = true; + $emailAddress->local_part_quoted = true; + $emailAddress->after_closing_quote = true; } } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { // qtext (RFC 5322 §3.2.4) excludes C0 controls; a bare CR or LF // inside a quoted-string is not valid (only a CRLF fold with WSP is). - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Control character in quoted string'; - $emailAddress['invalid_reason_code'] = Err::InvalidCharInQuotedString; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Control character in quoted string'; + $emailAddress->invalid_reason_code = Err::InvalidCharInQuotedString; } else { - $emailAddress['quote_temp'] .= $curChar; + $emailAddress->quote_temp .= $curChar; } break; case self::STATE_COMMENT: // Handle comments and nesting thereof - $emailAddress['original_address'] .= $curChar; - if ($emailAddress['comment_escaped']) { + $emailAddress->original_address .= $curChar; + if ($emailAddress->comment_escaped) { // Target of a quoted-pair — literal, never structural. - $emailAddress['comment_escaped'] = false; - $emailAddress['comment_temp'] .= $curChar; + $emailAddress->comment_escaped = false; + $emailAddress->comment_temp .= $curChar; } elseif ('\\' == $curChar) { // RFC 5322 §3.2.1: backslash starts a quoted-pair; the next // character is escaped (so "\)" does not close the comment). - $emailAddress['comment_escaped'] = true; + $emailAddress->comment_escaped = true; } elseif (')' == $curChar) { --$commentNestLevel; if ($commentNestLevel <= 0) { // End of comment - save it - if ($emailAddress['comment_temp']) { - $emailAddress['comments'][] = $emailAddress['comment_temp']; - $emailAddress['comment_temp'] = ''; + if ($emailAddress->comment_temp) { + $emailAddress->comments[] = $emailAddress->comment_temp; + $emailAddress->comment_temp = ''; } $state = self::STATE_ADDRESS; // Flag a comment that closed mid-word in the local part (before @@ -903,50 +904,50 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // preceding quoted-string (local_part_quoted) — "x"(c)y is as // invalid as x(c)y. Domain and display-name comments are excluded. if ((self::STATE_LOCAL_PART === $subState || self::STATE_START === $subState) - && ('' !== $emailAddress['address_temp'] || '' !== $emailAddress['local_part_parsed'] || $emailAddress['local_part_quoted'])) { - $emailAddress['comment_after_local_atext'] = true; + && ('' !== $emailAddress->address_temp || '' !== $emailAddress->local_part_parsed || $emailAddress->local_part_quoted)) { + $emailAddress->comment_after_local_atext = true; } } else { // Nested comment closing parenthesis - $emailAddress['comment_temp'] .= $curChar; + $emailAddress->comment_temp .= $curChar; } } elseif ('(' == $curChar) { ++$commentNestLevel; if ($commentNestLevel > 1) { // Nested comment opening parenthesis - $emailAddress['comment_temp'] .= $curChar; + $emailAddress->comment_temp .= $curChar; } } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { // ctext (RFC 5322 §3.2.3) excludes C0 controls; a bare CR or LF // inside a comment is not part of valid folding. - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Control character in comment'; - $emailAddress['invalid_reason_code'] = Err::ControlCharInComment; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Control character in comment'; + $emailAddress->invalid_reason_code = Err::ControlCharInComment; } elseif ($this->options->rejectC1Controls && preg_match('/[\x{0080}-\x{009F}]/u', $curChar)) { // RFC 6532 §3.1: C1 controls (2-byte UTF-8) are prohibited in // internationalized content, comments included. - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Control character in comment'; - $emailAddress['invalid_reason_code'] = Err::ControlCharInComment; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Control character in comment'; + $emailAddress->invalid_reason_code = Err::ControlCharInComment; } else { // Regular comment character - $emailAddress['comment_temp'] .= $curChar; + $emailAddress->comment_temp .= $curChar; } break; default: // Shouldn't ever get here - what is $state? - $emailAddress['original_address'] .= $curChar; - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Error during parsing'; - $emailAddress['invalid_reason_code'] = Err::ParseError; + $emailAddress->original_address .= $curChar; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Error during parsing'; + $emailAddress->invalid_reason_code = Err::ParseError; $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$state}\n\$subState: {$subState}\n\$i: {$i}\n\$curChar: {$curChar}"); break; } - // if there's a $emailAddress['original_address'] and the state is set to STATE_END_ADDRESS - if (self::STATE_END_ADDRESS == $state && strlen($emailAddress['original_address']) > 0) { + // if there's a $emailAddress->original_address and the state is set to STATE_END_ADDRESS + if (self::STATE_END_ADDRESS == $state && strlen($emailAddress->original_address) > 0) { $invalid = $this->addAddress( $emailAddresses, $emailAddress, @@ -963,7 +964,7 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } // Reset all local variables used during parsing - $emailAddress = $this->buildEmailAddressArray(); + $emailAddress->resetAddress(); $subState = self::STATE_START; $state = self::STATE_TRIM; } @@ -973,8 +974,8 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // character — and interpolating the full $emails / original_address each time // (even under a NullLogger, the argument is still built) makes malformed input // O(n^2). See the DoS regression benchmark. - if ($emailAddress['invalid'] && self::STATE_SKIP_AHEAD !== $state) { - $this->log('debug', "Email\\Parse->parse - invalid - {$emailAddress['invalid_reason']}\n\$emailAddress['original_address'] {$emailAddress['original_address']}\n\$emails: {$emails}"); + if ($emailAddress->invalid && self::STATE_SKIP_AHEAD !== $state) { + $this->log('debug', "Email\\Parse->parse - invalid - {$emailAddress->invalid_reason}\n\$emailAddress->original_address {$emailAddress->original_address}\n\$emails: {$emails}"); $state = self::STATE_SKIP_AHEAD; } } @@ -983,20 +984,20 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // literal, or obs-route) — the construct was never closed. Keyed on the // parser state rather than quote_temp, since bracket/comment content is // buffered elsewhere (a closed delimiter always returns to STATE_ADDRESS). - if (!$emailAddress['invalid'] && in_array($state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { - $emailAddress['invalid'] = true; - [$emailAddress['invalid_reason'], $emailAddress['invalid_reason_code']] = match ($state) { + if (!$emailAddress->invalid && in_array($state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { + $emailAddress->invalid = true; + [$emailAddress->invalid_reason, $emailAddress->invalid_reason_code] = match ($state) { self::STATE_QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], self::STATE_COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], self::STATE_SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], self::STATE_OBS_ROUTE => ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress], }; } - if (!$emailAddress['invalid'] && ($emailAddress['address_temp'] || $emailAddress['quote_temp'])) { - $this->log('error', "Email\\Parse->parse - corruption during parsing - leftovers:\n\$i: {$i}\n\$emailAddress['address_temp']: {$emailAddress['address_temp']}\n\$emailAddress['quote_temp']: {$emailAddress['quote_temp']}\nEmails: {$emails}"); - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Incomplete address'; - $emailAddress['invalid_reason_code'] = Err::IncompleteAddress; + if (!$emailAddress->invalid && ($emailAddress->address_temp || $emailAddress->quote_temp)) { + $this->log('error', "Email\\Parse->parse - corruption during parsing - leftovers:\n\$i: {$i}\n\$emailAddress->address_temp: {$emailAddress->address_temp}\n\$emailAddress->quote_temp: {$emailAddress->quote_temp}\nEmails: {$emails}"); + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Incomplete address'; + $emailAddress->invalid_reason_code = Err::IncompleteAddress; if (!$success) { $reason = 'Invalid email addresses'; } else { @@ -1008,20 +1009,20 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Did we find no email addresses at all? An empty local-part only counts as // "no address" when it is unquoted; `""@domain` is a legitimately-empty quoted // local-part whose acceptance is decided later by rejectEmptyQuotedLocalPart. - if (!$emailAddress['invalid'] && !count($emailAddresses) && (!$emailAddress['original_address'] || (!$emailAddress['local_part_parsed'] && !$emailAddress['local_part_quoted']))) { + if (!$emailAddress->invalid && !count($emailAddresses) && (!$emailAddress->original_address || (!$emailAddress->local_part_parsed && !$emailAddress->local_part_quoted))) { $success = false; $reason = 'No email addresses found'; if (!$multiple) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'No email address found'; - $emailAddress['invalid_reason_code'] = Err::IncompleteAddress; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'No email address found'; + $emailAddress->invalid_reason_code = Err::IncompleteAddress; $this->addAddress( $emailAddresses, $emailAddress, $i ); } - } elseif ($emailAddress['original_address']) { + } elseif ($emailAddress->original_address) { $invalid = $this->addAddress( $emailAddresses, $emailAddress, @@ -1050,74 +1051,25 @@ public function parse(string $emails, bool $multiple = true, string $encoding = * Periods in an unquoted name are invalid per RFC 5322 §3.4 — the display * name must be a phrase, and a period is not an atext character. */ - private function handleQuote(array &$emailAddress): void + private function handleQuote(ParseContext $emailAddress): void { - if ($emailAddress['quote_temp']) { - $emailAddress['name_parsed'] .= $emailAddress['quote_temp']; - $emailAddress['name_quoted'] = true; - $emailAddress['quote_temp'] = ''; - } elseif ($emailAddress['address_temp']) { - $emailAddress['name_parsed'] .= $emailAddress['address_temp']; - $emailAddress['name_quoted'] = $emailAddress['address_temp_quoted']; - $emailAddress['address_temp_quoted'] = false; - $emailAddress['address_temp'] = ''; - if ($emailAddress['address_temp_period'] > 0) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Periods within the display name of an email address must appear in quotes, such as "John Q. Public" according to RFC 5322'; - $emailAddress['invalid_reason_code'] = Err::UnquotedPeriodInDisplayName; + if ($emailAddress->quote_temp) { + $emailAddress->name_parsed .= $emailAddress->quote_temp; + $emailAddress->name_quoted = true; + $emailAddress->quote_temp = ''; + } elseif ($emailAddress->address_temp) { + $emailAddress->name_parsed .= $emailAddress->address_temp; + $emailAddress->name_quoted = $emailAddress->address_temp_quoted; + $emailAddress->address_temp_quoted = false; + $emailAddress->address_temp = ''; + if ($emailAddress->address_temp_period > 0) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Periods within the display name of an email address must appear in quotes, such as "John Q. Public" according to RFC 5322'; + $emailAddress->invalid_reason_code = Err::UnquotedPeriodInDisplayName; } } } - /** - * Returns a fresh email address accumulator array with all fields zeroed. - * @return array - */ - private function buildEmailAddressArray(): array - { - return [ - 'original_address' => '', - 'name_parsed' => '', - 'local_part_parsed' => '', - 'domain' => '', - 'domain_ascii' => null, - 'ip' => '', - 'invalid' => false, - 'invalid_reason' => null, - 'invalid_reason_code' => null, - 'local_part_quoted' => false, - 'name_quoted' => false, - 'address_temp_quoted' => false, - // True for exactly the character after a closing quote, so atext / a - // second quote directly abutting a quoted-string can be rejected. - 'after_closing_quote' => false, - 'quote_temp' => '', - 'address_temp' => '', - 'address_temp_period' => 0, - 'special_char_in_substate' => null, - 'comment_temp' => '', - // True for the character following an unescaped backslash inside a comment - // (RFC 5322 §3.2.1 quoted-pair: "\)" and "\(" are literal, not structural). - 'comment_escaped' => false, - // True just after a comment closes mid-atom in the local part (atext already - // accumulated), so the very next character can be inspected. - 'comment_after_local_atext' => false, - // Set when atext resumes the atom after such a comment. Whether that is an - // error depends on what the token turns out to be: the local part of an - // addr-spec (resolved at '@' → reject, RFC 5322 §3.2.3) or a display-name - // phrase where "word CFWS word" is legal (resolved at '<' → clear). - 'local_atom_split_by_comment' => false, - 'comments' => [], - // True while the parser is inside angle-addr (between `<` and `>`). - // Used to gate obs-route detection per RFC 5322 §4.4. - 'in_angle_addr' => false, - // Accumulates the obs-route prefix (everything between `<` and the - // terminating `:`) when ParseOptions::$allowObsRoute is true. - // Empty string when no obs-route was seen. - 'obs_route' => '', - ]; - } - /** * Validates the accumulated email address parts and appends the result to $emailAddresses. * @@ -1125,111 +1077,112 @@ private function buildEmailAddressArray(): array * domain name format validation (RFC 5321 §4.1.2, RFC 1035 §2.3.4), local-part * content validation, FQDN requirement, and length limits (RFC 5321 §4.5.3.1). * + * @param array> $emailAddresses Result list the parsed address is appended to + * * @return bool True if the address was invalid, false if it was valid */ private function addAddress( - &$emailAddresses, - &$emailAddress, - $i + array &$emailAddresses, + ParseContext $emailAddress, + int $i ): bool { - if (!$emailAddress['invalid']) { - if (isset($emailAddress['domain']) && - (filter_var($emailAddress['domain'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false || - str_starts_with($emailAddress['domain'], 'IPv6:') || - preg_match('/^\d+\.\d+\.\d+\.\d+$/', $emailAddress['domain']))) { - $emailAddress['ip'] = $emailAddress['domain']; - $emailAddress['domain'] = ''; + if (!$emailAddress->invalid) { + if (filter_var($emailAddress->domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false || + str_starts_with($emailAddress->domain, 'IPv6:') || + preg_match('/^\d+\.\d+\.\d+\.\d+$/', $emailAddress->domain)) { + $emailAddress->ip = $emailAddress->domain; + $emailAddress->domain = ''; } - if ($emailAddress['address_temp'] || $emailAddress['quote_temp']) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Incomplete address'; - $emailAddress['invalid_reason_code'] = Err::IncompleteAddress; - $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$emailAddress['address_temp'] : {$emailAddress['address_temp']}\n\$emailAddress['quote_temp']: {$emailAddress['quote_temp']}\n"); - } elseif ($emailAddress['ip'] && $emailAddress['domain']) { + if ($emailAddress->address_temp || $emailAddress->quote_temp) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Incomplete address'; + $emailAddress->invalid_reason_code = Err::IncompleteAddress; + $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$emailAddress->address_temp : {$emailAddress->address_temp}\n\$emailAddress->quote_temp: {$emailAddress->quote_temp}\n"); + } elseif ($emailAddress->ip && $emailAddress->domain) { // Error - this should never occur - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Confusion during parsing'; - $emailAddress['invalid_reason_code'] = Err::ParserConfusion; - $this->log('error', "Email\\Parse->addAddress - both an IP address '{$emailAddress['ip']}' and a domain '{$emailAddress['domain']}' found for the email address '{$emailAddress['original_address']}'\n"); - } elseif ($emailAddress['ip']) { - if (filter_var($emailAddress['ip'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { - if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($emailAddress['ip'], FILTER_FLAG_IPV4)) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'IP address invalid: \'' . $emailAddress['ip'] . '\' does not appear to be a valid IP address in the global range'; - $emailAddress['invalid_reason_code'] = Err::IpNotInGlobalRange; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Confusion during parsing'; + $emailAddress->invalid_reason_code = Err::ParserConfusion; + $this->log('error', "Email\\Parse->addAddress - both an IP address '{$emailAddress->ip}' and a domain '{$emailAddress->domain}' found for the email address '{$emailAddress->original_address}'\n"); + } elseif ($emailAddress->ip) { + if (filter_var($emailAddress->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($emailAddress->ip, FILTER_FLAG_IPV4)) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'IP address invalid: \'' . $emailAddress->ip . '\' does not appear to be a valid IP address in the global range'; + $emailAddress->invalid_reason_code = Err::IpNotInGlobalRange; } - } elseif (str_starts_with($emailAddress['ip'], 'IPv6:')) { - $tempIp = str_replace('IPv6:', '', $emailAddress['ip']); + } elseif (str_starts_with($emailAddress->ip, 'IPv6:')) { + $tempIp = str_replace('IPv6:', '', $emailAddress->ip); if (filter_var($tempIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($tempIp, FILTER_FLAG_IPV6)) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'IP address invalid: \'' . $emailAddress['ip'] . '\' does not appear to be a valid IPv6 address in the global range'; - $emailAddress['invalid_reason_code'] = Err::Ipv6NotInGlobalRange; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'IP address invalid: \'' . $emailAddress->ip . '\' does not appear to be a valid IPv6 address in the global range'; + $emailAddress->invalid_reason_code = Err::Ipv6NotInGlobalRange; } } else { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'IP address invalid: \'' . $emailAddress['ip'] . '\' does not appear to be a valid IP address'; - $emailAddress['invalid_reason_code'] = Err::InvalidIpAddress; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'IP address invalid: \'' . $emailAddress->ip . '\' does not appear to be a valid IP address'; + $emailAddress->invalid_reason_code = Err::InvalidIpAddress; } } else { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'IP address invalid: \'' . $emailAddress['ip'] . '\' does not appear to be a valid IP address'; - $emailAddress['invalid_reason_code'] = Err::InvalidIpAddress; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'IP address invalid: \'' . $emailAddress->ip . '\' does not appear to be a valid IP address'; + $emailAddress->invalid_reason_code = Err::InvalidIpAddress; } - } elseif ($emailAddress['domain']) { + } elseif ($emailAddress->domain) { // Optional FQDN root-label dot (RFC 5321 §2.3.5 allows "example.com."). // Accepted and stripped by default; rejected when rejectTrailingDot is set. - if (str_ends_with($emailAddress['domain'], '.')) { + if (str_ends_with($emailAddress->domain, '.')) { if ($this->options->rejectTrailingDot) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Domain must not end with a trailing dot'; - $emailAddress['invalid_reason_code'] = Err::TrailingDotNotAllowed; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Domain must not end with a trailing dot'; + $emailAddress->invalid_reason_code = Err::TrailingDotNotAllowed; } else { - $emailAddress['domain'] = substr($emailAddress['domain'], 0, -1); + $emailAddress->domain = substr($emailAddress->domain, 0, -1); } } } - if (!$emailAddress['invalid'] && $emailAddress['domain']) { + if (!$emailAddress->invalid && $emailAddress->domain) { // NFC-normalize internationalized domain before punycode conversion // RFC 6531 §3.3 / RFC 5891 §5.2: U-labels must be in NFC before IDNA processing if ($this->options->applyNfcNormalization) { - $nfc = $this->normalizeUtf8($emailAddress['domain']); + $nfc = $this->normalizeUtf8($emailAddress->domain); if ($nfc !== false) { - $emailAddress['domain'] = $nfc; + $emailAddress->domain = $nfc; } } - $domainAscii = $this->normalizeDomainAscii($emailAddress['domain']); + $domainAscii = $this->normalizeDomainAscii($emailAddress->domain); if ($domainAscii === null) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Can't convert domain {$emailAddress['domain']} to punycode"; - $emailAddress['invalid_reason_code'] = Err::PunycodeConversionFailed; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Can't convert domain {$emailAddress->domain} to punycode"; + $emailAddress->invalid_reason_code = Err::PunycodeConversionFailed; } else { - if ($domainAscii !== $emailAddress['domain']) { - $emailAddress['domain_ascii'] = $domainAscii; + if ($domainAscii !== $emailAddress->domain) { + $emailAddress->domain_ascii = $domainAscii; } $result = $this->validateDomainName($domainAscii); if (!$result['valid']) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = isset($result['reason']) ? 'Domain invalid: '.$result['reason'] : 'Domain invalid for some unknown reason'; - $emailAddress['invalid_reason_code'] = $result['code'] ?? Err::DomainInvalid; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = isset($result['reason']) ? 'Domain invalid: '.$result['reason'] : 'Domain invalid for some unknown reason'; + $emailAddress->invalid_reason_code = $result['code'] ?? Err::DomainInvalid; } } } } // Prepare some of the fields needed - $emailAddress['name_parsed'] = rtrim($emailAddress['name_parsed']); - $emailAddress['original_address'] = rtrim($emailAddress['original_address']); - $name = $emailAddress['name_quoted'] ? "\"{$emailAddress['name_parsed']}\"" : $emailAddress['name_parsed']; - $localPart = $emailAddress['local_part_quoted'] ? "\"{$emailAddress['local_part_parsed']}\"" : $emailAddress['local_part_parsed']; - $domainPart = $emailAddress['ip'] ? '['.$emailAddress['ip'].']' : $emailAddress['domain']; + $emailAddress->name_parsed = rtrim($emailAddress->name_parsed); + $emailAddress->original_address = rtrim($emailAddress->original_address); + $name = $emailAddress->name_quoted ? "\"{$emailAddress->name_parsed}\"" : $emailAddress->name_parsed; + $localPart = $emailAddress->local_part_quoted ? "\"{$emailAddress->local_part_parsed}\"" : $emailAddress->local_part_parsed; + $domainPart = $emailAddress->ip ? '['.$emailAddress->ip.']' : $emailAddress->domain; - if (!$emailAddress['invalid']) { + if (!$emailAddress->invalid) { if (0 == strlen($domainPart)) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Email address needs a domain after the \'@\''; - $emailAddress['invalid_reason_code'] = Err::MissingDomain; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Email address needs a domain after the \'@\''; + $emailAddress->invalid_reason_code = Err::MissingDomain; } } @@ -1239,30 +1192,30 @@ private function addAddress( // only atext characters and whitespace. The parser's state machine already // catches unquoted periods (UnquotedPeriodInDisplayName); this check adds // rejection of non-atext bytes such as stray UTF-8 in an unquoted name. - if (!$emailAddress['invalid'] + if (!$emailAddress->invalid && $this->options->validateDisplayNamePhrase - && !$emailAddress['name_quoted'] - && $emailAddress['name_parsed'] !== '' - && !preg_match('#^[A-Za-z0-9!\#$%&\'*+\-/=?^_`{|}~ \t]+$#', $emailAddress['name_parsed']) + && !$emailAddress->name_quoted + && $emailAddress->name_parsed !== '' + && !preg_match('#^[A-Za-z0-9!\#$%&\'*+\-/=?^_`{|}~ \t]+$#', $emailAddress->name_parsed) ) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Display name '{$emailAddress['name_parsed']}' must be a quoted-string or atext-only phrase per RFC 5322 §3.2.5"; - $emailAddress['invalid_reason_code'] = Err::InvalidDisplayNamePhrase; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Display name '{$emailAddress->name_parsed}' must be a quoted-string or atext-only phrase per RFC 5322 §3.2.5"; + $emailAddress->invalid_reason_code = Err::InvalidDisplayNamePhrase; } // Unified local-part validation - if (!$emailAddress['invalid']) { + if (!$emailAddress->invalid) { $result = $this->validateLocalPart($emailAddress); if (!$result['valid']) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = $result['reason']; - $emailAddress['invalid_reason_code'] = $result['code'] ?? null; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = $result['reason']; + $emailAddress->invalid_reason_code = $result['code'] ?? null; } elseif ($result['normalized'] !== null) { // Apply NFC normalization result to the parsed local-part and re-derive display form - $emailAddress['local_part_parsed'] = $result['normalized']; - $localPart = $emailAddress['local_part_quoted'] - ? "\"{$emailAddress['local_part_parsed']}\"" - : $emailAddress['local_part_parsed']; + $emailAddress->local_part_parsed = $result['normalized']; + $localPart = $emailAddress->local_part_quoted + ? "\"{$emailAddress->local_part_parsed}\"" + : $emailAddress->local_part_parsed; } // Optional caller-supplied local-part normalizer — invoked after structural @@ -1272,66 +1225,66 @@ private function addAddress( // domain-specific canonicalization. The returned string replaces // local_part_parsed and the display form is re-derived; `original_address` // still preserves the verbatim input. - if (!$emailAddress['invalid'] && $this->options->localPartNormalizer !== null) { + if (!$emailAddress->invalid && $this->options->localPartNormalizer !== null) { $normalizer = $this->options->localPartNormalizer; - $normalized = $normalizer($emailAddress['local_part_parsed'], $emailAddress['domain']); - if ($normalized !== $emailAddress['local_part_parsed']) { - $emailAddress['local_part_parsed'] = $normalized; - $localPart = $emailAddress['local_part_quoted'] - ? "\"{$emailAddress['local_part_parsed']}\"" - : $emailAddress['local_part_parsed']; + $normalized = $normalizer($emailAddress->local_part_parsed, $emailAddress->domain); + if ($normalized !== $emailAddress->local_part_parsed) { + $emailAddress->local_part_parsed = $normalized; + $localPart = $emailAddress->local_part_quoted + ? "\"{$emailAddress->local_part_parsed}\"" + : $emailAddress->local_part_parsed; } } } // FQDN check - if (!$emailAddress['invalid'] && $this->options->requireFqdn && $emailAddress['domain']) { - $dotPos = strpos($emailAddress['domain'], '.'); - if ($dotPos === false || $dotPos === 0 || $dotPos === strlen($emailAddress['domain']) - 1) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = 'Domain must be a fully-qualified domain name'; - $emailAddress['invalid_reason_code'] = Err::FqdnRequired; + if (!$emailAddress->invalid && $this->options->requireFqdn && $emailAddress->domain) { + $dotPos = strpos($emailAddress->domain, '.'); + if ($dotPos === false || $dotPos === 0 || $dotPos === strlen($emailAddress->domain) - 1) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Domain must be a fully-qualified domain name'; + $emailAddress->invalid_reason_code = Err::FqdnRequired; } } // RFC 5321 §4.5.3.1: all limits are in octets (bytes), not characters. // For quoted local-parts the wire form adds 2 DQUOTE bytes to the length. - if (!$emailAddress['invalid'] && $this->options->enforceLengthLimits) { + if (!$emailAddress->invalid && $this->options->enforceLengthLimits) { $limits = $this->options->getLengthLimits(); // RFC 5321 §4.5.3.1.1: local-part max 64 octets (wire form includes DQUOTE for quoted strings) - $localPartWireLen = $emailAddress['local_part_quoted'] - ? strlen($emailAddress['local_part_parsed']) + 2 - : strlen($emailAddress['local_part_parsed']); + $localPartWireLen = $emailAddress->local_part_quoted + ? strlen($emailAddress->local_part_parsed) + 2 + : strlen($emailAddress->local_part_parsed); if ($localPartWireLen > $limits->maxLocalPartLength) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Email address before the '@' can not be greater than {$limits->maxLocalPartLength} octets per RFC 5321"; - $emailAddress['invalid_reason_code'] = Err::LocalPartTooLong; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Email address before the '@' can not be greater than {$limits->maxLocalPartLength} octets per RFC 5321"; + $emailAddress->invalid_reason_code = Err::LocalPartTooLong; } elseif (($localPartWireLen + 1 + strlen($domainPart)) > $limits->maxTotalLength) { - $emailAddress['invalid'] = true; - $emailAddress['invalid_reason'] = "Email addresses can not be greater than {$limits->maxTotalLength} octets per RFC 3696 EID 1690"; - $emailAddress['invalid_reason_code'] = Err::TotalLengthExceeded; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Email addresses can not be greater than {$limits->maxTotalLength} octets per RFC 3696 EID 1690"; + $emailAddress->invalid_reason_code = Err::TotalLengthExceeded; } } // Build the email address hash $emailAddrDef = ['address' => '', 'simple_address' => '', - 'original_address' => rtrim($emailAddress['original_address']), + 'original_address' => rtrim($emailAddress->original_address), 'name' => $name, - 'name_parsed' => $emailAddress['name_parsed'], + 'name_parsed' => $emailAddress->name_parsed, 'local_part' => $localPart, - 'local_part_parsed' => $emailAddress['local_part_parsed'], + 'local_part_parsed' => $emailAddress->local_part_parsed, 'domain_part' => $domainPart, - 'domain' => $emailAddress['domain'], - 'domain_ascii' => $this->options->includeDomainAscii ? ($emailAddress['domain_ascii'] ?? null) : null, - 'ip' => $emailAddress['ip'], - 'invalid' => $emailAddress['invalid'], - 'invalid_reason' => $emailAddress['invalid_reason'], - 'invalid_reason_code' => $emailAddress['invalid_reason_code'], - 'comments' => $emailAddress['comments'], - 'obs_route' => $emailAddress['obs_route'] !== '' ? $emailAddress['obs_route'] : null, - 'domain_is_suspicious' => $this->isDomainConfusable($emailAddress['domain']), ]; + 'domain' => $emailAddress->domain, + 'domain_ascii' => $this->options->includeDomainAscii ? ($emailAddress->domain_ascii ?? null) : null, + 'ip' => $emailAddress->ip, + 'invalid' => $emailAddress->invalid, + 'invalid_reason' => $emailAddress->invalid_reason, + 'invalid_reason_code' => $emailAddress->invalid_reason_code, + 'comments' => $emailAddress->comments, + 'obs_route' => $emailAddress->obs_route !== '' ? $emailAddress->obs_route : null, + 'domain_is_suspicious' => $this->isDomainConfusable($emailAddress->domain), ]; // Build the proper address by hand (has comments stripped out and should have quotes in the proper places) if (!$emailAddrDef['invalid']) { @@ -1378,14 +1331,14 @@ private function isDomainConfusable(string $domain): bool /** * Unified local-part validation based on ParseOptions rule properties. * - * @param array $emailAddress The email address array from the parser + * @param ParseContext $emailAddress The email address accumulator from the parser * @return array{valid: bool, reason: ?string, code: ?ParseErrorCode, normalized: ?string} */ - protected function validateLocalPart(array $emailAddress): array + protected function validateLocalPart(ParseContext $emailAddress): array { $opts = $this->options; - $localPart = $emailAddress['local_part_parsed']; - $quoted = $emailAddress['local_part_quoted']; + $localPart = $emailAddress->local_part_parsed; + $quoted = $emailAddress->local_part_quoted; // RFC 6531 §3.3 / RFC 6532 §3.2: gate UTF-8 presence before other checks // (allowUtf8LocalPart is false in rfc5321() and rfc5322() presets) diff --git a/src/ParseContext.php b/src/ParseContext.php new file mode 100644 index 0000000..4adc6eb --- /dev/null +++ b/src/ParseContext.php @@ -0,0 +1,134 @@ + Extracted RFC 5322 comments. */ + public array $comments = []; + + /** + * True while the parser is inside angle-addr (between `<` and `>`). + * Used to gate obs-route detection per RFC 5322 §4.4. + */ + public bool $in_angle_addr = false; + + /** + * Accumulates the obs-route prefix (everything between `<` and the + * terminating `:`) when ParseOptions::$allowObsRoute is true. + * Empty string when no obs-route was seen. + */ + public string $obs_route = ''; + + /** + * Resets every accumulator field to its initial value, reusing the instance + * for the next address in a multi-address parse (matches the historical + * "rebuild the $emailAddress array" behaviour). + */ + public function resetAddress(): void + { + $this->original_address = ''; + $this->name_parsed = ''; + $this->local_part_parsed = ''; + $this->domain = ''; + $this->domain_ascii = null; + $this->ip = ''; + $this->invalid = false; + $this->invalid_reason = null; + $this->invalid_reason_code = null; + $this->local_part_quoted = false; + $this->name_quoted = false; + $this->address_temp_quoted = false; + $this->after_closing_quote = false; + $this->quote_temp = ''; + $this->address_temp = ''; + $this->address_temp_period = 0; + $this->special_char_in_substate = null; + $this->comment_temp = ''; + $this->comment_escaped = false; + $this->comment_after_local_atext = false; + $this->local_atom_split_by_comment = false; + $this->comments = []; + $this->in_angle_addr = false; + $this->obs_route = ''; + } +} From 37a3a9511be1c794721400d12e9740dece77089f Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Wed, 19 Aug 2026 23:30:04 -0700 Subject: [PATCH 02/11] refactor(parse): extract per-state handlers from the parse() loop Decompose the ~772-line parse() state machine into a thin switch that dispatches to one handler per parser state, plus sub-handlers for the heavy STATE_ADDRESS branches (CFWS, '@', '.', atext, non-atext). parse() is now ~190 lines. Loop control (state/subState/commentNestLevel) and the hoisted input and config move onto ParseContext so the handlers read them without long parameter lists; behaviour, error codes and output are unchanged. Refresh the Psalm baseline for the state-machine narrowing false-positives that shift when the discriminant becomes a context property (PHPStan level 8 handles the mutation across calls and stays clean). --- psalm-baseline.xml | 32 +- src/Parse.php | 1348 +++++++++++++++++++++++------------------- src/ParseContext.php | 52 +- 3 files changed, 809 insertions(+), 623 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 231599f..47553ec 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -6,6 +6,20 @@ + + state]]> + + + state? + $emailAddress->original_address .= $curChar; + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Error during parsing'; + $emailAddress->invalid_reason_code = Err::ParseError; + $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$emailAddress->state}\n\$subState: {$emailAddress->subState}\n\$i: {$i}\n\$curChar: {$curChar}"); + + break;]]> + @@ -16,13 +30,21 @@ - + - - - - + state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)]]> + + + ['No closing parenthesis: \')\'', Err::UnterminatedComment]]]> + state]]> + state]]> + + ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress]]]> + + ['No ending quote: \'"\'', Err::UnterminatedQuote]]]> + + ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket]]]> diff --git a/src/Parse.php b/src/Parse.php index 925854f..29609e7 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -282,11 +282,11 @@ public function parse(string $emails, bool $multiple = true, string $encoding = $reason = null; // Current state of the parser - $state = self::STATE_TRIM; + $emailAddress->state = self::STATE_TRIM; // Current sub state (this is for when we get to the xyz@somewhere.com email address itself) - $subState = self::STATE_START; - $commentNestLevel = 0; + $emailAddress->subState = self::STATE_START; + $emailAddress->commentNestLevel = 0; // Split once into an array of characters rather than calling // mb_substr($emails, $i, 1) on every iteration. For multi-byte encodings @@ -300,8 +300,6 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } // Hoist the immutable separator/banned-char config out of the per-character loop. $separators = $this->options->getSeparators(); - $bannedChars = $this->options->getBannedChars(); - $useWhitespaceAsSeparator = $this->options->getUseWhitespaceAsSeparator(); // Whitespace treated as insignificant (folding/separators; trimmable). In // single-address mode CR and LF are excluded — a lone addr-spec has no line // endings — unless trimSingleAddressWhitespace opts back into liberal trimming. @@ -309,645 +307,67 @@ public function parse(string $emails, bool $multiple = true, string $encoding = if (!$multiple && !$this->options->trimSingleAddressWhitespace) { unset($allowedWhitespace["\r"], $allowedWhitespace["\n"]); } + + // Publish the input snapshot and hoisted config onto the context so the + // per-state handlers can read them without long parameter lists. $chars + // and $len are also kept as locals below for the tight loop counter. + $emailAddress->chars = $chars; + $emailAddress->len = $len; + $emailAddress->multiple = $multiple; + $emailAddress->emails = $emails; + $emailAddress->separators = $separators; + $emailAddress->bannedChars = $this->options->getBannedChars(); + $emailAddress->useWhitespaceAsSeparator = $this->options->getUseWhitespaceAsSeparator(); + $emailAddress->allowedWhitespace = $allowedWhitespace; + $curChar = null; for ($i = 0; $i < $len; ++$i) { $prevChar = $curChar; // Previous Character $curChar = $chars[$i]; // Current Character - switch ($state) { + switch ($emailAddress->state) { case self::STATE_SKIP_AHEAD: - // Skip ahead is set when a bad email address is encountered - // It's supposed to skip to the next delimiter and continue parsing from there - $isWhitespaceSeparator = $useWhitespaceAsSeparator && isset($allowedWhitespace[$curChar]); - - if ($multiple && ($isWhitespaceSeparator || isset($separators[$curChar]))) { - $state = self::STATE_END_ADDRESS; - } else { - $emailAddress->original_address .= $curChar; - } + $this->handleStateSkipAhead($emailAddress, $curChar); break; /* @noinspection PhpMissingBreakStatementInspection — STATE_TRIM falls through to STATE_ADDRESS */ case self::STATE_TRIM: - if (isset($allowedWhitespace[$curChar])) { + if (!$this->handleStateTrim($emailAddress, $curChar)) { break; - } else { - $state = self::STATE_ADDRESS; - if ('"' == $curChar) { - $emailAddress->original_address .= $curChar; - $state = self::STATE_QUOTE; - - break; - } elseif ('(' == $curChar) { - $emailAddress->original_address .= $curChar; - $state = self::STATE_COMMENT; - // A leading comment opens at nest level 1 (matches the - // STATE_ADDRESS entry); without this an unbalanced nested - // comment like "((x)" would appear closed after one ")". - $commentNestLevel = 1; - - break; - } - // Non-whitespace, non-special char: fall through to STATE_ADDRESS processing } - // no break + // no break — a plain character falls through to STATE_ADDRESS case self::STATE_ADDRESS: - if (!isset($separators[$curChar]) || !$multiple) { - $emailAddress->original_address .= $curChar; - } - - if ($emailAddress->after_closing_quote) { - $emailAddress->after_closing_quote = false; - // RFC 5322 §3.2.4: a quoted-string is a whole word. Only a dot - // (obs word.word), '@', angle brackets, CFWS, or a separator may - // follow it — atext or a second quote directly abutting it is invalid. - if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; - $emailAddress->invalid_reason_code = Err::AtextAfterQuotedString; - } - } - - if ($emailAddress->comment_after_local_atext) { - $emailAddress->comment_after_local_atext = false; - // atext or a second quoted-string resuming the word after a comment. - // Defer the verdict: it is only an error if this turns out to be an - // addr-spec local part (resolved at '@'); in a display-name phrase - // "word CFWS word" is legal and is cleared at '<'. - if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $emailAddress->local_atom_split_by_comment = true; - } - } - - if ('(' == $curChar) { - // Handle comment - $state = self::STATE_COMMENT; - $commentNestLevel = 1; - - break; - } elseif (isset($separators[$curChar])) { - // Handle separator (comma, semicolon, etc.) - if ($multiple && (self::STATE_DOMAIN == $subState || self::STATE_AFTER_DOMAIN == $subState)) { - // If we're already in the domain part, this should be the end of the address - $state = self::STATE_END_ADDRESS; - - break; - } else { - $emailAddress->invalid = true; - if ($multiple || ($i + 5) >= $len) { - $emailAddress->invalid_reason = 'Misplaced separator or missing "@" symbol'; - $emailAddress->invalid_reason_code = Err::MisplacedSeparator; - } else { - $emailAddress->invalid_reason = 'Separator not permitted - only one email address allowed'; - $emailAddress->invalid_reason_code = Err::SeparatorNotPermitted; - } - } - } elseif (isset($allowedWhitespace[$curChar])) { - // RFC 5322 §3.2.2 CFWS — folding whitespace. Look ahead past the - // WSP run to find the next significant character; that character - // determines which kind of CFWS this is and whether it can be - // silently absorbed or if it marks an end-of-address / error. - $foundComment = false; - $lookAheadChar = null; - for ($j = ($i + 1); $j < $len; ++$j) { - $c = $chars[$j]; - if ('(' === $c) { - $foundComment = true; - - break; - } - if (' ' !== $c && "\t" !== $c && "\r" !== $c && "\n" !== $c) { - $lookAheadChar = $c; - - break; - } - } - - // CFWS absorption: whitespace is legal per RFC 5322 §3.2.3 at - // dot-atom boundaries ("[CFWS] dot-atom-text [CFWS]") and per - // §4.4 obs-angle-addr around the angle brackets. Detect the - // position from subState + lookahead rather than emitting a - // WhitespaceInAddress error. In multi-address mode with - // strictMultiWhitespace, this obsolete internal folding is instead - // rejected per-address (whitespace still separates addresses). - $cfwsAbsorbed = false; - if (!$foundComment && $lookAheadChar !== null && !($multiple && $this->options->strictMultiWhitespace)) { - if (self::STATE_LOCAL_PART === $subState) { - if ('@' === $lookAheadChar) { - // Trailing CFWS of the local-part dot-atom: "local @domain". - $cfwsAbsorbed = true; - } elseif ( - $emailAddress->in_angle_addr - && $emailAddress->local_part_parsed === '' - && $emailAddress->address_temp === '' - && $emailAddress->quote_temp === '' - ) { - // Leading CFWS inside angle-addr: "< local@domain>". - $cfwsAbsorbed = true; - } - } elseif (self::STATE_DOMAIN === $subState) { - if ($emailAddress->domain === '' && $emailAddress->ip === '') { - // Leading CFWS of the domain dot-atom: "local@ domain". - $cfwsAbsorbed = true; - } - } elseif ( - self::STATE_START === $subState - && '@' === $lookAheadChar - && $emailAddress->address_temp !== '' - ) { - // Top-level addr-spec with no angle-addr: "local @domain". - // The accumulated address_temp IS the local-part; absorb the - // whitespace as trailing CFWS before the `@`. - $cfwsAbsorbed = true; - } - } - - if ($cfwsAbsorbed) { - // Silently skip the whitespace character; state unchanged. - } elseif ($foundComment) { - if (self::STATE_DOMAIN == $subState) { - $subState = self::STATE_AFTER_DOMAIN; - } elseif (self::STATE_LOCAL_PART == $subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Email address contains whitespace'; - $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; - } - } elseif ( - $emailAddress->in_angle_addr - && self::STATE_DOMAIN == $subState - && $lookAheadChar === '>' - ) { - // Trailing CFWS inside angle-addr before `>`: "". - // Absorb and transition as if we saw `>` next. - $subState = self::STATE_AFTER_DOMAIN; - } elseif ( - $multiple - && $lookAheadChar !== null - && isset($separators[$lookAheadChar]) - && (self::STATE_DOMAIN == $subState || self::STATE_AFTER_DOMAIN == $subState) - ) { - // Whitespace between the domain and a following separator - // ("a@b.com , c@d.com"): absorb it and let the separator terminate - // the address, rather than ending here and leaving the separator to - // open an empty next address (a "misplaced separator" error). - $subState = self::STATE_AFTER_DOMAIN; - } elseif ($useWhitespaceAsSeparator && - (self::STATE_DOMAIN == $subState || self::STATE_AFTER_DOMAIN == $subState)) { - // Already past `@` and whitespace-as-separator: end address. - // Single mode has no next address to separate; if the trailing - // whitespace run contains a whitespace char excluded from the - // effective set (e.g. CR/LF in strict single mode), that is - // invalid trailing content — a dangling fold — not a terminator. - if (!$multiple) { - for ($k = $i; $k < $len && isset(self::WHITESPACE[$chars[$k]]); ++$k) { - if (!isset($allowedWhitespace[$chars[$k]])) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Disallowed whitespace after address'; - $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; - - break; - } - } - } - $state = self::STATE_END_ADDRESS; - - break; - } else { - if (self::STATE_LOCAL_PART == $subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Email address contains whitespace'; - $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; - } else { - // Display-name phrase: absorb into name_parsed. - $this->handleQuote($emailAddress); - $emailAddress->name_parsed .= $curChar; - } - } - } elseif ('<' == $curChar) { - // Start of the local part - if (self::STATE_LOCAL_PART == $subState || self::STATE_DOMAIN == $subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; - $emailAddress->invalid_reason_code = Err::MultipleOpeningAngle; - } else { - // Here should be the start of the local part for sure everything else then is part of the name - $subState = self::STATE_LOCAL_PART; - $emailAddress->special_char_in_substate = null; - $emailAddress->in_angle_addr = true; - // Any quote before `<` was the display name, not the local part; - // clear the quoted flag the closing-quote handler set so the real - // local-part inside the angle-addr starts unquoted. Likewise any - // comment before `<` sat in the display-name phrase (legal there), - // not an addr-spec local part — clear the deferred split marker. - $emailAddress->local_part_quoted = false; - $emailAddress->local_atom_split_by_comment = false; - $this->handleQuote($emailAddress); - } - } elseif ('>' == $curChar) { - // Should be the end of the domain part. Accept STATE_DOMAIN - // (normal dot-atom domain) and also STATE_AFTER_DOMAIN, which a - // domain-literal (``, `]` transitions to AFTER_DOMAIN) - // or trailing CFWS reaches — but only when a domain or IP is actually - // present, so `` / `` still fail. - if (self::STATE_DOMAIN == $subState - || (self::STATE_AFTER_DOMAIN == $subState - && ('' !== $emailAddress->domain || '' !== $emailAddress->ip))) { - $subState = self::STATE_AFTER_DOMAIN; - $emailAddress->in_angle_addr = false; - } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Did not find domain name before a closing '>'"; - $emailAddress->invalid_reason_code = Err::MissingDomainBeforeClosingAngle; - } - } elseif ('"' == $curChar) { - // If we hit a quote - change to the quote state, unless it's in the domain, in which case it's error - if (self::STATE_DOMAIN == $subState || self::STATE_AFTER_DOMAIN == $subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Quote \'"\' found where it shouldn\'t be'; - $emailAddress->invalid_reason_code = Err::MisplacedQuote; - } else { - $state = self::STATE_QUOTE; - } - } elseif ('@' == $curChar) { - // Handle '@' sign - if (self::STATE_DOMAIN == $subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Multiple at '@' symbols in email address"; - $emailAddress->invalid_reason_code = Err::MultipleAtSymbols; - } elseif (self::STATE_AFTER_DOMAIN == $subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Stray at '@' symbol found after domain name"; - $emailAddress->invalid_reason_code = Err::StrayAtAfterDomain; - } elseif (null !== $emailAddress->special_char_in_substate) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$emailAddress->special_char_in_substate}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; - } elseif ($emailAddress->local_atom_split_by_comment) { - // The `@` confirms this was an addr-spec local part, so the comment - // that split its atext (RFC 5322 §3.2.3) is invalid here. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; - $emailAddress->invalid_reason_code = Err::AtextAfterComment; - } elseif ( - $this->options->allowObsRoute - && $emailAddress->in_angle_addr - && $emailAddress->obs_route === '' - && $emailAddress->local_part_parsed === '' - && $emailAddress->quote_temp === '' - && $emailAddress->address_temp === '' - // An empty *quoted* local part (`<""@host>`) is a real local - // part, not the "no local part" that starts an obs-route. - && !$emailAddress->local_part_quoted - ) { - // RFC 5322 §4.4 obs-route: first `@` seen inside `<...>` with no - // preceding local-part starts the source-route prefix. Consume - // the remainder until `:` via STATE_OBS_ROUTE, then resume - // addr-spec parsing with local-part reset. - $state = self::STATE_OBS_ROUTE; - $emailAddress->obs_route = '@'; - } else { - $subState = self::STATE_DOMAIN; - // A trailing quoted word after earlier words ("x"."y", x."y") - // is the final word of an obs-local-part (RFC 5322 §3.4.1: - // word *("." word), word = atom / quoted-string). Flush it onto - // the accumulated local part, exactly as the dot handler flushes - // earlier words — not a parser error. - if ($emailAddress->address_temp && $emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; - } - if ($emailAddress->quote_temp) { - $emailAddress->local_part_parsed = $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->local_part_quoted = true; - } elseif ($emailAddress->address_temp) { - $emailAddress->local_part_parsed = $emailAddress->address_temp; - $emailAddress->address_temp = ''; - $emailAddress->local_part_quoted = $emailAddress->address_temp_quoted; - $emailAddress->address_temp_quoted = false; - $emailAddress->address_temp_period = 0; - } - } - } elseif ('[' == $curChar) { - // A domain literal ("[...]") is the entire domain (RFC 5322 §3.4.1), - // so '[' is only valid at the start of the domain — not in the local - // part, and not after domain characters or a first literal. Accepting - // it mid-domain used to set both domain and ip and surface as an - // internal "parser confusion" error. - if (self::STATE_DOMAIN != $subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character '[' in email address"; - $emailAddress->invalid_reason_code = Err::InvalidOpeningBracket; - } elseif ('' !== $emailAddress->domain || '' !== $emailAddress->ip) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; - $emailAddress->invalid_reason_code = Err::InvalidOpeningBracket; - } else { - $state = self::STATE_SQUARE_BRACKET; - } - } elseif ('.' == $curChar) { - // Handle periods specially - if ('.' == $prevChar && !$this->options->allowObsLocalPart) { - // Consecutive dots only allowed when obs-local-part is enabled - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Email address should not contain two dots '.' in a row"; - $emailAddress->invalid_reason_code = Err::ConsecutiveDots; - } elseif (self::STATE_LOCAL_PART == $subState) { - if (!$emailAddress->local_part_parsed && !$this->options->allowObsLocalPart) { - // Leading dots only allowed when obs-local-part is enabled - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Email address can not start with '.'"; - $emailAddress->invalid_reason_code = Err::LeadingDot; - } else { - $emailAddress->local_part_parsed .= $curChar; - } - } elseif (self::STATE_DOMAIN == $subState) { - $emailAddress->domain .= $curChar; - } elseif (self::STATE_AFTER_DOMAIN == $subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Stray period '.' found after domain of email address"; - $emailAddress->invalid_reason_code = Err::StrayPeriodAfterDomain; - } elseif (self::STATE_START == $subState) { - if ($emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; - } - $emailAddress->address_temp .= $curChar; - ++$emailAddress->address_temp_period; - } else { - // RFC 5322 §3.4: a period is not an atext character and is not - // valid in an unquoted display name or at the start of an address. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; - $emailAddress->invalid_reason_code = Err::StrayPeriod; - } - } elseif (preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - // RFC 5322 §3.2.3: atext characters — valid in unquoted local-parts and display names - - if (isset($bannedChars[$curChar])) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::CharacterNotAllowed; - } elseif (('/' == $curChar || '|' == $curChar) && - !$emailAddress->local_part_parsed && !$emailAddress->address_temp && !$emailAddress->quote_temp && !$emailAddress->name_parsed) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterAtStart; - } elseif (self::STATE_LOCAL_PART == $subState) { - // Legitimate character - Determine where to append based on the current 'substate' - - if ($emailAddress->quote_temp) { - $emailAddress->local_part_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->local_part_quoted = true; - } - $emailAddress->local_part_parsed .= $curChar; - } elseif (self::STATE_NAME == $subState) { - if ($emailAddress->quote_temp) { - $emailAddress->name_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->name_quoted = true; - } - $emailAddress->name_parsed .= $curChar; - } elseif (self::STATE_DOMAIN == $subState) { - $emailAddress->domain .= $curChar; - } else { - if ($emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; - } - $emailAddress->address_temp .= $curChar; - } - } else { - if (self::STATE_DOMAIN == $subState) { - if ($this->isUtf8Char($curChar)) { - $emailAddress->domain .= $curChar; - } else { - try { - // Test by trying to encode the current character into Punycode - // Punycode should match the traditional domain name subset of characters - $punycoded = idn_to_ascii($curChar); - if ($punycoded !== false && preg_match('/[a-z0-9\-]/', $punycoded)) { - $emailAddress->domain .= $curChar; - } else { - $emailAddress->invalid = true; - } - } catch (\Exception $e) { - $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$emailAddress->original_address: {$emailAddress->original_address}\n\$emails: {$emails}"); - $emailAddress->invalid = true; - } - if ($emailAddress->invalid) { - $emailAddress->invalid_reason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInDomain; - } - } - } elseif (self::STATE_START === $subState || self::STATE_LOCAL_PART === $subState) { - // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently - if ($subState === self::STATE_START && $emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; - } elseif ($subState === self::STATE_LOCAL_PART && $emailAddress->quote_temp) { - $emailAddress->local_part_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->local_part_quoted = true; - } - - $isUtf8 = $this->isUtf8Char($curChar); - - if ($isUtf8 && $this->options->allowUtf8LocalPart) { - // UTF-8 character allowed - if ($subState === self::STATE_START) { - $emailAddress->address_temp .= $curChar; - } else { - $emailAddress->local_part_parsed .= $curChar; - } - } elseif ($isUtf8) { - // UTF-8 present but not allowed by rules — collect and reject in validateLocalPart() - if ($subState === self::STATE_START) { - $emailAddress->address_temp .= $curChar; - // ??= preserves the first invalid character seen; later chars must not overwrite it - $emailAddress->special_char_in_substate ??= $curChar; - } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; - } - } else { - // Non-UTF-8, non-atext character - if ($subState === self::STATE_START) { - // ??= preserves the first invalid character seen; later chars must not overwrite it - $emailAddress->special_char_in_substate ??= $curChar; - $emailAddress->address_temp .= $curChar; - } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; - } - } - } elseif (self::STATE_NAME === $subState) { - if ($emailAddress->quote_temp) { - $emailAddress->name_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->name_quoted = true; - } - $emailAddress->special_char_in_substate = $curChar; - $emailAddress->name_parsed .= $curChar; - } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInAddress; - } - } + $this->handleStateAddress($emailAddress, $curChar, $prevChar, $i); break; case self::STATE_SQUARE_BRACKET: - // Handle square bracketed IP addresses such as [10.0.10.2] - $emailAddress->original_address .= $curChar; - if (']' == $curChar) { - $subState = self::STATE_AFTER_DOMAIN; - $state = self::STATE_ADDRESS; - } else { - $emailAddress->ip .= $curChar; - } + $this->handleStateSquareBracket($emailAddress, $curChar); break; case self::STATE_OBS_ROUTE: - // RFC 5322 §4.4 obs-route absorption — consume the - // `@host1,@host2:` source-route prefix inside angle-addr. - // On `:` terminator, resume normal addr-spec parsing with - // local-part state cleared. An unterminated obs-route - // (end of input or `>` before `:`) is an invalid address. - $emailAddress->original_address .= $curChar; - if (':' == $curChar) { - $state = self::STATE_ADDRESS; - $subState = self::STATE_LOCAL_PART; - } elseif ('>' == $curChar) { - // `<@host>` without a colon — incomplete obs-route. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Incomplete obs-route: missing colon before closing angle-bracket'; - $emailAddress->invalid_reason_code = Err::IncompleteAddress; - $emailAddress->in_angle_addr = false; - $state = self::STATE_ADDRESS; - $subState = self::STATE_AFTER_DOMAIN; - } else { - $emailAddress->obs_route .= $curChar; - } + $this->handleStateObsRoute($emailAddress, $curChar); break; case self::STATE_QUOTE: - // Handle quoted strings - $emailAddress->original_address .= $curChar; - if ('"' == $curChar) { - // RFC 5322 §3.2.4 / RFC 5321 §4.1.2: detect escaped quote by counting - // consecutive backslashes immediately before this position. An odd count - // means the quote is escaped (e.g. \" or \\\"); even count (incl. zero) - // means it is the real closing delimiter. - $backslashCount = 0; - for ($j = $i - 1; $j >= 0; --$j) { - if ('\\' == $chars[$j]) { - ++$backslashCount; - } else { - break; - } - } - if ($backslashCount && 1 == $backslashCount % 2) { - // Odd number of backslashes = this quote is escaped - $emailAddress->quote_temp .= $curChar; - } else { - // Even backslashes (or zero) = this is the real closing quote. - // Record that a quote was seen so an *empty* quoted local-part - // (`""@domain`) is still recognised as quoted — quote_temp is - // empty in that case, so the '@' handler below can't tell. A - // display-name quote self-corrects: the real local-part resets - // this flag from address_temp_quoted when '@' is reached. - $state = self::STATE_ADDRESS; - $emailAddress->local_part_quoted = true; - $emailAddress->after_closing_quote = true; - } - } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { - // qtext (RFC 5322 §3.2.4) excludes C0 controls; a bare CR or LF - // inside a quoted-string is not valid (only a CRLF fold with WSP is). - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Control character in quoted string'; - $emailAddress->invalid_reason_code = Err::InvalidCharInQuotedString; - } else { - $emailAddress->quote_temp .= $curChar; - } + $this->handleStateQuote($emailAddress, $curChar, $i); break; case self::STATE_COMMENT: - // Handle comments and nesting thereof - $emailAddress->original_address .= $curChar; - if ($emailAddress->comment_escaped) { - // Target of a quoted-pair — literal, never structural. - $emailAddress->comment_escaped = false; - $emailAddress->comment_temp .= $curChar; - } elseif ('\\' == $curChar) { - // RFC 5322 §3.2.1: backslash starts a quoted-pair; the next - // character is escaped (so "\)" does not close the comment). - $emailAddress->comment_escaped = true; - } elseif (')' == $curChar) { - --$commentNestLevel; - if ($commentNestLevel <= 0) { - // End of comment - save it - if ($emailAddress->comment_temp) { - $emailAddress->comments[] = $emailAddress->comment_temp; - $emailAddress->comment_temp = ''; - } - $state = self::STATE_ADDRESS; - // Flag a comment that closed mid-word in the local part (before - // `@`), so a token resuming the word can be rejected. Covers a - // preceding atext run (address_temp/local_part_parsed) or a - // preceding quoted-string (local_part_quoted) — "x"(c)y is as - // invalid as x(c)y. Domain and display-name comments are excluded. - if ((self::STATE_LOCAL_PART === $subState || self::STATE_START === $subState) - && ('' !== $emailAddress->address_temp || '' !== $emailAddress->local_part_parsed || $emailAddress->local_part_quoted)) { - $emailAddress->comment_after_local_atext = true; - } - } else { - // Nested comment closing parenthesis - $emailAddress->comment_temp .= $curChar; - } - } elseif ('(' == $curChar) { - ++$commentNestLevel; - if ($commentNestLevel > 1) { - // Nested comment opening parenthesis - $emailAddress->comment_temp .= $curChar; - } - } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { - // ctext (RFC 5322 §3.2.3) excludes C0 controls; a bare CR or LF - // inside a comment is not part of valid folding. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Control character in comment'; - $emailAddress->invalid_reason_code = Err::ControlCharInComment; - } elseif ($this->options->rejectC1Controls && preg_match('/[\x{0080}-\x{009F}]/u', $curChar)) { - // RFC 6532 §3.1: C1 controls (2-byte UTF-8) are prohibited in - // internationalized content, comments included. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Control character in comment'; - $emailAddress->invalid_reason_code = Err::ControlCharInComment; - } else { - // Regular comment character - $emailAddress->comment_temp .= $curChar; - } + $this->handleStateComment($emailAddress, $curChar); break; default: - // Shouldn't ever get here - what is $state? + // Shouldn't ever get here - what is $emailAddress->state? $emailAddress->original_address .= $curChar; $emailAddress->invalid = true; $emailAddress->invalid_reason = 'Error during parsing'; $emailAddress->invalid_reason_code = Err::ParseError; - $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$state}\n\$subState: {$subState}\n\$i: {$i}\n\$curChar: {$curChar}"); + $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$emailAddress->state}\n\$subState: {$emailAddress->subState}\n\$i: {$i}\n\$curChar: {$curChar}"); break; } // if there's a $emailAddress->original_address and the state is set to STATE_END_ADDRESS - if (self::STATE_END_ADDRESS == $state && strlen($emailAddress->original_address) > 0) { + if (self::STATE_END_ADDRESS == $emailAddress->state && strlen($emailAddress->original_address) > 0) { $invalid = $this->addAddress( $emailAddresses, $emailAddress, @@ -965,8 +385,8 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Reset all local variables used during parsing $emailAddress->resetAddress(); - $subState = self::STATE_START; - $state = self::STATE_TRIM; + $emailAddress->subState = self::STATE_START; + $emailAddress->state = self::STATE_TRIM; } // Fire once, on the transition into invalid: STATE_SKIP_AHEAD does not clear @@ -974,9 +394,9 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // character — and interpolating the full $emails / original_address each time // (even under a NullLogger, the argument is still built) makes malformed input // O(n^2). See the DoS regression benchmark. - if ($emailAddress->invalid && self::STATE_SKIP_AHEAD !== $state) { + if ($emailAddress->invalid && self::STATE_SKIP_AHEAD !== $emailAddress->state) { $this->log('debug', "Email\\Parse->parse - invalid - {$emailAddress->invalid_reason}\n\$emailAddress->original_address {$emailAddress->original_address}\n\$emails: {$emails}"); - $state = self::STATE_SKIP_AHEAD; + $emailAddress->state = self::STATE_SKIP_AHEAD; } } @@ -984,9 +404,9 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // literal, or obs-route) — the construct was never closed. Keyed on the // parser state rather than quote_temp, since bracket/comment content is // buffered elsewhere (a closed delimiter always returns to STATE_ADDRESS). - if (!$emailAddress->invalid && in_array($state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { + if (!$emailAddress->invalid && in_array($emailAddress->state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { $emailAddress->invalid = true; - [$emailAddress->invalid_reason, $emailAddress->invalid_reason_code] = match ($state) { + [$emailAddress->invalid_reason, $emailAddress->invalid_reason_code] = match ($emailAddress->state) { self::STATE_QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], self::STATE_COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], self::STATE_SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], @@ -1044,6 +464,706 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } } + /** + * STATE_SKIP_AHEAD: a bad address was seen; discard characters until the next + * separator, then let the main loop transition to STATE_END_ADDRESS. + */ + private function handleStateSkipAhead(ParseContext $emailAddress, string $curChar): void + { + $isWhitespaceSeparator = $emailAddress->useWhitespaceAsSeparator && isset($emailAddress->allowedWhitespace[$curChar]); + + if ($emailAddress->multiple && ($isWhitespaceSeparator || isset($emailAddress->separators[$curChar]))) { + $emailAddress->state = self::STATE_END_ADDRESS; + } else { + $emailAddress->original_address .= $curChar; + } + } + + /** + * STATE_TRIM: skip leading whitespace and detect a leading quote/comment. + * + * @return bool true when the character is ordinary and parsing should fall + * through to STATE_ADDRESS; false when it was consumed here + */ + private function handleStateTrim(ParseContext $emailAddress, string $curChar): bool + { + if (isset($emailAddress->allowedWhitespace[$curChar])) { + return false; + } + $emailAddress->state = self::STATE_ADDRESS; + if ('"' == $curChar) { + $emailAddress->original_address .= $curChar; + $emailAddress->state = self::STATE_QUOTE; + + return false; + } + if ('(' == $curChar) { + $emailAddress->original_address .= $curChar; + $emailAddress->state = self::STATE_COMMENT; + // A leading comment opens at nest level 1 (matches the + // STATE_ADDRESS entry); without this an unbalanced nested + // comment like "((x)" would appear closed after one ")". + $emailAddress->commentNestLevel = 1; + + return false; + } + + // Non-whitespace, non-special char: fall through to STATE_ADDRESS processing. + return true; + } + + /** + * STATE_ADDRESS: the main dispatch on the current character. Small structural + * branches are handled inline; the heavier ones (CFWS, '@', '.', atext and + * non-atext runs) delegate to dedicated helpers below. + */ + private function handleStateAddress(ParseContext $emailAddress, string $curChar, ?string $prevChar, int $i): void + { + if (!isset($emailAddress->separators[$curChar]) || !$emailAddress->multiple) { + $emailAddress->original_address .= $curChar; + } + + if ($emailAddress->after_closing_quote) { + $emailAddress->after_closing_quote = false; + // RFC 5322 §3.2.4: a quoted-string is a whole word. Only a dot + // (obs word.word), '@', angle brackets, CFWS, or a separator may + // follow it — atext or a second quote directly abutting it is invalid. + if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; + $emailAddress->invalid_reason_code = Err::AtextAfterQuotedString; + } + } + + if ($emailAddress->comment_after_local_atext) { + $emailAddress->comment_after_local_atext = false; + // atext or a second quoted-string resuming the word after a comment. + // Defer the verdict: it is only an error if this turns out to be an + // addr-spec local part (resolved at '@'); in a display-name phrase + // "word CFWS word" is legal and is cleared at '<'. + if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { + $emailAddress->local_atom_split_by_comment = true; + } + } + + if ('(' == $curChar) { + // Handle comment + $emailAddress->state = self::STATE_COMMENT; + $emailAddress->commentNestLevel = 1; + + return; + } elseif (isset($emailAddress->separators[$curChar])) { + // Handle separator (comma, semicolon, etc.) + if ($emailAddress->multiple && (self::STATE_DOMAIN == $emailAddress->subState || self::STATE_AFTER_DOMAIN == $emailAddress->subState)) { + // If we're already in the domain part, this should be the end of the address + $emailAddress->state = self::STATE_END_ADDRESS; + + return; + } else { + $emailAddress->invalid = true; + if ($emailAddress->multiple || ($i + 5) >= $emailAddress->len) { + $emailAddress->invalid_reason = 'Misplaced separator or missing "@" symbol'; + $emailAddress->invalid_reason_code = Err::MisplacedSeparator; + } else { + $emailAddress->invalid_reason = 'Separator not permitted - only one email address allowed'; + $emailAddress->invalid_reason_code = Err::SeparatorNotPermitted; + } + } + } elseif (isset($emailAddress->allowedWhitespace[$curChar])) { + if ($this->handleAddressWhitespace($emailAddress, $curChar, $i)) { + return; + } + } elseif ('<' == $curChar) { + // Start of the local part + if (self::STATE_LOCAL_PART == $emailAddress->subState || self::STATE_DOMAIN == $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; + $emailAddress->invalid_reason_code = Err::MultipleOpeningAngle; + } else { + // Here should be the start of the local part for sure everything else then is part of the name + $emailAddress->subState = self::STATE_LOCAL_PART; + $emailAddress->special_char_in_substate = null; + $emailAddress->in_angle_addr = true; + // Any quote before `<` was the display name, not the local part; + // clear the quoted flag the closing-quote handler set so the real + // local-part inside the angle-addr starts unquoted. Likewise any + // comment before `<` sat in the display-name phrase (legal there), + // not an addr-spec local part — clear the deferred split marker. + $emailAddress->local_part_quoted = false; + $emailAddress->local_atom_split_by_comment = false; + $this->handleQuote($emailAddress); + } + } elseif ('>' == $curChar) { + // Should be the end of the domain part. Accept STATE_DOMAIN + // (normal dot-atom domain) and also STATE_AFTER_DOMAIN, which a + // domain-literal (``, `]` transitions to AFTER_DOMAIN) + // or trailing CFWS reaches — but only when a domain or IP is actually + // present, so `` / `` still fail. + if (self::STATE_DOMAIN == $emailAddress->subState + || (self::STATE_AFTER_DOMAIN == $emailAddress->subState + && ('' !== $emailAddress->domain || '' !== $emailAddress->ip))) { + $emailAddress->subState = self::STATE_AFTER_DOMAIN; + $emailAddress->in_angle_addr = false; + } else { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Did not find domain name before a closing '>'"; + $emailAddress->invalid_reason_code = Err::MissingDomainBeforeClosingAngle; + } + } elseif ('"' == $curChar) { + // If we hit a quote - change to the quote state, unless it's in the domain, in which case it's error + if (self::STATE_DOMAIN == $emailAddress->subState || self::STATE_AFTER_DOMAIN == $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Quote \'"\' found where it shouldn\'t be'; + $emailAddress->invalid_reason_code = Err::MisplacedQuote; + } else { + $emailAddress->state = self::STATE_QUOTE; + } + } elseif ('@' == $curChar) { + $this->handleAddressAt($emailAddress); + } elseif ('[' == $curChar) { + // A domain literal ("[...]") is the entire domain (RFC 5322 §3.4.1), + // so '[' is only valid at the start of the domain — not in the local + // part, and not after domain characters or a first literal. Accepting + // it mid-domain used to set both domain and ip and surface as an + // internal "parser confusion" error. + if (self::STATE_DOMAIN != $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character '[' in email address"; + $emailAddress->invalid_reason_code = Err::InvalidOpeningBracket; + } elseif ('' !== $emailAddress->domain || '' !== $emailAddress->ip) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; + $emailAddress->invalid_reason_code = Err::InvalidOpeningBracket; + } else { + $emailAddress->state = self::STATE_SQUARE_BRACKET; + } + } elseif ('.' == $curChar) { + $this->handleAddressDot($emailAddress, $curChar, $prevChar); + } elseif (preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { + $this->handleAddressAtext($emailAddress, $curChar); + } else { + $this->handleAddressNonAtext($emailAddress, $curChar); + } + } + + /** + * STATE_ADDRESS whitespace (RFC 5322 §3.2.2 CFWS). Looks ahead past the WSP + * run to classify the fold and decide whether it is absorbed, ends the + * address, or is an error. + * + * @return bool true when the address is complete and the caller should stop + * processing this character (STATE_END_ADDRESS was set) + */ + private function handleAddressWhitespace(ParseContext $emailAddress, string $curChar, int $i): bool + { + // Look ahead past the WSP run to find the next significant character; that + // character determines which kind of CFWS this is and whether it can be + // silently absorbed or if it marks an end-of-address / error. + $foundComment = false; + $lookAheadChar = null; + for ($j = ($i + 1); $j < $emailAddress->len; ++$j) { + $c = $emailAddress->chars[$j]; + if ('(' === $c) { + $foundComment = true; + + break; + } + if (' ' !== $c && "\t" !== $c && "\r" !== $c && "\n" !== $c) { + $lookAheadChar = $c; + + break; + } + } + + // CFWS absorption: whitespace is legal per RFC 5322 §3.2.3 at + // dot-atom boundaries ("[CFWS] dot-atom-text [CFWS]") and per + // §4.4 obs-angle-addr around the angle brackets. Detect the + // position from subState + lookahead rather than emitting a + // WhitespaceInAddress error. In multi-address mode with + // strictMultiWhitespace, this obsolete internal folding is instead + // rejected per-address (whitespace still separates addresses). + $cfwsAbsorbed = false; + if (!$foundComment && $lookAheadChar !== null && !($emailAddress->multiple && $this->options->strictMultiWhitespace)) { + if (self::STATE_LOCAL_PART === $emailAddress->subState) { + if ('@' === $lookAheadChar) { + // Trailing CFWS of the local-part dot-atom: "local @domain". + $cfwsAbsorbed = true; + } elseif ( + $emailAddress->in_angle_addr + && $emailAddress->local_part_parsed === '' + && $emailAddress->address_temp === '' + && $emailAddress->quote_temp === '' + ) { + // Leading CFWS inside angle-addr: "< local@domain>". + $cfwsAbsorbed = true; + } + } elseif (self::STATE_DOMAIN === $emailAddress->subState) { + if ($emailAddress->domain === '' && $emailAddress->ip === '') { + // Leading CFWS of the domain dot-atom: "local@ domain". + $cfwsAbsorbed = true; + } + } elseif ( + self::STATE_START === $emailAddress->subState + && '@' === $lookAheadChar + && $emailAddress->address_temp !== '' + ) { + // Top-level addr-spec with no angle-addr: "local @domain". + // The accumulated address_temp IS the local-part; absorb the + // whitespace as trailing CFWS before the `@`. + $cfwsAbsorbed = true; + } + } + + if ($cfwsAbsorbed) { + // Silently skip the whitespace character; state unchanged. + } elseif ($foundComment) { + if (self::STATE_DOMAIN == $emailAddress->subState) { + $emailAddress->subState = self::STATE_AFTER_DOMAIN; + } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Email address contains whitespace'; + $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; + } + } elseif ( + $emailAddress->in_angle_addr + && self::STATE_DOMAIN == $emailAddress->subState + && $lookAheadChar === '>' + ) { + // Trailing CFWS inside angle-addr before `>`: "". + // Absorb and transition as if we saw `>` next. + $emailAddress->subState = self::STATE_AFTER_DOMAIN; + } elseif ( + $emailAddress->multiple + && $lookAheadChar !== null + && isset($emailAddress->separators[$lookAheadChar]) + && (self::STATE_DOMAIN == $emailAddress->subState || self::STATE_AFTER_DOMAIN == $emailAddress->subState) + ) { + // Whitespace between the domain and a following separator + // ("a@b.com , c@d.com"): absorb it and let the separator terminate + // the address, rather than ending here and leaving the separator to + // open an empty next address (a "misplaced separator" error). + $emailAddress->subState = self::STATE_AFTER_DOMAIN; + } elseif ($emailAddress->useWhitespaceAsSeparator && + (self::STATE_DOMAIN == $emailAddress->subState || self::STATE_AFTER_DOMAIN == $emailAddress->subState)) { + // Already past `@` and whitespace-as-separator: end address. + // Single mode has no next address to separate; if the trailing + // whitespace run contains a whitespace char excluded from the + // effective set (e.g. CR/LF in strict single mode), that is + // invalid trailing content — a dangling fold — not a terminator. + if (!$emailAddress->multiple) { + for ($k = $i; $k < $emailAddress->len && isset(self::WHITESPACE[$emailAddress->chars[$k]]); ++$k) { + if (!isset($emailAddress->allowedWhitespace[$emailAddress->chars[$k]])) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Disallowed whitespace after address'; + $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; + + break; + } + } + } + $emailAddress->state = self::STATE_END_ADDRESS; + + return true; + } else { + if (self::STATE_LOCAL_PART == $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Email address contains whitespace'; + $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; + } else { + // Display-name phrase: absorb into name_parsed. + $this->handleQuote($emailAddress); + $emailAddress->name_parsed .= $curChar; + } + } + + return false; + } + + /** + * STATE_ADDRESS '@' handling: reject a misplaced '@', start an obs-route, or + * flush the accumulated word(s) into the local-part and enter the domain. + */ + private function handleAddressAt(ParseContext $emailAddress): void + { + if (self::STATE_DOMAIN == $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Multiple at '@' symbols in email address"; + $emailAddress->invalid_reason_code = Err::MultipleAtSymbols; + } elseif (self::STATE_AFTER_DOMAIN == $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Stray at '@' symbol found after domain name"; + $emailAddress->invalid_reason_code = Err::StrayAtAfterDomain; + } elseif (null !== $emailAddress->special_char_in_substate) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$emailAddress->special_char_in_substate}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; + } elseif ($emailAddress->local_atom_split_by_comment) { + // The `@` confirms this was an addr-spec local part, so the comment + // that split its atext (RFC 5322 §3.2.3) is invalid here. + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; + $emailAddress->invalid_reason_code = Err::AtextAfterComment; + } elseif ( + $this->options->allowObsRoute + && $emailAddress->in_angle_addr + && $emailAddress->obs_route === '' + && $emailAddress->local_part_parsed === '' + && $emailAddress->quote_temp === '' + && $emailAddress->address_temp === '' + // An empty *quoted* local part (`<""@host>`) is a real local + // part, not the "no local part" that starts an obs-route. + && !$emailAddress->local_part_quoted + ) { + // RFC 5322 §4.4 obs-route: first `@` seen inside `<...>` with no + // preceding local-part starts the source-route prefix. Consume + // the remainder until `:` via STATE_OBS_ROUTE, then resume + // addr-spec parsing with local-part reset. + $emailAddress->state = self::STATE_OBS_ROUTE; + $emailAddress->obs_route = '@'; + } else { + $emailAddress->subState = self::STATE_DOMAIN; + // A trailing quoted word after earlier words ("x"."y", x."y") + // is the final word of an obs-local-part (RFC 5322 §3.4.1: + // word *("." word), word = atom / quoted-string). Flush it onto + // the accumulated local part, exactly as the dot handler flushes + // earlier words — not a parser error. + if ($emailAddress->address_temp && $emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; + } + if ($emailAddress->quote_temp) { + $emailAddress->local_part_parsed = $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->local_part_quoted = true; + } elseif ($emailAddress->address_temp) { + $emailAddress->local_part_parsed = $emailAddress->address_temp; + $emailAddress->address_temp = ''; + $emailAddress->local_part_quoted = $emailAddress->address_temp_quoted; + $emailAddress->address_temp_quoted = false; + $emailAddress->address_temp_period = 0; + } + } + } + + /** + * STATE_ADDRESS period handling — placement rules differ by sub-state and by + * whether obs-local-part is permitted (RFC 5322 §3.4). + */ + private function handleAddressDot(ParseContext $emailAddress, string $curChar, ?string $prevChar): void + { + if ('.' == $prevChar && !$this->options->allowObsLocalPart) { + // Consecutive dots only allowed when obs-local-part is enabled + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Email address should not contain two dots '.' in a row"; + $emailAddress->invalid_reason_code = Err::ConsecutiveDots; + } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { + if (!$emailAddress->local_part_parsed && !$this->options->allowObsLocalPart) { + // Leading dots only allowed when obs-local-part is enabled + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Email address can not start with '.'"; + $emailAddress->invalid_reason_code = Err::LeadingDot; + } else { + $emailAddress->local_part_parsed .= $curChar; + } + } elseif (self::STATE_DOMAIN == $emailAddress->subState) { + $emailAddress->domain .= $curChar; + } elseif (self::STATE_AFTER_DOMAIN == $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Stray period '.' found after domain of email address"; + $emailAddress->invalid_reason_code = Err::StrayPeriodAfterDomain; + } elseif (self::STATE_START == $emailAddress->subState) { + if ($emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; + } + $emailAddress->address_temp .= $curChar; + ++$emailAddress->address_temp_period; + } else { + // RFC 5322 §3.4: a period is not an atext character and is not + // valid in an unquoted display name or at the start of an address. + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; + $emailAddress->invalid_reason_code = Err::StrayPeriod; + } + } + + /** + * STATE_ADDRESS atext handling (RFC 5322 §3.2.3) — appends the character to + * the local-part, display name, domain or pending word per the sub-state. + */ + private function handleAddressAtext(ParseContext $emailAddress, string $curChar): void + { + if (isset($emailAddress->bannedChars[$curChar])) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::CharacterNotAllowed; + } elseif (('/' == $curChar || '|' == $curChar) && + !$emailAddress->local_part_parsed && !$emailAddress->address_temp && !$emailAddress->quote_temp && !$emailAddress->name_parsed) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterAtStart; + } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { + // Legitimate character - Determine where to append based on the current 'substate' + + if ($emailAddress->quote_temp) { + $emailAddress->local_part_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->local_part_quoted = true; + } + $emailAddress->local_part_parsed .= $curChar; + } elseif (self::STATE_NAME == $emailAddress->subState) { + if ($emailAddress->quote_temp) { + $emailAddress->name_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->name_quoted = true; + } + $emailAddress->name_parsed .= $curChar; + } elseif (self::STATE_DOMAIN == $emailAddress->subState) { + $emailAddress->domain .= $curChar; + } else { + if ($emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; + } + $emailAddress->address_temp .= $curChar; + } + } + + /** + * STATE_ADDRESS non-atext handling — UTF-8 domain/local-part characters + * (punycode-tested for the domain) plus rejection of other stray bytes. + */ + private function handleAddressNonAtext(ParseContext $emailAddress, string $curChar): void + { + if (self::STATE_DOMAIN == $emailAddress->subState) { + if ($this->isUtf8Char($curChar)) { + $emailAddress->domain .= $curChar; + } else { + try { + // Test by trying to encode the current character into Punycode + // Punycode should match the traditional domain name subset of characters + $punycoded = idn_to_ascii($curChar); + if ($punycoded !== false && preg_match('/[a-z0-9\-]/', $punycoded)) { + $emailAddress->domain .= $curChar; + } else { + $emailAddress->invalid = true; + } + } catch (\Exception $e) { + $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$emailAddress->original_address: {$emailAddress->original_address}\n\$emails: {$emailAddress->emails}"); + $emailAddress->invalid = true; + } + if ($emailAddress->invalid) { + $emailAddress->invalid_reason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInDomain; + } + } + } elseif (self::STATE_START === $emailAddress->subState || self::STATE_LOCAL_PART === $emailAddress->subState) { + // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently + if ($emailAddress->subState === self::STATE_START && $emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; + } elseif ($emailAddress->subState === self::STATE_LOCAL_PART && $emailAddress->quote_temp) { + $emailAddress->local_part_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->local_part_quoted = true; + } + + $isUtf8 = $this->isUtf8Char($curChar); + + if ($isUtf8 && $this->options->allowUtf8LocalPart) { + // UTF-8 character allowed + if ($emailAddress->subState === self::STATE_START) { + $emailAddress->address_temp .= $curChar; + } else { + $emailAddress->local_part_parsed .= $curChar; + } + } elseif ($isUtf8) { + // UTF-8 present but not allowed by rules — collect and reject in validateLocalPart() + if ($emailAddress->subState === self::STATE_START) { + $emailAddress->address_temp .= $curChar; + // ??= preserves the first invalid character seen; later chars must not overwrite it + $emailAddress->special_char_in_substate ??= $curChar; + } else { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; + } + } else { + // Non-UTF-8, non-atext character + if ($emailAddress->subState === self::STATE_START) { + // ??= preserves the first invalid character seen; later chars must not overwrite it + $emailAddress->special_char_in_substate ??= $curChar; + $emailAddress->address_temp .= $curChar; + } else { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; + } + } + } elseif (self::STATE_NAME === $emailAddress->subState) { + if ($emailAddress->quote_temp) { + $emailAddress->name_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->name_quoted = true; + } + $emailAddress->special_char_in_substate = $curChar; + $emailAddress->name_parsed .= $curChar; + } else { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterInAddress; + } + } + + /** + * STATE_SQUARE_BRACKET: accumulate a domain-literal IP until the closing ']'. + */ + private function handleStateSquareBracket(ParseContext $emailAddress, string $curChar): void + { + $emailAddress->original_address .= $curChar; + if (']' == $curChar) { + $emailAddress->subState = self::STATE_AFTER_DOMAIN; + $emailAddress->state = self::STATE_ADDRESS; + } else { + $emailAddress->ip .= $curChar; + } + } + + /** + * STATE_OBS_ROUTE (RFC 5322 §4.4): consume the `@host1,@host2:` source-route + * prefix inside angle-addr. On `:` resume addr-spec parsing; an unterminated + * route (`>` or end of input before `:`) is invalid. + */ + private function handleStateObsRoute(ParseContext $emailAddress, string $curChar): void + { + $emailAddress->original_address .= $curChar; + if (':' == $curChar) { + $emailAddress->state = self::STATE_ADDRESS; + $emailAddress->subState = self::STATE_LOCAL_PART; + } elseif ('>' == $curChar) { + // `<@host>` without a colon — incomplete obs-route. + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Incomplete obs-route: missing colon before closing angle-bracket'; + $emailAddress->invalid_reason_code = Err::IncompleteAddress; + $emailAddress->in_angle_addr = false; + $emailAddress->state = self::STATE_ADDRESS; + $emailAddress->subState = self::STATE_AFTER_DOMAIN; + } else { + $emailAddress->obs_route .= $curChar; + } + } + + /** + * STATE_QUOTE: accumulate a quoted-string, honouring backslash escapes and + * rejecting bare C0 controls, until the real closing quote returns to + * STATE_ADDRESS. + */ + private function handleStateQuote(ParseContext $emailAddress, string $curChar, int $i): void + { + $emailAddress->original_address .= $curChar; + if ('"' == $curChar) { + // RFC 5322 §3.2.4 / RFC 5321 §4.1.2: detect escaped quote by counting + // consecutive backslashes immediately before this position. An odd count + // means the quote is escaped (e.g. \" or \\\"); even count (incl. zero) + // means it is the real closing delimiter. + $backslashCount = 0; + for ($j = $i - 1; $j >= 0; --$j) { + if ('\\' == $emailAddress->chars[$j]) { + ++$backslashCount; + } else { + break; + } + } + if ($backslashCount && 1 == $backslashCount % 2) { + // Odd number of backslashes = this quote is escaped + $emailAddress->quote_temp .= $curChar; + } else { + // Even backslashes (or zero) = this is the real closing quote. + // Record that a quote was seen so an *empty* quoted local-part + // (`""@domain`) is still recognised as quoted — quote_temp is + // empty in that case, so the '@' handler below can't tell. A + // display-name quote self-corrects: the real local-part resets + // this flag from address_temp_quoted when '@' is reached. + $emailAddress->state = self::STATE_ADDRESS; + $emailAddress->local_part_quoted = true; + $emailAddress->after_closing_quote = true; + } + } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { + // qtext (RFC 5322 §3.2.4) excludes C0 controls; a bare CR or LF + // inside a quoted-string is not valid (only a CRLF fold with WSP is). + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Control character in quoted string'; + $emailAddress->invalid_reason_code = Err::InvalidCharInQuotedString; + } else { + $emailAddress->quote_temp .= $curChar; + } + } + + /** + * STATE_COMMENT (RFC 5322 §3.2.2): accumulate comment text, tracking nesting + * and quoted-pairs, and on close flag a comment that split a local-part atom. + */ + private function handleStateComment(ParseContext $emailAddress, string $curChar): void + { + $emailAddress->original_address .= $curChar; + if ($emailAddress->comment_escaped) { + // Target of a quoted-pair — literal, never structural. + $emailAddress->comment_escaped = false; + $emailAddress->comment_temp .= $curChar; + } elseif ('\\' == $curChar) { + // RFC 5322 §3.2.1: backslash starts a quoted-pair; the next + // character is escaped (so "\)" does not close the comment). + $emailAddress->comment_escaped = true; + } elseif (')' == $curChar) { + --$emailAddress->commentNestLevel; + if ($emailAddress->commentNestLevel <= 0) { + // End of comment - save it + if ($emailAddress->comment_temp) { + $emailAddress->comments[] = $emailAddress->comment_temp; + $emailAddress->comment_temp = ''; + } + $emailAddress->state = self::STATE_ADDRESS; + // Flag a comment that closed mid-word in the local part (before + // `@`), so a token resuming the word can be rejected. Covers a + // preceding atext run (address_temp/local_part_parsed) or a + // preceding quoted-string (local_part_quoted) — "x"(c)y is as + // invalid as x(c)y. Domain and display-name comments are excluded. + if ((self::STATE_LOCAL_PART === $emailAddress->subState || self::STATE_START === $emailAddress->subState) + && ('' !== $emailAddress->address_temp || '' !== $emailAddress->local_part_parsed || $emailAddress->local_part_quoted)) { + $emailAddress->comment_after_local_atext = true; + } + } else { + // Nested comment closing parenthesis + $emailAddress->comment_temp .= $curChar; + } + } elseif ('(' == $curChar) { + ++$emailAddress->commentNestLevel; + if ($emailAddress->commentNestLevel > 1) { + // Nested comment opening parenthesis + $emailAddress->comment_temp .= $curChar; + } + } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { + // ctext (RFC 5322 §3.2.3) excludes C0 controls; a bare CR or LF + // inside a comment is not part of valid folding. + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Control character in comment'; + $emailAddress->invalid_reason_code = Err::ControlCharInComment; + } elseif ($this->options->rejectC1Controls && preg_match('/[\x{0080}-\x{009F}]/u', $curChar)) { + // RFC 6532 §3.1: C1 controls (2-byte UTF-8) are prohibited in + // internationalized content, comments included. + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Control character in comment'; + $emailAddress->invalid_reason_code = Err::ControlCharInComment; + } else { + // Regular comment character + $emailAddress->comment_temp .= $curChar; + } + } + /** * Resolves a pending quoted or temp buffer into the display name. * diff --git a/src/ParseContext.php b/src/ParseContext.php index 4adc6eb..5c5c3fe 100644 --- a/src/ParseContext.php +++ b/src/ParseContext.php @@ -3,20 +3,64 @@ namespace Email; /** - * Per-parse mutable accumulator for {@see Parse::parse()}. + * Per-parse mutable state for {@see Parse::parse()}. + * + * Holds the input snapshot and hoisted config, the state-machine control + * variables, and the ~24-field address accumulator that the state handlers + * read and mutate as they walk the input character by character. * * A fresh instance is created for every parse() call and is never stored on the * Parse instance, so the parser stays reentrant: a caller-supplied * localPartNormalizer closure may call back into parse() mid-parse without * clobbering the outer parse's state. * - * Property names mirror the historical $emailAddress accumulator keys so the - * accumulator threads through the validation helpers unchanged; the public - * output array shape is built separately in {@see Parse::addAddress()} and is + * The accumulator property names mirror the historical $emailAddress array keys + * so they thread through the validation helpers unchanged; the public output + * array shape is built separately in {@see Parse::addAddress()} and is * unaffected by this object. */ final class ParseContext { + // --- Per-parse input snapshot and hoisted config (set once in parse(), + // never reset between addresses). --- + + /** @var array The input split into characters (see parse()). */ + public array $chars = []; + + /** Number of characters in $chars. */ + public int $len = 0; + + /** Whether multiple addresses are being parsed. */ + public bool $multiple = true; + + /** The original input string, retained for diagnostic logging. */ + public string $emails = ''; + + /** @var array Separator characters, as a lookup map. */ + public array $separators = []; + + /** @var array Banned characters, as a lookup map. */ + public array $bannedChars = []; + + /** Whether whitespace acts as an address separator. */ + public bool $useWhitespaceAsSeparator = false; + + /** @var array Insignificant (foldable/trimmable) whitespace, as a lookup map. */ + public array $allowedWhitespace = []; + + // --- Loop control state (state/subState reset per address by parse()). --- + + /** Current parser state (one of Parse::STATE_*). */ + public int $state = 0; + + /** Current parser sub-state within an addr-spec (one of Parse::STATE_*). */ + public int $subState = 0; + + /** Current comment nesting depth. */ + public int $commentNestLevel = 0; + + // --- Accumulator fields (reset per address via resetAddress()). --- + /** Raw address as given, comments included. */ public string $original_address = ''; From 7792e6d075db439c422e2fff95e9f5d62cdb5a85 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Thu, 20 Aug 2026 00:11:58 -0700 Subject: [PATCH 03/11] perf(parse): inline the per-character hot branches in handleStateAddress Fold the atext and period handling back inline into handleStateAddress so the dominant STATE_ADDRESS path makes a single method call per character instead of two. The larger, less-frequent branches (CFWS whitespace, '@', non-atext) stay in their own helpers. Behaviour is unchanged (full suite still green). Under opcache+JIT the batch-parsing benchmarks now run at or below the pre-refactor baseline. --- src/Parse.php | 162 +++++++++++++++++++++++--------------------------- 1 file changed, 74 insertions(+), 88 deletions(-) diff --git a/src/Parse.php b/src/Parse.php index 29609e7..1e825b3 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -638,9 +638,81 @@ private function handleStateAddress(ParseContext $emailAddress, string $curChar, $emailAddress->state = self::STATE_SQUARE_BRACKET; } } elseif ('.' == $curChar) { - $this->handleAddressDot($emailAddress, $curChar, $prevChar); + // Period placement (RFC 5322 §3.4) — inlined as it is per-character hot. + if ('.' == $prevChar && !$this->options->allowObsLocalPart) { + // Consecutive dots only allowed when obs-local-part is enabled + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Email address should not contain two dots '.' in a row"; + $emailAddress->invalid_reason_code = Err::ConsecutiveDots; + } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { + if (!$emailAddress->local_part_parsed && !$this->options->allowObsLocalPart) { + // Leading dots only allowed when obs-local-part is enabled + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Email address can not start with '.'"; + $emailAddress->invalid_reason_code = Err::LeadingDot; + } else { + $emailAddress->local_part_parsed .= $curChar; + } + } elseif (self::STATE_DOMAIN == $emailAddress->subState) { + $emailAddress->domain .= $curChar; + } elseif (self::STATE_AFTER_DOMAIN == $emailAddress->subState) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "Stray period '.' found after domain of email address"; + $emailAddress->invalid_reason_code = Err::StrayPeriodAfterDomain; + } elseif (self::STATE_START == $emailAddress->subState) { + if ($emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; + } + $emailAddress->address_temp .= $curChar; + ++$emailAddress->address_temp_period; + } else { + // RFC 5322 §3.4: a period is not an atext character and is not + // valid in an unquoted display name or at the start of an address. + $emailAddress->invalid = true; + $emailAddress->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; + $emailAddress->invalid_reason_code = Err::StrayPeriod; + } } elseif (preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $this->handleAddressAtext($emailAddress, $curChar); + // atext (RFC 5322 §3.2.3) — the per-character hot path; inlined to keep + // one call per character. Appends to the local-part, display name, + // domain or pending word per the sub-state. + if (isset($emailAddress->bannedChars[$curChar])) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::CharacterNotAllowed; + } elseif (('/' == $curChar || '|' == $curChar) && + !$emailAddress->local_part_parsed && !$emailAddress->address_temp && !$emailAddress->quote_temp && !$emailAddress->name_parsed) { + $emailAddress->invalid = true; + $emailAddress->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; + $emailAddress->invalid_reason_code = Err::InvalidCharacterAtStart; + } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { + // Legitimate character - Determine where to append based on the current 'substate' + + if ($emailAddress->quote_temp) { + $emailAddress->local_part_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->local_part_quoted = true; + } + $emailAddress->local_part_parsed .= $curChar; + } elseif (self::STATE_NAME == $emailAddress->subState) { + if ($emailAddress->quote_temp) { + $emailAddress->name_parsed .= $emailAddress->quote_temp; + $emailAddress->quote_temp = ''; + $emailAddress->name_quoted = true; + } + $emailAddress->name_parsed .= $curChar; + } elseif (self::STATE_DOMAIN == $emailAddress->subState) { + $emailAddress->domain .= $curChar; + } else { + if ($emailAddress->quote_temp) { + $emailAddress->address_temp .= $emailAddress->quote_temp; + $emailAddress->address_temp_quoted = true; + $emailAddress->quote_temp = ''; + } + $emailAddress->address_temp .= $curChar; + } } else { $this->handleAddressNonAtext($emailAddress, $curChar); } @@ -846,92 +918,6 @@ private function handleAddressAt(ParseContext $emailAddress): void } } - /** - * STATE_ADDRESS period handling — placement rules differ by sub-state and by - * whether obs-local-part is permitted (RFC 5322 §3.4). - */ - private function handleAddressDot(ParseContext $emailAddress, string $curChar, ?string $prevChar): void - { - if ('.' == $prevChar && !$this->options->allowObsLocalPart) { - // Consecutive dots only allowed when obs-local-part is enabled - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Email address should not contain two dots '.' in a row"; - $emailAddress->invalid_reason_code = Err::ConsecutiveDots; - } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { - if (!$emailAddress->local_part_parsed && !$this->options->allowObsLocalPart) { - // Leading dots only allowed when obs-local-part is enabled - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Email address can not start with '.'"; - $emailAddress->invalid_reason_code = Err::LeadingDot; - } else { - $emailAddress->local_part_parsed .= $curChar; - } - } elseif (self::STATE_DOMAIN == $emailAddress->subState) { - $emailAddress->domain .= $curChar; - } elseif (self::STATE_AFTER_DOMAIN == $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Stray period '.' found after domain of email address"; - $emailAddress->invalid_reason_code = Err::StrayPeriodAfterDomain; - } elseif (self::STATE_START == $emailAddress->subState) { - if ($emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; - } - $emailAddress->address_temp .= $curChar; - ++$emailAddress->address_temp_period; - } else { - // RFC 5322 §3.4: a period is not an atext character and is not - // valid in an unquoted display name or at the start of an address. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; - $emailAddress->invalid_reason_code = Err::StrayPeriod; - } - } - - /** - * STATE_ADDRESS atext handling (RFC 5322 §3.2.3) — appends the character to - * the local-part, display name, domain or pending word per the sub-state. - */ - private function handleAddressAtext(ParseContext $emailAddress, string $curChar): void - { - if (isset($emailAddress->bannedChars[$curChar])) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::CharacterNotAllowed; - } elseif (('/' == $curChar || '|' == $curChar) && - !$emailAddress->local_part_parsed && !$emailAddress->address_temp && !$emailAddress->quote_temp && !$emailAddress->name_parsed) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterAtStart; - } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { - // Legitimate character - Determine where to append based on the current 'substate' - - if ($emailAddress->quote_temp) { - $emailAddress->local_part_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->local_part_quoted = true; - } - $emailAddress->local_part_parsed .= $curChar; - } elseif (self::STATE_NAME == $emailAddress->subState) { - if ($emailAddress->quote_temp) { - $emailAddress->name_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->name_quoted = true; - } - $emailAddress->name_parsed .= $curChar; - } elseif (self::STATE_DOMAIN == $emailAddress->subState) { - $emailAddress->domain .= $curChar; - } else { - if ($emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; - } - $emailAddress->address_temp .= $curChar; - } - } - /** * STATE_ADDRESS non-atext handling — UTF-8 domain/local-part characters * (punycode-tested for the domain) plus rejection of other stray bytes. From f9cd672b94c7be898c63f7c00cd01145a542578f Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Fri, 21 Aug 2026 00:23:03 -0700 Subject: [PATCH 04/11] refactor: rename ParseContext var to $ctx; consolidate per-address reset Post-review cleanup for the parse() decomposition. - Rename the parse-state local (and every handler parameter) from $emailAddress to $ctx: it holds a ParseContext, not an address, and the old name shadowed the concept of the address being built. Pure mechanical rename, no behavior change. - Make ParseContext::resetAddress(int $state, int $subState) the single source of truth for per-address reset. It now also sets state/subState and zeroes commentNestLevel, which nothing reset before: an unterminated comment could leak its nesting level into the next address in a batch, self-healing only because '(' reassigns it to 1. Both the initial setup and the per-address reset in parse() now go through it. Roadmap: mark the parse() readability refactor delivered and record the remaining non-blocking follow-ups (snake_case fields, structural split of the context's three concerns, chars/len duplication, handleStateAddress). 108 tests / 7199 assertions, PHPStan and CS clean. --- ROADMAP.md | 7 +- src/Parse.php | 966 +++++++++++++++++++++---------------------- src/ParseContext.php | 10 +- 3 files changed, 496 insertions(+), 487 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 45771fe..c150a4c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,9 +117,14 @@ The comparison harness remains a local dev tool (not a CI gate). Every fixed clu - [ ] Further profiling under mailing-list-sized inputs if needed — the `mb_str_split` array now dominates memory for very large batches; a streaming/chunked reader could bound that. **Maintainability / readability:** -- [ ] **Reorganize `Parse::parse()` for readability.** The main state machine has grown deeply nested (a `switch ($state)` with a nested `switch/if` on `$subState`, plus per-character CFWS/comment/quote handling), and several correctness fixes have added flags and edge branches that are hard to follow. Decompose the loop body into named per-state handlers (e.g. `handleTrim`/`handleAddress`/`handleQuote`/`handleComment`) so each state's logic is isolated and independently readable. Also fold the accumulated tracking flags (`after_closing_quote`, `comment_after_local_atext`, `comment_escaped`, …) into a clearer per-parse context object. +- [x] **Reorganize `Parse::parse()` for readability.** The main state machine has grown deeply nested (a `switch ($state)` with a nested `switch/if` on `$subState`, plus per-character CFWS/comment/quote handling), and several correctness fixes have added flags and edge branches that are hard to follow. Decompose the loop body into named per-state handlers (e.g. `handleTrim`/`handleAddress`/`handleQuote`/`handleComment`) so each state's logic is isolated and independently readable. Also fold the accumulated tracking flags (`after_closing_quote`, `comment_after_local_atext`, `comment_escaped`, …) into a clearer per-parse context object. - **Hard constraint: no performance regression.** Benchmark before and after with `composer bench:baseline` (on the pre-refactor commit) then `composer bench:compare` on the refactor; every subject must stay within noise. A prior spike proved this is achievable — decomposing the switch into method-per-character dispatch dropped `parse()` cyclomatic complexity 168 → 23 with **no measurable slowdown** (PHP 8's method calls are cheap; smaller methods can even help I-cache). Prefer passing a context object over instance properties, to keep the parser reentrant (a user `localPartNormalizer` callback can re-enter `parse()`). - Keep it behavior-preserving: it is a pure structural refactor, gated by the full test suite (currently 99 tests) + PHPStan level 8 + Psalm, with no changes to parsing logic, conditions, or ordering. + - **Delivered** as `ParseContext` (per-parse mutable state, reentrancy-safe) plus per-state handler methods. Follow-ups from review, not blocking: + - [ ] Migrate `ParseContext`'s per-address accumulator fields from `snake_case` to the codebase's `camelCase`. Kept `snake_case` during the extraction so the diff was a pure move of the original loop locals; rename once the dust settles. + - [ ] Encode `ParseContext`'s three concerns structurally rather than by convention: the immutable input snapshot (`chars`/`len`/`emails`), the hoisted read-only config (`separators`, `bannedChars`, …), and the mutable per-address accumulator are all public fields today, so nothing stops a handler from writing config. Consider grouping/readonly-marking the stable fields. + - [ ] Remove the `chars`/`len` double source of truth: they exist both as `parse()` loop locals and as `ParseContext` properties. Read from one (kept duplicated for hot-loop locality; measure before changing). + - [ ] Decompose `handleStateAddress` further (~200 lines). CFWS/`@`/non-atext handling is already split into helpers; the remaining bulk is inherent to the address sub-state machine, so this is diminishing-returns polish. **Community / documentation:** - [x] `CONTRIBUTING.md` — dev setup, all `composer` scripts, test-case guidance, code-style rules, RFC citation expectations. diff --git a/src/Parse.php b/src/Parse.php index 1e825b3..290add5 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -276,17 +276,15 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Per-parse accumulator. A fresh instance (never an instance property) // keeps parse() reentrant across a localPartNormalizer callback. - $emailAddress = new ParseContext(); + $ctx = new ParseContext(); $success = true; $reason = null; - // Current state of the parser - $emailAddress->state = self::STATE_TRIM; - - // Current sub state (this is for when we get to the xyz@somewhere.com email address itself) - $emailAddress->subState = self::STATE_START; - $emailAddress->commentNestLevel = 0; + // Initialize per-address state: STATE_TRIM as the current parser state, + // STATE_START as the sub state (for when we reach the xyz@somewhere.com + // address itself), and zero the accumulator and comment nesting. + $ctx->resetAddress(self::STATE_TRIM, self::STATE_START); // Split once into an array of characters rather than calling // mb_substr($emails, $i, 1) on every iteration. For multi-byte encodings @@ -311,66 +309,66 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Publish the input snapshot and hoisted config onto the context so the // per-state handlers can read them without long parameter lists. $chars // and $len are also kept as locals below for the tight loop counter. - $emailAddress->chars = $chars; - $emailAddress->len = $len; - $emailAddress->multiple = $multiple; - $emailAddress->emails = $emails; - $emailAddress->separators = $separators; - $emailAddress->bannedChars = $this->options->getBannedChars(); - $emailAddress->useWhitespaceAsSeparator = $this->options->getUseWhitespaceAsSeparator(); - $emailAddress->allowedWhitespace = $allowedWhitespace; + $ctx->chars = $chars; + $ctx->len = $len; + $ctx->multiple = $multiple; + $ctx->emails = $emails; + $ctx->separators = $separators; + $ctx->bannedChars = $this->options->getBannedChars(); + $ctx->useWhitespaceAsSeparator = $this->options->getUseWhitespaceAsSeparator(); + $ctx->allowedWhitespace = $allowedWhitespace; $curChar = null; for ($i = 0; $i < $len; ++$i) { $prevChar = $curChar; // Previous Character $curChar = $chars[$i]; // Current Character - switch ($emailAddress->state) { + switch ($ctx->state) { case self::STATE_SKIP_AHEAD: - $this->handleStateSkipAhead($emailAddress, $curChar); + $this->handleStateSkipAhead($ctx, $curChar); break; /* @noinspection PhpMissingBreakStatementInspection — STATE_TRIM falls through to STATE_ADDRESS */ case self::STATE_TRIM: - if (!$this->handleStateTrim($emailAddress, $curChar)) { + if (!$this->handleStateTrim($ctx, $curChar)) { break; } // no break — a plain character falls through to STATE_ADDRESS case self::STATE_ADDRESS: - $this->handleStateAddress($emailAddress, $curChar, $prevChar, $i); + $this->handleStateAddress($ctx, $curChar, $prevChar, $i); break; case self::STATE_SQUARE_BRACKET: - $this->handleStateSquareBracket($emailAddress, $curChar); + $this->handleStateSquareBracket($ctx, $curChar); break; case self::STATE_OBS_ROUTE: - $this->handleStateObsRoute($emailAddress, $curChar); + $this->handleStateObsRoute($ctx, $curChar); break; case self::STATE_QUOTE: - $this->handleStateQuote($emailAddress, $curChar, $i); + $this->handleStateQuote($ctx, $curChar, $i); break; case self::STATE_COMMENT: - $this->handleStateComment($emailAddress, $curChar); + $this->handleStateComment($ctx, $curChar); break; default: - // Shouldn't ever get here - what is $emailAddress->state? - $emailAddress->original_address .= $curChar; - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Error during parsing'; - $emailAddress->invalid_reason_code = Err::ParseError; - $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$emailAddress->state}\n\$subState: {$emailAddress->subState}\n\$i: {$i}\n\$curChar: {$curChar}"); + // Shouldn't ever get here - what is $ctx->state? + $ctx->original_address .= $curChar; + $ctx->invalid = true; + $ctx->invalid_reason = 'Error during parsing'; + $ctx->invalid_reason_code = Err::ParseError; + $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$ctx->state}\n\$subState: {$ctx->subState}\n\$i: {$i}\n\$curChar: {$curChar}"); break; } - // if there's a $emailAddress->original_address and the state is set to STATE_END_ADDRESS - if (self::STATE_END_ADDRESS == $emailAddress->state && strlen($emailAddress->original_address) > 0) { + // if there's a $ctx->original_address and the state is set to STATE_END_ADDRESS + if (self::STATE_END_ADDRESS == $ctx->state && strlen($ctx->original_address) > 0) { $invalid = $this->addAddress( $emailAddresses, - $emailAddress, + $ctx, $i ); @@ -383,10 +381,8 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } } - // Reset all local variables used during parsing - $emailAddress->resetAddress(); - $emailAddress->subState = self::STATE_START; - $emailAddress->state = self::STATE_TRIM; + // Reset all per-address state before the next address in the batch. + $ctx->resetAddress(self::STATE_TRIM, self::STATE_START); } // Fire once, on the transition into invalid: STATE_SKIP_AHEAD does not clear @@ -394,9 +390,9 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // character — and interpolating the full $emails / original_address each time // (even under a NullLogger, the argument is still built) makes malformed input // O(n^2). See the DoS regression benchmark. - if ($emailAddress->invalid && self::STATE_SKIP_AHEAD !== $emailAddress->state) { - $this->log('debug', "Email\\Parse->parse - invalid - {$emailAddress->invalid_reason}\n\$emailAddress->original_address {$emailAddress->original_address}\n\$emails: {$emails}"); - $emailAddress->state = self::STATE_SKIP_AHEAD; + if ($ctx->invalid && self::STATE_SKIP_AHEAD !== $ctx->state) { + $this->log('debug', "Email\\Parse->parse - invalid - {$ctx->invalid_reason}\n\$ctx->original_address {$ctx->original_address}\n\$emails: {$emails}"); + $ctx->state = self::STATE_SKIP_AHEAD; } } @@ -404,20 +400,20 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // literal, or obs-route) — the construct was never closed. Keyed on the // parser state rather than quote_temp, since bracket/comment content is // buffered elsewhere (a closed delimiter always returns to STATE_ADDRESS). - if (!$emailAddress->invalid && in_array($emailAddress->state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { - $emailAddress->invalid = true; - [$emailAddress->invalid_reason, $emailAddress->invalid_reason_code] = match ($emailAddress->state) { + if (!$ctx->invalid && in_array($ctx->state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { + $ctx->invalid = true; + [$ctx->invalid_reason, $ctx->invalid_reason_code] = match ($ctx->state) { self::STATE_QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], self::STATE_COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], self::STATE_SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], self::STATE_OBS_ROUTE => ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress], }; } - if (!$emailAddress->invalid && ($emailAddress->address_temp || $emailAddress->quote_temp)) { - $this->log('error', "Email\\Parse->parse - corruption during parsing - leftovers:\n\$i: {$i}\n\$emailAddress->address_temp: {$emailAddress->address_temp}\n\$emailAddress->quote_temp: {$emailAddress->quote_temp}\nEmails: {$emails}"); - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Incomplete address'; - $emailAddress->invalid_reason_code = Err::IncompleteAddress; + if (!$ctx->invalid && ($ctx->address_temp || $ctx->quote_temp)) { + $this->log('error', "Email\\Parse->parse - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->address_temp: {$ctx->address_temp}\n\$ctx->quote_temp: {$ctx->quote_temp}\nEmails: {$emails}"); + $ctx->invalid = true; + $ctx->invalid_reason = 'Incomplete address'; + $ctx->invalid_reason_code = Err::IncompleteAddress; if (!$success) { $reason = 'Invalid email addresses'; } else { @@ -429,23 +425,23 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Did we find no email addresses at all? An empty local-part only counts as // "no address" when it is unquoted; `""@domain` is a legitimately-empty quoted // local-part whose acceptance is decided later by rejectEmptyQuotedLocalPart. - if (!$emailAddress->invalid && !count($emailAddresses) && (!$emailAddress->original_address || (!$emailAddress->local_part_parsed && !$emailAddress->local_part_quoted))) { + if (!$ctx->invalid && !count($emailAddresses) && (!$ctx->original_address || (!$ctx->local_part_parsed && !$ctx->local_part_quoted))) { $success = false; $reason = 'No email addresses found'; if (!$multiple) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'No email address found'; - $emailAddress->invalid_reason_code = Err::IncompleteAddress; + $ctx->invalid = true; + $ctx->invalid_reason = 'No email address found'; + $ctx->invalid_reason_code = Err::IncompleteAddress; $this->addAddress( $emailAddresses, - $emailAddress, + $ctx, $i ); } - } elseif ($emailAddress->original_address) { + } elseif ($ctx->original_address) { $invalid = $this->addAddress( $emailAddresses, - $emailAddress, + $ctx, $i ); if ($invalid) { @@ -468,14 +464,14 @@ public function parse(string $emails, bool $multiple = true, string $encoding = * STATE_SKIP_AHEAD: a bad address was seen; discard characters until the next * separator, then let the main loop transition to STATE_END_ADDRESS. */ - private function handleStateSkipAhead(ParseContext $emailAddress, string $curChar): void + private function handleStateSkipAhead(ParseContext $ctx, string $curChar): void { - $isWhitespaceSeparator = $emailAddress->useWhitespaceAsSeparator && isset($emailAddress->allowedWhitespace[$curChar]); + $isWhitespaceSeparator = $ctx->useWhitespaceAsSeparator && isset($ctx->allowedWhitespace[$curChar]); - if ($emailAddress->multiple && ($isWhitespaceSeparator || isset($emailAddress->separators[$curChar]))) { - $emailAddress->state = self::STATE_END_ADDRESS; + if ($ctx->multiple && ($isWhitespaceSeparator || isset($ctx->separators[$curChar]))) { + $ctx->state = self::STATE_END_ADDRESS; } else { - $emailAddress->original_address .= $curChar; + $ctx->original_address .= $curChar; } } @@ -485,25 +481,25 @@ private function handleStateSkipAhead(ParseContext $emailAddress, string $curCha * @return bool true when the character is ordinary and parsing should fall * through to STATE_ADDRESS; false when it was consumed here */ - private function handleStateTrim(ParseContext $emailAddress, string $curChar): bool + private function handleStateTrim(ParseContext $ctx, string $curChar): bool { - if (isset($emailAddress->allowedWhitespace[$curChar])) { + if (isset($ctx->allowedWhitespace[$curChar])) { return false; } - $emailAddress->state = self::STATE_ADDRESS; + $ctx->state = self::STATE_ADDRESS; if ('"' == $curChar) { - $emailAddress->original_address .= $curChar; - $emailAddress->state = self::STATE_QUOTE; + $ctx->original_address .= $curChar; + $ctx->state = self::STATE_QUOTE; return false; } if ('(' == $curChar) { - $emailAddress->original_address .= $curChar; - $emailAddress->state = self::STATE_COMMENT; + $ctx->original_address .= $curChar; + $ctx->state = self::STATE_COMMENT; // A leading comment opens at nest level 1 (matches the // STATE_ADDRESS entry); without this an unbalanced nested // comment like "((x)" would appear closed after one ")". - $emailAddress->commentNestLevel = 1; + $ctx->commentNestLevel = 1; return false; } @@ -517,81 +513,81 @@ private function handleStateTrim(ParseContext $emailAddress, string $curChar): b * branches are handled inline; the heavier ones (CFWS, '@', '.', atext and * non-atext runs) delegate to dedicated helpers below. */ - private function handleStateAddress(ParseContext $emailAddress, string $curChar, ?string $prevChar, int $i): void + private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $prevChar, int $i): void { - if (!isset($emailAddress->separators[$curChar]) || !$emailAddress->multiple) { - $emailAddress->original_address .= $curChar; + if (!isset($ctx->separators[$curChar]) || !$ctx->multiple) { + $ctx->original_address .= $curChar; } - if ($emailAddress->after_closing_quote) { - $emailAddress->after_closing_quote = false; + if ($ctx->after_closing_quote) { + $ctx->after_closing_quote = false; // RFC 5322 §3.2.4: a quoted-string is a whole word. Only a dot // (obs word.word), '@', angle brackets, CFWS, or a separator may // follow it — atext or a second quote directly abutting it is invalid. if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; - $emailAddress->invalid_reason_code = Err::AtextAfterQuotedString; + $ctx->invalid = true; + $ctx->invalid_reason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; + $ctx->invalid_reason_code = Err::AtextAfterQuotedString; } } - if ($emailAddress->comment_after_local_atext) { - $emailAddress->comment_after_local_atext = false; + if ($ctx->comment_after_local_atext) { + $ctx->comment_after_local_atext = false; // atext or a second quoted-string resuming the word after a comment. // Defer the verdict: it is only an error if this turns out to be an // addr-spec local part (resolved at '@'); in a display-name phrase // "word CFWS word" is legal and is cleared at '<'. if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $emailAddress->local_atom_split_by_comment = true; + $ctx->local_atom_split_by_comment = true; } } if ('(' == $curChar) { // Handle comment - $emailAddress->state = self::STATE_COMMENT; - $emailAddress->commentNestLevel = 1; + $ctx->state = self::STATE_COMMENT; + $ctx->commentNestLevel = 1; return; - } elseif (isset($emailAddress->separators[$curChar])) { + } elseif (isset($ctx->separators[$curChar])) { // Handle separator (comma, semicolon, etc.) - if ($emailAddress->multiple && (self::STATE_DOMAIN == $emailAddress->subState || self::STATE_AFTER_DOMAIN == $emailAddress->subState)) { + if ($ctx->multiple && (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState)) { // If we're already in the domain part, this should be the end of the address - $emailAddress->state = self::STATE_END_ADDRESS; + $ctx->state = self::STATE_END_ADDRESS; return; } else { - $emailAddress->invalid = true; - if ($emailAddress->multiple || ($i + 5) >= $emailAddress->len) { - $emailAddress->invalid_reason = 'Misplaced separator or missing "@" symbol'; - $emailAddress->invalid_reason_code = Err::MisplacedSeparator; + $ctx->invalid = true; + if ($ctx->multiple || ($i + 5) >= $ctx->len) { + $ctx->invalid_reason = 'Misplaced separator or missing "@" symbol'; + $ctx->invalid_reason_code = Err::MisplacedSeparator; } else { - $emailAddress->invalid_reason = 'Separator not permitted - only one email address allowed'; - $emailAddress->invalid_reason_code = Err::SeparatorNotPermitted; + $ctx->invalid_reason = 'Separator not permitted - only one email address allowed'; + $ctx->invalid_reason_code = Err::SeparatorNotPermitted; } } - } elseif (isset($emailAddress->allowedWhitespace[$curChar])) { - if ($this->handleAddressWhitespace($emailAddress, $curChar, $i)) { + } elseif (isset($ctx->allowedWhitespace[$curChar])) { + if ($this->handleAddressWhitespace($ctx, $curChar, $i)) { return; } } elseif ('<' == $curChar) { // Start of the local part - if (self::STATE_LOCAL_PART == $emailAddress->subState || self::STATE_DOMAIN == $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; - $emailAddress->invalid_reason_code = Err::MultipleOpeningAngle; + if (self::STATE_LOCAL_PART == $ctx->subState || self::STATE_DOMAIN == $ctx->subState) { + $ctx->invalid = true; + $ctx->invalid_reason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; + $ctx->invalid_reason_code = Err::MultipleOpeningAngle; } else { // Here should be the start of the local part for sure everything else then is part of the name - $emailAddress->subState = self::STATE_LOCAL_PART; - $emailAddress->special_char_in_substate = null; - $emailAddress->in_angle_addr = true; + $ctx->subState = self::STATE_LOCAL_PART; + $ctx->special_char_in_substate = null; + $ctx->in_angle_addr = true; // Any quote before `<` was the display name, not the local part; // clear the quoted flag the closing-quote handler set so the real // local-part inside the angle-addr starts unquoted. Likewise any // comment before `<` sat in the display-name phrase (legal there), // not an addr-spec local part — clear the deferred split marker. - $emailAddress->local_part_quoted = false; - $emailAddress->local_atom_split_by_comment = false; - $this->handleQuote($emailAddress); + $ctx->local_part_quoted = false; + $ctx->local_atom_split_by_comment = false; + $this->handleQuote($ctx); } } elseif ('>' == $curChar) { // Should be the end of the domain part. Accept STATE_DOMAIN @@ -599,122 +595,122 @@ private function handleStateAddress(ParseContext $emailAddress, string $curChar, // domain-literal (``, `]` transitions to AFTER_DOMAIN) // or trailing CFWS reaches — but only when a domain or IP is actually // present, so `` / `` still fail. - if (self::STATE_DOMAIN == $emailAddress->subState - || (self::STATE_AFTER_DOMAIN == $emailAddress->subState - && ('' !== $emailAddress->domain || '' !== $emailAddress->ip))) { - $emailAddress->subState = self::STATE_AFTER_DOMAIN; - $emailAddress->in_angle_addr = false; + if (self::STATE_DOMAIN == $ctx->subState + || (self::STATE_AFTER_DOMAIN == $ctx->subState + && ('' !== $ctx->domain || '' !== $ctx->ip))) { + $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->in_angle_addr = false; } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Did not find domain name before a closing '>'"; - $emailAddress->invalid_reason_code = Err::MissingDomainBeforeClosingAngle; + $ctx->invalid = true; + $ctx->invalid_reason = "Did not find domain name before a closing '>'"; + $ctx->invalid_reason_code = Err::MissingDomainBeforeClosingAngle; } } elseif ('"' == $curChar) { // If we hit a quote - change to the quote state, unless it's in the domain, in which case it's error - if (self::STATE_DOMAIN == $emailAddress->subState || self::STATE_AFTER_DOMAIN == $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Quote \'"\' found where it shouldn\'t be'; - $emailAddress->invalid_reason_code = Err::MisplacedQuote; + if (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState) { + $ctx->invalid = true; + $ctx->invalid_reason = 'Quote \'"\' found where it shouldn\'t be'; + $ctx->invalid_reason_code = Err::MisplacedQuote; } else { - $emailAddress->state = self::STATE_QUOTE; + $ctx->state = self::STATE_QUOTE; } } elseif ('@' == $curChar) { - $this->handleAddressAt($emailAddress); + $this->handleAddressAt($ctx); } elseif ('[' == $curChar) { // A domain literal ("[...]") is the entire domain (RFC 5322 §3.4.1), // so '[' is only valid at the start of the domain — not in the local // part, and not after domain characters or a first literal. Accepting // it mid-domain used to set both domain and ip and surface as an // internal "parser confusion" error. - if (self::STATE_DOMAIN != $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character '[' in email address"; - $emailAddress->invalid_reason_code = Err::InvalidOpeningBracket; - } elseif ('' !== $emailAddress->domain || '' !== $emailAddress->ip) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; - $emailAddress->invalid_reason_code = Err::InvalidOpeningBracket; + if (self::STATE_DOMAIN != $ctx->subState) { + $ctx->invalid = true; + $ctx->invalid_reason = "Invalid character '[' in email address"; + $ctx->invalid_reason_code = Err::InvalidOpeningBracket; + } elseif ('' !== $ctx->domain || '' !== $ctx->ip) { + $ctx->invalid = true; + $ctx->invalid_reason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; + $ctx->invalid_reason_code = Err::InvalidOpeningBracket; } else { - $emailAddress->state = self::STATE_SQUARE_BRACKET; + $ctx->state = self::STATE_SQUARE_BRACKET; } } elseif ('.' == $curChar) { // Period placement (RFC 5322 §3.4) — inlined as it is per-character hot. if ('.' == $prevChar && !$this->options->allowObsLocalPart) { // Consecutive dots only allowed when obs-local-part is enabled - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Email address should not contain two dots '.' in a row"; - $emailAddress->invalid_reason_code = Err::ConsecutiveDots; - } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { - if (!$emailAddress->local_part_parsed && !$this->options->allowObsLocalPart) { + $ctx->invalid = true; + $ctx->invalid_reason = "Email address should not contain two dots '.' in a row"; + $ctx->invalid_reason_code = Err::ConsecutiveDots; + } elseif (self::STATE_LOCAL_PART == $ctx->subState) { + if (!$ctx->local_part_parsed && !$this->options->allowObsLocalPart) { // Leading dots only allowed when obs-local-part is enabled - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Email address can not start with '.'"; - $emailAddress->invalid_reason_code = Err::LeadingDot; + $ctx->invalid = true; + $ctx->invalid_reason = "Email address can not start with '.'"; + $ctx->invalid_reason_code = Err::LeadingDot; } else { - $emailAddress->local_part_parsed .= $curChar; + $ctx->local_part_parsed .= $curChar; } - } elseif (self::STATE_DOMAIN == $emailAddress->subState) { - $emailAddress->domain .= $curChar; - } elseif (self::STATE_AFTER_DOMAIN == $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Stray period '.' found after domain of email address"; - $emailAddress->invalid_reason_code = Err::StrayPeriodAfterDomain; - } elseif (self::STATE_START == $emailAddress->subState) { - if ($emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; + } elseif (self::STATE_DOMAIN == $ctx->subState) { + $ctx->domain .= $curChar; + } elseif (self::STATE_AFTER_DOMAIN == $ctx->subState) { + $ctx->invalid = true; + $ctx->invalid_reason = "Stray period '.' found after domain of email address"; + $ctx->invalid_reason_code = Err::StrayPeriodAfterDomain; + } elseif (self::STATE_START == $ctx->subState) { + if ($ctx->quote_temp) { + $ctx->address_temp .= $ctx->quote_temp; + $ctx->address_temp_quoted = true; + $ctx->quote_temp = ''; } - $emailAddress->address_temp .= $curChar; - ++$emailAddress->address_temp_period; + $ctx->address_temp .= $curChar; + ++$ctx->address_temp_period; } else { // RFC 5322 §3.4: a period is not an atext character and is not // valid in an unquoted display name or at the start of an address. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; - $emailAddress->invalid_reason_code = Err::StrayPeriod; + $ctx->invalid = true; + $ctx->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; + $ctx->invalid_reason_code = Err::StrayPeriod; } } elseif (preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { // atext (RFC 5322 §3.2.3) — the per-character hot path; inlined to keep // one call per character. Appends to the local-part, display name, // domain or pending word per the sub-state. - if (isset($emailAddress->bannedChars[$curChar])) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::CharacterNotAllowed; + if (isset($ctx->bannedChars[$curChar])) { + $ctx->invalid = true; + $ctx->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; + $ctx->invalid_reason_code = Err::CharacterNotAllowed; } elseif (('/' == $curChar || '|' == $curChar) && - !$emailAddress->local_part_parsed && !$emailAddress->address_temp && !$emailAddress->quote_temp && !$emailAddress->name_parsed) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterAtStart; - } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { + !$ctx->local_part_parsed && !$ctx->address_temp && !$ctx->quote_temp && !$ctx->name_parsed) { + $ctx->invalid = true; + $ctx->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalid_reason_code = Err::InvalidCharacterAtStart; + } elseif (self::STATE_LOCAL_PART == $ctx->subState) { // Legitimate character - Determine where to append based on the current 'substate' - if ($emailAddress->quote_temp) { - $emailAddress->local_part_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->local_part_quoted = true; + if ($ctx->quote_temp) { + $ctx->local_part_parsed .= $ctx->quote_temp; + $ctx->quote_temp = ''; + $ctx->local_part_quoted = true; } - $emailAddress->local_part_parsed .= $curChar; - } elseif (self::STATE_NAME == $emailAddress->subState) { - if ($emailAddress->quote_temp) { - $emailAddress->name_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->name_quoted = true; + $ctx->local_part_parsed .= $curChar; + } elseif (self::STATE_NAME == $ctx->subState) { + if ($ctx->quote_temp) { + $ctx->name_parsed .= $ctx->quote_temp; + $ctx->quote_temp = ''; + $ctx->name_quoted = true; } - $emailAddress->name_parsed .= $curChar; - } elseif (self::STATE_DOMAIN == $emailAddress->subState) { - $emailAddress->domain .= $curChar; + $ctx->name_parsed .= $curChar; + } elseif (self::STATE_DOMAIN == $ctx->subState) { + $ctx->domain .= $curChar; } else { - if ($emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; + if ($ctx->quote_temp) { + $ctx->address_temp .= $ctx->quote_temp; + $ctx->address_temp_quoted = true; + $ctx->quote_temp = ''; } - $emailAddress->address_temp .= $curChar; + $ctx->address_temp .= $curChar; } } else { - $this->handleAddressNonAtext($emailAddress, $curChar); + $this->handleAddressNonAtext($ctx, $curChar); } } @@ -726,15 +722,15 @@ private function handleStateAddress(ParseContext $emailAddress, string $curChar, * @return bool true when the address is complete and the caller should stop * processing this character (STATE_END_ADDRESS was set) */ - private function handleAddressWhitespace(ParseContext $emailAddress, string $curChar, int $i): bool + private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int $i): bool { // Look ahead past the WSP run to find the next significant character; that // character determines which kind of CFWS this is and whether it can be // silently absorbed or if it marks an end-of-address / error. $foundComment = false; $lookAheadChar = null; - for ($j = ($i + 1); $j < $emailAddress->len; ++$j) { - $c = $emailAddress->chars[$j]; + for ($j = ($i + 1); $j < $ctx->len; ++$j) { + $c = $ctx->chars[$j]; if ('(' === $c) { $foundComment = true; @@ -755,29 +751,29 @@ private function handleAddressWhitespace(ParseContext $emailAddress, string $cur // strictMultiWhitespace, this obsolete internal folding is instead // rejected per-address (whitespace still separates addresses). $cfwsAbsorbed = false; - if (!$foundComment && $lookAheadChar !== null && !($emailAddress->multiple && $this->options->strictMultiWhitespace)) { - if (self::STATE_LOCAL_PART === $emailAddress->subState) { + if (!$foundComment && $lookAheadChar !== null && !($ctx->multiple && $this->options->strictMultiWhitespace)) { + if (self::STATE_LOCAL_PART === $ctx->subState) { if ('@' === $lookAheadChar) { // Trailing CFWS of the local-part dot-atom: "local @domain". $cfwsAbsorbed = true; } elseif ( - $emailAddress->in_angle_addr - && $emailAddress->local_part_parsed === '' - && $emailAddress->address_temp === '' - && $emailAddress->quote_temp === '' + $ctx->in_angle_addr + && $ctx->local_part_parsed === '' + && $ctx->address_temp === '' + && $ctx->quote_temp === '' ) { // Leading CFWS inside angle-addr: "< local@domain>". $cfwsAbsorbed = true; } - } elseif (self::STATE_DOMAIN === $emailAddress->subState) { - if ($emailAddress->domain === '' && $emailAddress->ip === '') { + } elseif (self::STATE_DOMAIN === $ctx->subState) { + if ($ctx->domain === '' && $ctx->ip === '') { // Leading CFWS of the domain dot-atom: "local@ domain". $cfwsAbsorbed = true; } } elseif ( - self::STATE_START === $emailAddress->subState + self::STATE_START === $ctx->subState && '@' === $lookAheadChar - && $emailAddress->address_temp !== '' + && $ctx->address_temp !== '' ) { // Top-level addr-spec with no angle-addr: "local @domain". // The accumulated address_temp IS the local-part; absorb the @@ -789,62 +785,62 @@ private function handleAddressWhitespace(ParseContext $emailAddress, string $cur if ($cfwsAbsorbed) { // Silently skip the whitespace character; state unchanged. } elseif ($foundComment) { - if (self::STATE_DOMAIN == $emailAddress->subState) { - $emailAddress->subState = self::STATE_AFTER_DOMAIN; - } elseif (self::STATE_LOCAL_PART == $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Email address contains whitespace'; - $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; + if (self::STATE_DOMAIN == $ctx->subState) { + $ctx->subState = self::STATE_AFTER_DOMAIN; + } elseif (self::STATE_LOCAL_PART == $ctx->subState) { + $ctx->invalid = true; + $ctx->invalid_reason = 'Email address contains whitespace'; + $ctx->invalid_reason_code = Err::WhitespaceInAddress; } } elseif ( - $emailAddress->in_angle_addr - && self::STATE_DOMAIN == $emailAddress->subState + $ctx->in_angle_addr + && self::STATE_DOMAIN == $ctx->subState && $lookAheadChar === '>' ) { // Trailing CFWS inside angle-addr before `>`: "". // Absorb and transition as if we saw `>` next. - $emailAddress->subState = self::STATE_AFTER_DOMAIN; + $ctx->subState = self::STATE_AFTER_DOMAIN; } elseif ( - $emailAddress->multiple + $ctx->multiple && $lookAheadChar !== null - && isset($emailAddress->separators[$lookAheadChar]) - && (self::STATE_DOMAIN == $emailAddress->subState || self::STATE_AFTER_DOMAIN == $emailAddress->subState) + && isset($ctx->separators[$lookAheadChar]) + && (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState) ) { // Whitespace between the domain and a following separator // ("a@b.com , c@d.com"): absorb it and let the separator terminate // the address, rather than ending here and leaving the separator to // open an empty next address (a "misplaced separator" error). - $emailAddress->subState = self::STATE_AFTER_DOMAIN; - } elseif ($emailAddress->useWhitespaceAsSeparator && - (self::STATE_DOMAIN == $emailAddress->subState || self::STATE_AFTER_DOMAIN == $emailAddress->subState)) { + $ctx->subState = self::STATE_AFTER_DOMAIN; + } elseif ($ctx->useWhitespaceAsSeparator && + (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState)) { // Already past `@` and whitespace-as-separator: end address. // Single mode has no next address to separate; if the trailing // whitespace run contains a whitespace char excluded from the // effective set (e.g. CR/LF in strict single mode), that is // invalid trailing content — a dangling fold — not a terminator. - if (!$emailAddress->multiple) { - for ($k = $i; $k < $emailAddress->len && isset(self::WHITESPACE[$emailAddress->chars[$k]]); ++$k) { - if (!isset($emailAddress->allowedWhitespace[$emailAddress->chars[$k]])) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Disallowed whitespace after address'; - $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; + if (!$ctx->multiple) { + for ($k = $i; $k < $ctx->len && isset(self::WHITESPACE[$ctx->chars[$k]]); ++$k) { + if (!isset($ctx->allowedWhitespace[$ctx->chars[$k]])) { + $ctx->invalid = true; + $ctx->invalid_reason = 'Disallowed whitespace after address'; + $ctx->invalid_reason_code = Err::WhitespaceInAddress; break; } } } - $emailAddress->state = self::STATE_END_ADDRESS; + $ctx->state = self::STATE_END_ADDRESS; return true; } else { - if (self::STATE_LOCAL_PART == $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Email address contains whitespace'; - $emailAddress->invalid_reason_code = Err::WhitespaceInAddress; + if (self::STATE_LOCAL_PART == $ctx->subState) { + $ctx->invalid = true; + $ctx->invalid_reason = 'Email address contains whitespace'; + $ctx->invalid_reason_code = Err::WhitespaceInAddress; } else { // Display-name phrase: absorb into name_parsed. - $this->handleQuote($emailAddress); - $emailAddress->name_parsed .= $curChar; + $this->handleQuote($ctx); + $ctx->name_parsed .= $curChar; } } @@ -855,65 +851,65 @@ private function handleAddressWhitespace(ParseContext $emailAddress, string $cur * STATE_ADDRESS '@' handling: reject a misplaced '@', start an obs-route, or * flush the accumulated word(s) into the local-part and enter the domain. */ - private function handleAddressAt(ParseContext $emailAddress): void + private function handleAddressAt(ParseContext $ctx): void { - if (self::STATE_DOMAIN == $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Multiple at '@' symbols in email address"; - $emailAddress->invalid_reason_code = Err::MultipleAtSymbols; - } elseif (self::STATE_AFTER_DOMAIN == $emailAddress->subState) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Stray at '@' symbol found after domain name"; - $emailAddress->invalid_reason_code = Err::StrayAtAfterDomain; - } elseif (null !== $emailAddress->special_char_in_substate) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$emailAddress->special_char_in_substate}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; - } elseif ($emailAddress->local_atom_split_by_comment) { + if (self::STATE_DOMAIN == $ctx->subState) { + $ctx->invalid = true; + $ctx->invalid_reason = "Multiple at '@' symbols in email address"; + $ctx->invalid_reason_code = Err::MultipleAtSymbols; + } elseif (self::STATE_AFTER_DOMAIN == $ctx->subState) { + $ctx->invalid = true; + $ctx->invalid_reason = "Stray at '@' symbol found after domain name"; + $ctx->invalid_reason_code = Err::StrayAtAfterDomain; + } elseif (null !== $ctx->special_char_in_substate) { + $ctx->invalid = true; + $ctx->invalid_reason = "Invalid character found in email address local part: '{$ctx->special_char_in_substate}'"; + $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; + } elseif ($ctx->local_atom_split_by_comment) { // The `@` confirms this was an addr-spec local part, so the comment // that split its atext (RFC 5322 §3.2.3) is invalid here. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; - $emailAddress->invalid_reason_code = Err::AtextAfterComment; + $ctx->invalid = true; + $ctx->invalid_reason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; + $ctx->invalid_reason_code = Err::AtextAfterComment; } elseif ( $this->options->allowObsRoute - && $emailAddress->in_angle_addr - && $emailAddress->obs_route === '' - && $emailAddress->local_part_parsed === '' - && $emailAddress->quote_temp === '' - && $emailAddress->address_temp === '' + && $ctx->in_angle_addr + && $ctx->obs_route === '' + && $ctx->local_part_parsed === '' + && $ctx->quote_temp === '' + && $ctx->address_temp === '' // An empty *quoted* local part (`<""@host>`) is a real local // part, not the "no local part" that starts an obs-route. - && !$emailAddress->local_part_quoted + && !$ctx->local_part_quoted ) { // RFC 5322 §4.4 obs-route: first `@` seen inside `<...>` with no // preceding local-part starts the source-route prefix. Consume // the remainder until `:` via STATE_OBS_ROUTE, then resume // addr-spec parsing with local-part reset. - $emailAddress->state = self::STATE_OBS_ROUTE; - $emailAddress->obs_route = '@'; + $ctx->state = self::STATE_OBS_ROUTE; + $ctx->obs_route = '@'; } else { - $emailAddress->subState = self::STATE_DOMAIN; + $ctx->subState = self::STATE_DOMAIN; // A trailing quoted word after earlier words ("x"."y", x."y") // is the final word of an obs-local-part (RFC 5322 §3.4.1: // word *("." word), word = atom / quoted-string). Flush it onto // the accumulated local part, exactly as the dot handler flushes // earlier words — not a parser error. - if ($emailAddress->address_temp && $emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; + if ($ctx->address_temp && $ctx->quote_temp) { + $ctx->address_temp .= $ctx->quote_temp; + $ctx->address_temp_quoted = true; + $ctx->quote_temp = ''; } - if ($emailAddress->quote_temp) { - $emailAddress->local_part_parsed = $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->local_part_quoted = true; - } elseif ($emailAddress->address_temp) { - $emailAddress->local_part_parsed = $emailAddress->address_temp; - $emailAddress->address_temp = ''; - $emailAddress->local_part_quoted = $emailAddress->address_temp_quoted; - $emailAddress->address_temp_quoted = false; - $emailAddress->address_temp_period = 0; + if ($ctx->quote_temp) { + $ctx->local_part_parsed = $ctx->quote_temp; + $ctx->quote_temp = ''; + $ctx->local_part_quoted = true; + } elseif ($ctx->address_temp) { + $ctx->local_part_parsed = $ctx->address_temp; + $ctx->address_temp = ''; + $ctx->local_part_quoted = $ctx->address_temp_quoted; + $ctx->address_temp_quoted = false; + $ctx->address_temp_period = 0; } } } @@ -922,100 +918,100 @@ private function handleAddressAt(ParseContext $emailAddress): void * STATE_ADDRESS non-atext handling — UTF-8 domain/local-part characters * (punycode-tested for the domain) plus rejection of other stray bytes. */ - private function handleAddressNonAtext(ParseContext $emailAddress, string $curChar): void + private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void { - if (self::STATE_DOMAIN == $emailAddress->subState) { + if (self::STATE_DOMAIN == $ctx->subState) { if ($this->isUtf8Char($curChar)) { - $emailAddress->domain .= $curChar; + $ctx->domain .= $curChar; } else { try { // Test by trying to encode the current character into Punycode // Punycode should match the traditional domain name subset of characters $punycoded = idn_to_ascii($curChar); if ($punycoded !== false && preg_match('/[a-z0-9\-]/', $punycoded)) { - $emailAddress->domain .= $curChar; + $ctx->domain .= $curChar; } else { - $emailAddress->invalid = true; + $ctx->invalid = true; } } catch (\Exception $e) { - $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$emailAddress->original_address: {$emailAddress->original_address}\n\$emails: {$emailAddress->emails}"); - $emailAddress->invalid = true; + $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$ctx->original_address: {$ctx->original_address}\n\$emails: {$ctx->emails}"); + $ctx->invalid = true; } - if ($emailAddress->invalid) { - $emailAddress->invalid_reason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInDomain; + if ($ctx->invalid) { + $ctx->invalid_reason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalid_reason_code = Err::InvalidCharacterInDomain; } } - } elseif (self::STATE_START === $emailAddress->subState || self::STATE_LOCAL_PART === $emailAddress->subState) { + } elseif (self::STATE_START === $ctx->subState || self::STATE_LOCAL_PART === $ctx->subState) { // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently - if ($emailAddress->subState === self::STATE_START && $emailAddress->quote_temp) { - $emailAddress->address_temp .= $emailAddress->quote_temp; - $emailAddress->address_temp_quoted = true; - $emailAddress->quote_temp = ''; - } elseif ($emailAddress->subState === self::STATE_LOCAL_PART && $emailAddress->quote_temp) { - $emailAddress->local_part_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->local_part_quoted = true; + if ($ctx->subState === self::STATE_START && $ctx->quote_temp) { + $ctx->address_temp .= $ctx->quote_temp; + $ctx->address_temp_quoted = true; + $ctx->quote_temp = ''; + } elseif ($ctx->subState === self::STATE_LOCAL_PART && $ctx->quote_temp) { + $ctx->local_part_parsed .= $ctx->quote_temp; + $ctx->quote_temp = ''; + $ctx->local_part_quoted = true; } $isUtf8 = $this->isUtf8Char($curChar); if ($isUtf8 && $this->options->allowUtf8LocalPart) { // UTF-8 character allowed - if ($emailAddress->subState === self::STATE_START) { - $emailAddress->address_temp .= $curChar; + if ($ctx->subState === self::STATE_START) { + $ctx->address_temp .= $curChar; } else { - $emailAddress->local_part_parsed .= $curChar; + $ctx->local_part_parsed .= $curChar; } } elseif ($isUtf8) { // UTF-8 present but not allowed by rules — collect and reject in validateLocalPart() - if ($emailAddress->subState === self::STATE_START) { - $emailAddress->address_temp .= $curChar; + if ($ctx->subState === self::STATE_START) { + $ctx->address_temp .= $curChar; // ??= preserves the first invalid character seen; later chars must not overwrite it - $emailAddress->special_char_in_substate ??= $curChar; + $ctx->special_char_in_substate ??= $curChar; } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; + $ctx->invalid = true; + $ctx->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; + $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; } } else { // Non-UTF-8, non-atext character - if ($emailAddress->subState === self::STATE_START) { + if ($ctx->subState === self::STATE_START) { // ??= preserves the first invalid character seen; later chars must not overwrite it - $emailAddress->special_char_in_substate ??= $curChar; - $emailAddress->address_temp .= $curChar; + $ctx->special_char_in_substate ??= $curChar; + $ctx->address_temp .= $curChar; } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInLocalPart; + $ctx->invalid = true; + $ctx->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; + $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; } } - } elseif (self::STATE_NAME === $emailAddress->subState) { - if ($emailAddress->quote_temp) { - $emailAddress->name_parsed .= $emailAddress->quote_temp; - $emailAddress->quote_temp = ''; - $emailAddress->name_quoted = true; + } elseif (self::STATE_NAME === $ctx->subState) { + if ($ctx->quote_temp) { + $ctx->name_parsed .= $ctx->quote_temp; + $ctx->quote_temp = ''; + $ctx->name_quoted = true; } - $emailAddress->special_char_in_substate = $curChar; - $emailAddress->name_parsed .= $curChar; + $ctx->special_char_in_substate = $curChar; + $ctx->name_parsed .= $curChar; } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; - $emailAddress->invalid_reason_code = Err::InvalidCharacterInAddress; + $ctx->invalid = true; + $ctx->invalid_reason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalid_reason_code = Err::InvalidCharacterInAddress; } } /** * STATE_SQUARE_BRACKET: accumulate a domain-literal IP until the closing ']'. */ - private function handleStateSquareBracket(ParseContext $emailAddress, string $curChar): void + private function handleStateSquareBracket(ParseContext $ctx, string $curChar): void { - $emailAddress->original_address .= $curChar; + $ctx->original_address .= $curChar; if (']' == $curChar) { - $emailAddress->subState = self::STATE_AFTER_DOMAIN; - $emailAddress->state = self::STATE_ADDRESS; + $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->state = self::STATE_ADDRESS; } else { - $emailAddress->ip .= $curChar; + $ctx->ip .= $curChar; } } @@ -1024,22 +1020,22 @@ private function handleStateSquareBracket(ParseContext $emailAddress, string $cu * prefix inside angle-addr. On `:` resume addr-spec parsing; an unterminated * route (`>` or end of input before `:`) is invalid. */ - private function handleStateObsRoute(ParseContext $emailAddress, string $curChar): void + private function handleStateObsRoute(ParseContext $ctx, string $curChar): void { - $emailAddress->original_address .= $curChar; + $ctx->original_address .= $curChar; if (':' == $curChar) { - $emailAddress->state = self::STATE_ADDRESS; - $emailAddress->subState = self::STATE_LOCAL_PART; + $ctx->state = self::STATE_ADDRESS; + $ctx->subState = self::STATE_LOCAL_PART; } elseif ('>' == $curChar) { // `<@host>` without a colon — incomplete obs-route. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Incomplete obs-route: missing colon before closing angle-bracket'; - $emailAddress->invalid_reason_code = Err::IncompleteAddress; - $emailAddress->in_angle_addr = false; - $emailAddress->state = self::STATE_ADDRESS; - $emailAddress->subState = self::STATE_AFTER_DOMAIN; + $ctx->invalid = true; + $ctx->invalid_reason = 'Incomplete obs-route: missing colon before closing angle-bracket'; + $ctx->invalid_reason_code = Err::IncompleteAddress; + $ctx->in_angle_addr = false; + $ctx->state = self::STATE_ADDRESS; + $ctx->subState = self::STATE_AFTER_DOMAIN; } else { - $emailAddress->obs_route .= $curChar; + $ctx->obs_route .= $curChar; } } @@ -1048,9 +1044,9 @@ private function handleStateObsRoute(ParseContext $emailAddress, string $curChar * rejecting bare C0 controls, until the real closing quote returns to * STATE_ADDRESS. */ - private function handleStateQuote(ParseContext $emailAddress, string $curChar, int $i): void + private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): void { - $emailAddress->original_address .= $curChar; + $ctx->original_address .= $curChar; if ('"' == $curChar) { // RFC 5322 §3.2.4 / RFC 5321 §4.1.2: detect escaped quote by counting // consecutive backslashes immediately before this position. An odd count @@ -1058,7 +1054,7 @@ private function handleStateQuote(ParseContext $emailAddress, string $curChar, i // means it is the real closing delimiter. $backslashCount = 0; for ($j = $i - 1; $j >= 0; --$j) { - if ('\\' == $emailAddress->chars[$j]) { + if ('\\' == $ctx->chars[$j]) { ++$backslashCount; } else { break; @@ -1066,7 +1062,7 @@ private function handleStateQuote(ParseContext $emailAddress, string $curChar, i } if ($backslashCount && 1 == $backslashCount % 2) { // Odd number of backslashes = this quote is escaped - $emailAddress->quote_temp .= $curChar; + $ctx->quote_temp .= $curChar; } else { // Even backslashes (or zero) = this is the real closing quote. // Record that a quote was seen so an *empty* quoted local-part @@ -1074,18 +1070,18 @@ private function handleStateQuote(ParseContext $emailAddress, string $curChar, i // empty in that case, so the '@' handler below can't tell. A // display-name quote self-corrects: the real local-part resets // this flag from address_temp_quoted when '@' is reached. - $emailAddress->state = self::STATE_ADDRESS; - $emailAddress->local_part_quoted = true; - $emailAddress->after_closing_quote = true; + $ctx->state = self::STATE_ADDRESS; + $ctx->local_part_quoted = true; + $ctx->after_closing_quote = true; } } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { // qtext (RFC 5322 §3.2.4) excludes C0 controls; a bare CR or LF // inside a quoted-string is not valid (only a CRLF fold with WSP is). - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Control character in quoted string'; - $emailAddress->invalid_reason_code = Err::InvalidCharInQuotedString; + $ctx->invalid = true; + $ctx->invalid_reason = 'Control character in quoted string'; + $ctx->invalid_reason_code = Err::InvalidCharInQuotedString; } else { - $emailAddress->quote_temp .= $curChar; + $ctx->quote_temp .= $curChar; } } @@ -1093,60 +1089,60 @@ private function handleStateQuote(ParseContext $emailAddress, string $curChar, i * STATE_COMMENT (RFC 5322 §3.2.2): accumulate comment text, tracking nesting * and quoted-pairs, and on close flag a comment that split a local-part atom. */ - private function handleStateComment(ParseContext $emailAddress, string $curChar): void + private function handleStateComment(ParseContext $ctx, string $curChar): void { - $emailAddress->original_address .= $curChar; - if ($emailAddress->comment_escaped) { + $ctx->original_address .= $curChar; + if ($ctx->comment_escaped) { // Target of a quoted-pair — literal, never structural. - $emailAddress->comment_escaped = false; - $emailAddress->comment_temp .= $curChar; + $ctx->comment_escaped = false; + $ctx->comment_temp .= $curChar; } elseif ('\\' == $curChar) { // RFC 5322 §3.2.1: backslash starts a quoted-pair; the next // character is escaped (so "\)" does not close the comment). - $emailAddress->comment_escaped = true; + $ctx->comment_escaped = true; } elseif (')' == $curChar) { - --$emailAddress->commentNestLevel; - if ($emailAddress->commentNestLevel <= 0) { + --$ctx->commentNestLevel; + if ($ctx->commentNestLevel <= 0) { // End of comment - save it - if ($emailAddress->comment_temp) { - $emailAddress->comments[] = $emailAddress->comment_temp; - $emailAddress->comment_temp = ''; + if ($ctx->comment_temp) { + $ctx->comments[] = $ctx->comment_temp; + $ctx->comment_temp = ''; } - $emailAddress->state = self::STATE_ADDRESS; + $ctx->state = self::STATE_ADDRESS; // Flag a comment that closed mid-word in the local part (before // `@`), so a token resuming the word can be rejected. Covers a // preceding atext run (address_temp/local_part_parsed) or a // preceding quoted-string (local_part_quoted) — "x"(c)y is as // invalid as x(c)y. Domain and display-name comments are excluded. - if ((self::STATE_LOCAL_PART === $emailAddress->subState || self::STATE_START === $emailAddress->subState) - && ('' !== $emailAddress->address_temp || '' !== $emailAddress->local_part_parsed || $emailAddress->local_part_quoted)) { - $emailAddress->comment_after_local_atext = true; + if ((self::STATE_LOCAL_PART === $ctx->subState || self::STATE_START === $ctx->subState) + && ('' !== $ctx->address_temp || '' !== $ctx->local_part_parsed || $ctx->local_part_quoted)) { + $ctx->comment_after_local_atext = true; } } else { // Nested comment closing parenthesis - $emailAddress->comment_temp .= $curChar; + $ctx->comment_temp .= $curChar; } } elseif ('(' == $curChar) { - ++$emailAddress->commentNestLevel; - if ($emailAddress->commentNestLevel > 1) { + ++$ctx->commentNestLevel; + if ($ctx->commentNestLevel > 1) { // Nested comment opening parenthesis - $emailAddress->comment_temp .= $curChar; + $ctx->comment_temp .= $curChar; } } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { // ctext (RFC 5322 §3.2.3) excludes C0 controls; a bare CR or LF // inside a comment is not part of valid folding. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Control character in comment'; - $emailAddress->invalid_reason_code = Err::ControlCharInComment; + $ctx->invalid = true; + $ctx->invalid_reason = 'Control character in comment'; + $ctx->invalid_reason_code = Err::ControlCharInComment; } elseif ($this->options->rejectC1Controls && preg_match('/[\x{0080}-\x{009F}]/u', $curChar)) { // RFC 6532 §3.1: C1 controls (2-byte UTF-8) are prohibited in // internationalized content, comments included. - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Control character in comment'; - $emailAddress->invalid_reason_code = Err::ControlCharInComment; + $ctx->invalid = true; + $ctx->invalid_reason = 'Control character in comment'; + $ctx->invalid_reason_code = Err::ControlCharInComment; } else { // Regular comment character - $emailAddress->comment_temp .= $curChar; + $ctx->comment_temp .= $curChar; } } @@ -1157,21 +1153,21 @@ private function handleStateComment(ParseContext $emailAddress, string $curChar) * Periods in an unquoted name are invalid per RFC 5322 §3.4 — the display * name must be a phrase, and a period is not an atext character. */ - private function handleQuote(ParseContext $emailAddress): void + private function handleQuote(ParseContext $ctx): void { - if ($emailAddress->quote_temp) { - $emailAddress->name_parsed .= $emailAddress->quote_temp; - $emailAddress->name_quoted = true; - $emailAddress->quote_temp = ''; - } elseif ($emailAddress->address_temp) { - $emailAddress->name_parsed .= $emailAddress->address_temp; - $emailAddress->name_quoted = $emailAddress->address_temp_quoted; - $emailAddress->address_temp_quoted = false; - $emailAddress->address_temp = ''; - if ($emailAddress->address_temp_period > 0) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Periods within the display name of an email address must appear in quotes, such as "John Q. Public" according to RFC 5322'; - $emailAddress->invalid_reason_code = Err::UnquotedPeriodInDisplayName; + if ($ctx->quote_temp) { + $ctx->name_parsed .= $ctx->quote_temp; + $ctx->name_quoted = true; + $ctx->quote_temp = ''; + } elseif ($ctx->address_temp) { + $ctx->name_parsed .= $ctx->address_temp; + $ctx->name_quoted = $ctx->address_temp_quoted; + $ctx->address_temp_quoted = false; + $ctx->address_temp = ''; + if ($ctx->address_temp_period > 0) { + $ctx->invalid = true; + $ctx->invalid_reason = 'Periods within the display name of an email address must appear in quotes, such as "John Q. Public" according to RFC 5322'; + $ctx->invalid_reason_code = Err::UnquotedPeriodInDisplayName; } } } @@ -1189,106 +1185,106 @@ private function handleQuote(ParseContext $emailAddress): void */ private function addAddress( array &$emailAddresses, - ParseContext $emailAddress, + ParseContext $ctx, int $i ): bool { - if (!$emailAddress->invalid) { - if (filter_var($emailAddress->domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false || - str_starts_with($emailAddress->domain, 'IPv6:') || - preg_match('/^\d+\.\d+\.\d+\.\d+$/', $emailAddress->domain)) { - $emailAddress->ip = $emailAddress->domain; - $emailAddress->domain = ''; + if (!$ctx->invalid) { + if (filter_var($ctx->domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false || + str_starts_with($ctx->domain, 'IPv6:') || + preg_match('/^\d+\.\d+\.\d+\.\d+$/', $ctx->domain)) { + $ctx->ip = $ctx->domain; + $ctx->domain = ''; } - if ($emailAddress->address_temp || $emailAddress->quote_temp) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Incomplete address'; - $emailAddress->invalid_reason_code = Err::IncompleteAddress; - $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$emailAddress->address_temp : {$emailAddress->address_temp}\n\$emailAddress->quote_temp: {$emailAddress->quote_temp}\n"); - } elseif ($emailAddress->ip && $emailAddress->domain) { + if ($ctx->address_temp || $ctx->quote_temp) { + $ctx->invalid = true; + $ctx->invalid_reason = 'Incomplete address'; + $ctx->invalid_reason_code = Err::IncompleteAddress; + $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->address_temp : {$ctx->address_temp}\n\$ctx->quote_temp: {$ctx->quote_temp}\n"); + } elseif ($ctx->ip && $ctx->domain) { // Error - this should never occur - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Confusion during parsing'; - $emailAddress->invalid_reason_code = Err::ParserConfusion; - $this->log('error', "Email\\Parse->addAddress - both an IP address '{$emailAddress->ip}' and a domain '{$emailAddress->domain}' found for the email address '{$emailAddress->original_address}'\n"); - } elseif ($emailAddress->ip) { - if (filter_var($emailAddress->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { - if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($emailAddress->ip, FILTER_FLAG_IPV4)) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'IP address invalid: \'' . $emailAddress->ip . '\' does not appear to be a valid IP address in the global range'; - $emailAddress->invalid_reason_code = Err::IpNotInGlobalRange; + $ctx->invalid = true; + $ctx->invalid_reason = 'Confusion during parsing'; + $ctx->invalid_reason_code = Err::ParserConfusion; + $this->log('error', "Email\\Parse->addAddress - both an IP address '{$ctx->ip}' and a domain '{$ctx->domain}' found for the email address '{$ctx->original_address}'\n"); + } elseif ($ctx->ip) { + if (filter_var($ctx->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($ctx->ip, FILTER_FLAG_IPV4)) { + $ctx->invalid = true; + $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address in the global range'; + $ctx->invalid_reason_code = Err::IpNotInGlobalRange; } - } elseif (str_starts_with($emailAddress->ip, 'IPv6:')) { - $tempIp = str_replace('IPv6:', '', $emailAddress->ip); + } elseif (str_starts_with($ctx->ip, 'IPv6:')) { + $tempIp = str_replace('IPv6:', '', $ctx->ip); if (filter_var($tempIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($tempIp, FILTER_FLAG_IPV6)) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'IP address invalid: \'' . $emailAddress->ip . '\' does not appear to be a valid IPv6 address in the global range'; - $emailAddress->invalid_reason_code = Err::Ipv6NotInGlobalRange; + $ctx->invalid = true; + $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IPv6 address in the global range'; + $ctx->invalid_reason_code = Err::Ipv6NotInGlobalRange; } } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'IP address invalid: \'' . $emailAddress->ip . '\' does not appear to be a valid IP address'; - $emailAddress->invalid_reason_code = Err::InvalidIpAddress; + $ctx->invalid = true; + $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; + $ctx->invalid_reason_code = Err::InvalidIpAddress; } } else { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'IP address invalid: \'' . $emailAddress->ip . '\' does not appear to be a valid IP address'; - $emailAddress->invalid_reason_code = Err::InvalidIpAddress; + $ctx->invalid = true; + $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; + $ctx->invalid_reason_code = Err::InvalidIpAddress; } - } elseif ($emailAddress->domain) { + } elseif ($ctx->domain) { // Optional FQDN root-label dot (RFC 5321 §2.3.5 allows "example.com."). // Accepted and stripped by default; rejected when rejectTrailingDot is set. - if (str_ends_with($emailAddress->domain, '.')) { + if (str_ends_with($ctx->domain, '.')) { if ($this->options->rejectTrailingDot) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Domain must not end with a trailing dot'; - $emailAddress->invalid_reason_code = Err::TrailingDotNotAllowed; + $ctx->invalid = true; + $ctx->invalid_reason = 'Domain must not end with a trailing dot'; + $ctx->invalid_reason_code = Err::TrailingDotNotAllowed; } else { - $emailAddress->domain = substr($emailAddress->domain, 0, -1); + $ctx->domain = substr($ctx->domain, 0, -1); } } } - if (!$emailAddress->invalid && $emailAddress->domain) { + if (!$ctx->invalid && $ctx->domain) { // NFC-normalize internationalized domain before punycode conversion // RFC 6531 §3.3 / RFC 5891 §5.2: U-labels must be in NFC before IDNA processing if ($this->options->applyNfcNormalization) { - $nfc = $this->normalizeUtf8($emailAddress->domain); + $nfc = $this->normalizeUtf8($ctx->domain); if ($nfc !== false) { - $emailAddress->domain = $nfc; + $ctx->domain = $nfc; } } - $domainAscii = $this->normalizeDomainAscii($emailAddress->domain); + $domainAscii = $this->normalizeDomainAscii($ctx->domain); if ($domainAscii === null) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Can't convert domain {$emailAddress->domain} to punycode"; - $emailAddress->invalid_reason_code = Err::PunycodeConversionFailed; + $ctx->invalid = true; + $ctx->invalid_reason = "Can't convert domain {$ctx->domain} to punycode"; + $ctx->invalid_reason_code = Err::PunycodeConversionFailed; } else { - if ($domainAscii !== $emailAddress->domain) { - $emailAddress->domain_ascii = $domainAscii; + if ($domainAscii !== $ctx->domain) { + $ctx->domain_ascii = $domainAscii; } $result = $this->validateDomainName($domainAscii); if (!$result['valid']) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = isset($result['reason']) ? 'Domain invalid: '.$result['reason'] : 'Domain invalid for some unknown reason'; - $emailAddress->invalid_reason_code = $result['code'] ?? Err::DomainInvalid; + $ctx->invalid = true; + $ctx->invalid_reason = isset($result['reason']) ? 'Domain invalid: '.$result['reason'] : 'Domain invalid for some unknown reason'; + $ctx->invalid_reason_code = $result['code'] ?? Err::DomainInvalid; } } } } // Prepare some of the fields needed - $emailAddress->name_parsed = rtrim($emailAddress->name_parsed); - $emailAddress->original_address = rtrim($emailAddress->original_address); - $name = $emailAddress->name_quoted ? "\"{$emailAddress->name_parsed}\"" : $emailAddress->name_parsed; - $localPart = $emailAddress->local_part_quoted ? "\"{$emailAddress->local_part_parsed}\"" : $emailAddress->local_part_parsed; - $domainPart = $emailAddress->ip ? '['.$emailAddress->ip.']' : $emailAddress->domain; + $ctx->name_parsed = rtrim($ctx->name_parsed); + $ctx->original_address = rtrim($ctx->original_address); + $name = $ctx->name_quoted ? "\"{$ctx->name_parsed}\"" : $ctx->name_parsed; + $localPart = $ctx->local_part_quoted ? "\"{$ctx->local_part_parsed}\"" : $ctx->local_part_parsed; + $domainPart = $ctx->ip ? '['.$ctx->ip.']' : $ctx->domain; - if (!$emailAddress->invalid) { + if (!$ctx->invalid) { if (0 == strlen($domainPart)) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Email address needs a domain after the \'@\''; - $emailAddress->invalid_reason_code = Err::MissingDomain; + $ctx->invalid = true; + $ctx->invalid_reason = 'Email address needs a domain after the \'@\''; + $ctx->invalid_reason_code = Err::MissingDomain; } } @@ -1298,30 +1294,30 @@ private function addAddress( // only atext characters and whitespace. The parser's state machine already // catches unquoted periods (UnquotedPeriodInDisplayName); this check adds // rejection of non-atext bytes such as stray UTF-8 in an unquoted name. - if (!$emailAddress->invalid + if (!$ctx->invalid && $this->options->validateDisplayNamePhrase - && !$emailAddress->name_quoted - && $emailAddress->name_parsed !== '' - && !preg_match('#^[A-Za-z0-9!\#$%&\'*+\-/=?^_`{|}~ \t]+$#', $emailAddress->name_parsed) + && !$ctx->name_quoted + && $ctx->name_parsed !== '' + && !preg_match('#^[A-Za-z0-9!\#$%&\'*+\-/=?^_`{|}~ \t]+$#', $ctx->name_parsed) ) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Display name '{$emailAddress->name_parsed}' must be a quoted-string or atext-only phrase per RFC 5322 §3.2.5"; - $emailAddress->invalid_reason_code = Err::InvalidDisplayNamePhrase; + $ctx->invalid = true; + $ctx->invalid_reason = "Display name '{$ctx->name_parsed}' must be a quoted-string or atext-only phrase per RFC 5322 §3.2.5"; + $ctx->invalid_reason_code = Err::InvalidDisplayNamePhrase; } // Unified local-part validation - if (!$emailAddress->invalid) { - $result = $this->validateLocalPart($emailAddress); + if (!$ctx->invalid) { + $result = $this->validateLocalPart($ctx); if (!$result['valid']) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = $result['reason']; - $emailAddress->invalid_reason_code = $result['code'] ?? null; + $ctx->invalid = true; + $ctx->invalid_reason = $result['reason']; + $ctx->invalid_reason_code = $result['code'] ?? null; } elseif ($result['normalized'] !== null) { // Apply NFC normalization result to the parsed local-part and re-derive display form - $emailAddress->local_part_parsed = $result['normalized']; - $localPart = $emailAddress->local_part_quoted - ? "\"{$emailAddress->local_part_parsed}\"" - : $emailAddress->local_part_parsed; + $ctx->local_part_parsed = $result['normalized']; + $localPart = $ctx->local_part_quoted + ? "\"{$ctx->local_part_parsed}\"" + : $ctx->local_part_parsed; } // Optional caller-supplied local-part normalizer — invoked after structural @@ -1331,66 +1327,66 @@ private function addAddress( // domain-specific canonicalization. The returned string replaces // local_part_parsed and the display form is re-derived; `original_address` // still preserves the verbatim input. - if (!$emailAddress->invalid && $this->options->localPartNormalizer !== null) { + if (!$ctx->invalid && $this->options->localPartNormalizer !== null) { $normalizer = $this->options->localPartNormalizer; - $normalized = $normalizer($emailAddress->local_part_parsed, $emailAddress->domain); - if ($normalized !== $emailAddress->local_part_parsed) { - $emailAddress->local_part_parsed = $normalized; - $localPart = $emailAddress->local_part_quoted - ? "\"{$emailAddress->local_part_parsed}\"" - : $emailAddress->local_part_parsed; + $normalized = $normalizer($ctx->local_part_parsed, $ctx->domain); + if ($normalized !== $ctx->local_part_parsed) { + $ctx->local_part_parsed = $normalized; + $localPart = $ctx->local_part_quoted + ? "\"{$ctx->local_part_parsed}\"" + : $ctx->local_part_parsed; } } } // FQDN check - if (!$emailAddress->invalid && $this->options->requireFqdn && $emailAddress->domain) { - $dotPos = strpos($emailAddress->domain, '.'); - if ($dotPos === false || $dotPos === 0 || $dotPos === strlen($emailAddress->domain) - 1) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Domain must be a fully-qualified domain name'; - $emailAddress->invalid_reason_code = Err::FqdnRequired; + if (!$ctx->invalid && $this->options->requireFqdn && $ctx->domain) { + $dotPos = strpos($ctx->domain, '.'); + if ($dotPos === false || $dotPos === 0 || $dotPos === strlen($ctx->domain) - 1) { + $ctx->invalid = true; + $ctx->invalid_reason = 'Domain must be a fully-qualified domain name'; + $ctx->invalid_reason_code = Err::FqdnRequired; } } // RFC 5321 §4.5.3.1: all limits are in octets (bytes), not characters. // For quoted local-parts the wire form adds 2 DQUOTE bytes to the length. - if (!$emailAddress->invalid && $this->options->enforceLengthLimits) { + if (!$ctx->invalid && $this->options->enforceLengthLimits) { $limits = $this->options->getLengthLimits(); // RFC 5321 §4.5.3.1.1: local-part max 64 octets (wire form includes DQUOTE for quoted strings) - $localPartWireLen = $emailAddress->local_part_quoted - ? strlen($emailAddress->local_part_parsed) + 2 - : strlen($emailAddress->local_part_parsed); + $localPartWireLen = $ctx->local_part_quoted + ? strlen($ctx->local_part_parsed) + 2 + : strlen($ctx->local_part_parsed); if ($localPartWireLen > $limits->maxLocalPartLength) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Email address before the '@' can not be greater than {$limits->maxLocalPartLength} octets per RFC 5321"; - $emailAddress->invalid_reason_code = Err::LocalPartTooLong; + $ctx->invalid = true; + $ctx->invalid_reason = "Email address before the '@' can not be greater than {$limits->maxLocalPartLength} octets per RFC 5321"; + $ctx->invalid_reason_code = Err::LocalPartTooLong; } elseif (($localPartWireLen + 1 + strlen($domainPart)) > $limits->maxTotalLength) { - $emailAddress->invalid = true; - $emailAddress->invalid_reason = "Email addresses can not be greater than {$limits->maxTotalLength} octets per RFC 3696 EID 1690"; - $emailAddress->invalid_reason_code = Err::TotalLengthExceeded; + $ctx->invalid = true; + $ctx->invalid_reason = "Email addresses can not be greater than {$limits->maxTotalLength} octets per RFC 3696 EID 1690"; + $ctx->invalid_reason_code = Err::TotalLengthExceeded; } } // Build the email address hash $emailAddrDef = ['address' => '', 'simple_address' => '', - 'original_address' => rtrim($emailAddress->original_address), + 'original_address' => rtrim($ctx->original_address), 'name' => $name, - 'name_parsed' => $emailAddress->name_parsed, + 'name_parsed' => $ctx->name_parsed, 'local_part' => $localPart, - 'local_part_parsed' => $emailAddress->local_part_parsed, + 'local_part_parsed' => $ctx->local_part_parsed, 'domain_part' => $domainPart, - 'domain' => $emailAddress->domain, - 'domain_ascii' => $this->options->includeDomainAscii ? ($emailAddress->domain_ascii ?? null) : null, - 'ip' => $emailAddress->ip, - 'invalid' => $emailAddress->invalid, - 'invalid_reason' => $emailAddress->invalid_reason, - 'invalid_reason_code' => $emailAddress->invalid_reason_code, - 'comments' => $emailAddress->comments, - 'obs_route' => $emailAddress->obs_route !== '' ? $emailAddress->obs_route : null, - 'domain_is_suspicious' => $this->isDomainConfusable($emailAddress->domain), ]; + 'domain' => $ctx->domain, + 'domain_ascii' => $this->options->includeDomainAscii ? ($ctx->domain_ascii ?? null) : null, + 'ip' => $ctx->ip, + 'invalid' => $ctx->invalid, + 'invalid_reason' => $ctx->invalid_reason, + 'invalid_reason_code' => $ctx->invalid_reason_code, + 'comments' => $ctx->comments, + 'obs_route' => $ctx->obs_route !== '' ? $ctx->obs_route : null, + 'domain_is_suspicious' => $this->isDomainConfusable($ctx->domain), ]; // Build the proper address by hand (has comments stripped out and should have quotes in the proper places) if (!$emailAddrDef['invalid']) { @@ -1437,14 +1433,14 @@ private function isDomainConfusable(string $domain): bool /** * Unified local-part validation based on ParseOptions rule properties. * - * @param ParseContext $emailAddress The email address accumulator from the parser + * @param ParseContext $ctx The email address accumulator from the parser * @return array{valid: bool, reason: ?string, code: ?ParseErrorCode, normalized: ?string} */ - protected function validateLocalPart(ParseContext $emailAddress): array + protected function validateLocalPart(ParseContext $ctx): array { $opts = $this->options; - $localPart = $emailAddress->local_part_parsed; - $quoted = $emailAddress->local_part_quoted; + $localPart = $ctx->local_part_parsed; + $quoted = $ctx->local_part_quoted; // RFC 6531 §3.3 / RFC 6532 §3.2: gate UTF-8 presence before other checks // (allowUtf8LocalPart is false in rfc5321() and rfc5322() presets) diff --git a/src/ParseContext.php b/src/ParseContext.php index 5c5c3fe..e73f0f1 100644 --- a/src/ParseContext.php +++ b/src/ParseContext.php @@ -148,8 +148,16 @@ final class ParseContext * for the next address in a multi-address parse (matches the historical * "rebuild the $emailAddress array" behaviour). */ - public function resetAddress(): void + public function resetAddress(int $state, int $subState): void { + // Loop-control state, reset here so every per-address field has a single + // source of truth. commentNestLevel in particular has no other reset: + // leaving it out would let an unterminated comment leak into the next + // address in a batch, self-healing only because '(' reassigns it to 1. + $this->state = $state; + $this->subState = $subState; + $this->commentNestLevel = 0; + $this->original_address = ''; $this->name_parsed = ''; $this->local_part_parsed = ''; From 5c8fa9bf3c67f20abe2317bc499526965dddbc3d Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 01:57:37 -0700 Subject: [PATCH 05/11] docs: add ARCHITECTURE.md for the parse() state machine Document the parser internals introduced by the parse() decomposition: the character-by-character dispatch loop, the 12 states and their 7 handlers, the ParseContext object and how a fresh instance per call keeps parse() reentrant, and the single-source-of-truth per-address reset. State machine and dispatch loop are drawn as mermaid so they render inline on GitHub. Complements DESIGN.md (RFC semantics) with the implementation shape; linked from the README docs line. --- ARCHITECTURE.md | 139 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ab563ad --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,139 @@ +# Parser Architecture + +How `Email\Parse` turns a string of addresses into parsed results. This is the +*implementation* companion to [`DESIGN.md`](DESIGN.md), which covers the RFC +*semantics* (what counts as valid and why). Here the subject is the shape of the +code: a character-by-character state machine, decomposed into a dispatch loop +over per-state handlers, backed by a per-parse context object. + +## At a glance + +| | | +|---|---| +| Entry point | `parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array` | +| Model | Character-by-character state machine, 12 states | +| `parse()` body | Setup + a `switch ($ctx->state)` dispatch loop (~193 lines) | +| State handlers | 7 methods (one per switch arm) | +| Working state | `ParseContext` — one object per `parse()` call, ~24 accumulator fields | +| Reentrancy | A fresh context per call; nothing parse-specific is stored on the `Parse` instance | + +## The dispatch loop + +`parse()` reads the input once, left to right. Each iteration reads one +character, dispatches on the current state to a handler that mutates the +context, and — when an address boundary is reached — commits the address and +resets for the next one. + +```mermaid +flowchart TD + A["for i in 0..len
read curChar, keep prevChar"] --> B{"switch ctx.state"} + B --> C["state handler
mutates ctx"] + C --> D{"ctx.state == END_ADDRESS
and got characters?"} + D -- "yes" --> E["addAddress()
build output row"] + E --> F["ctx.resetAddress(TRIM, START)"] + F --> A + D -- "no" --> A +``` + +The `TRIM → ADDRESS` transition is a genuine `switch` fall-through: a plain +character seen in the trim state *is* the first character of the address, so +control drops straight from the `TRIM` arm into the `ADDRESS` arm without +re-reading. That is why `handleStateTrim()` returns a `bool` — `true` tells the +loop to fall through. + +## The states + +`ADDRESS` is the hub. It runs the addr-spec walk via an inner `subState` machine +(`LOCAL_PART → DOMAIN → AFTER_DOMAIN`, plus `NAME` for display names). From the +hub the parser makes bounded *excursions* into quoted strings, nested comments, +address literals, and obsolete source routes; each returns to `ADDRESS`. A +separator or end-of-input drops to `END_ADDRESS`, which commits the address and +loops back to `TRIM`. Malformed input diverts to `SKIP_AHEAD`, which +resynchronizes at the next separator. + +```mermaid +stateDiagram-v2 + [*] --> TRIM + TRIM --> ADDRESS: plain char, fall-through + + ADDRESS --> QUOTE: double-quote + QUOTE --> ADDRESS: return + ADDRESS --> COMMENT: open-paren + COMMENT --> ADDRESS: return + ADDRESS --> SQUARE_BRACKET: open-bracket + SQUARE_BRACKET --> ADDRESS: return + ADDRESS --> OBS_ROUTE: obs-route + OBS_ROUTE --> ADDRESS: return + + ADDRESS --> SKIP_AHEAD: on invalid + SKIP_AHEAD --> END_ADDRESS: next separator + + ADDRESS --> END_ADDRESS: separator / EOF + END_ADDRESS --> TRIM: next address, resetAddress + END_ADDRESS --> [*]: end of input +``` + +Each switch arm is a method, so a state's logic is isolated and independently +readable: + +| State | Handler | Responsibility | +|---|---|---| +| `SKIP_AHEAD` | `handleStateSkipAhead` | Error recovery — consume until the next separator | +| `TRIM` | `handleStateTrim` | Skip leading separators/whitespace; signal fall-through | +| `ADDRESS` | `handleStateAddress` | The addr-spec walk (local-part `@` domain, display name) | +| `SQUARE_BRACKET` | `handleStateSquareBracket` | `[...]` domain / address literal | +| `OBS_ROUTE` | `handleStateObsRoute` | Obsolete `@a,@b:addr` source route | +| `QUOTE` | `handleStateQuote` | Quoted-string local-part or display name | +| `COMMENT` | `handleStateComment` | Nested `( ... )` comments | + +`handleStateAddress` further delegates the per-character work to +`handleAddressWhitespace` (CFWS/folding), `handleAddressAt` (the `@` boundary), +and `handleAddressNonAtext` (punctuation and specials). `addAddress()` builds the +public output array; its shape is independent of the context object. + +## ParseContext + +All of the loop's working state lives on one object, `ParseContext`. A fresh +instance is created for every `parse()` call and is never stored on the `Parse` +instance. That is the whole reentrancy story: a caller-supplied +`localPartNormalizer` closure may call back into `parse()` mid-parse, and the +inner call gets its own context instead of clobbering the outer one's. + +The object holds three kinds of field. The distinction matters because only the +last kind is cleared between addresses in a batch: + +| Group | Lifetime | Fields (representative) | +|---|---|---| +| Input snapshot | Set once per parse, never reset | `chars[]`, `len`, `emails`, `multiple` | +| Hoisted config | Set once per parse, never reset | `separators`, `bannedChars`, `allowedWhitespace`, `useWhitespaceAsSeparator` | +| Per-address accumulator + loop control | Cleared by `resetAddress()` | `state`, `subState`, `commentNestLevel`, `original_address`, `local_part_parsed`, `domain`, `quote_temp`, `comments[]`, `in_angle_addr`, ... (~24 total) | + +The accumulator field names deliberately mirror the historical loop-local +variable names so they thread through the validation helpers unchanged; the +rename to the codebase's `camelCase` convention is a tracked follow-up (see +[`ROADMAP.md`](ROADMAP.md)). + +## Per-address reset + +`resetAddress(int $state, int $subState)` is the single source of truth for +clearing per-address state between addresses in a batch. It zeroes the +accumulator *and* the three loop-control fields — `state`, `subState`, and +`commentNestLevel`. Both call sites use it: the initial setup before the loop and +the reset after each committed address. + +Consolidating this matters for a subtle reason. `commentNestLevel` previously had +no explicit reset at all — it stayed correct only because entering a comment with +a leading `(` reassigns the level to `1`. Any future per-address field added to +the wrong place would have silently leaked into the next address in a batch. +Routing all per-address state through one method removes that trap: a new field +has exactly one place to be cleared. + +## Invariants + +- **Behavior-preserving.** The decomposition changed structure only; parsing + logic, conditions, and ordering are unchanged, and the output arrays are + byte-identical. Gated by the full test suite, PHPStan level 8, and Psalm. +- **Reentrant.** No per-parse state on the `Parse` instance; a normalizer + callback may re-enter `parse()` safely. +- **No performance regression.** Hard constraint on the refactor; `chars`/`len` + are kept as loop locals (not only context properties) for hot-loop locality. diff --git a/README.md b/README.md index 233487e..3ceebb6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Email\Parse is a batch email address parser with configurable RFC compliance lev It parses a list of 1 to n email addresses separated by comma and whitespace by default, with configurable separators (e.g. semicolon). -**Other docs:** [CHANGELOG](CHANGELOG.md) · [UPGRADE guide (v2.x → v3.0)](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ROADMAP](ROADMAP.md) +**Other docs:** [CHANGELOG](CHANGELOG.md) · [UPGRADE guide (v2.x → v3.0)](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ARCHITECTURE](ARCHITECTURE.md) · [ROADMAP](ROADMAP.md) Installation: ------------- From 0eaa65c6d7416bde934b66497344aeee13c880fb Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 19:17:12 -0700 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94?= =?UTF-8?q?=20@internal=20validator,=20ParseContext=20ctor,=20inline=20loc?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the parse() decomposition review (PR #71). - Mark validateLocalPart() @internal. It is protected on a non-final class, so its array -> ParseContext signature change is technically a subclass break; but it takes the parser's internal accumulator and was never a supported extension point (validation is customized via ParseOptions). Documented as such and slated to go private in v4.0 (roadmap). - Give ParseContext a constructor requiring the initial state/subState, which runs resetAddress(). This makes an un-initialized context unrepresentable, replacing the "construct then remember to reset" pattern and the misleading zero-value field defaults (subState 0 = STATE_TRIM, not the required STATE_START). - Inline the single-use $separators local straight onto the context. 108 tests / 7172 assertions, PHPStan and CS clean. --- ROADMAP.md | 1 + src/Parse.php | 20 ++++++++++---------- src/ParseContext.php | 19 ++++++++++++++++++- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c150a4c..424becb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ The comparison harness remains a local dev tool (not a CI gate). Every fixed clu - [ ] Remove `parse()` in favor of `parseSingle()` / `parseMultiple()` with typed returns — eliminates the polymorphic `$multiple` boolean parameter. - [ ] Deprecate or remove the `getInstance()` singleton (recommend explicit instantiation). - [ ] Constructor promotion on `ParseOptions` with named arguments. +- [ ] Make the internal validation helpers `private` (notably `validateLocalPart`, which takes the parser-internal `ParseContext`, and `validateDomainName`). They are `protected` only for historical reasons and were never a supported extension point — validation is customized through `ParseOptions`. Marked `@internal` when the `parse()` decomposition landed, which already changed `validateLocalPart`'s signature (`array` → `ParseContext`). **New capabilities (genuinely breaking or late-binding):** - [ ] Optional DNS/MX validation via callback interface (`DnsValidator`). Breaking because the Parse constructor signature grows, and because synchronous DNS lookups change performance characteristics meaningfully. diff --git a/src/Parse.php b/src/Parse.php index 290add5..efff849 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -275,17 +275,15 @@ public function parse(string $emails, bool $multiple = true, string $encoding = $emailAddresses = []; // Per-parse accumulator. A fresh instance (never an instance property) - // keeps parse() reentrant across a localPartNormalizer callback. - $ctx = new ParseContext(); + // keeps parse() reentrant across a localPartNormalizer callback. The + // constructor requires the initial state (STATE_TRIM) and sub-state + // (STATE_START, for when we reach the xyz@somewhere.com address itself), + // so the context is fully initialized before its first use. + $ctx = new ParseContext(self::STATE_TRIM, self::STATE_START); $success = true; $reason = null; - // Initialize per-address state: STATE_TRIM as the current parser state, - // STATE_START as the sub state (for when we reach the xyz@somewhere.com - // address itself), and zero the accumulator and comment nesting. - $ctx->resetAddress(self::STATE_TRIM, self::STATE_START); - // Split once into an array of characters rather than calling // mb_substr($emails, $i, 1) on every iteration. For multi-byte encodings // each mb_substr rescans from the start of the string (O(n) per call, so @@ -296,8 +294,6 @@ public function parse(string $emails, bool $multiple = true, string $encoding = $success = false; $reason = 'No emails passed in'; } - // Hoist the immutable separator/banned-char config out of the per-character loop. - $separators = $this->options->getSeparators(); // Whitespace treated as insignificant (folding/separators; trimmable). In // single-address mode CR and LF are excluded — a lone addr-spec has no line // endings — unless trimSingleAddressWhitespace opts back into liberal trimming. @@ -313,7 +309,7 @@ public function parse(string $emails, bool $multiple = true, string $encoding = $ctx->len = $len; $ctx->multiple = $multiple; $ctx->emails = $emails; - $ctx->separators = $separators; + $ctx->separators = $this->options->getSeparators(); $ctx->bannedChars = $this->options->getBannedChars(); $ctx->useWhitespaceAsSeparator = $this->options->getUseWhitespaceAsSeparator(); $ctx->allowedWhitespace = $allowedWhitespace; @@ -1433,6 +1429,10 @@ private function isDomainConfusable(string $domain): bool /** * Unified local-part validation based on ParseOptions rule properties. * + * @internal Not a supported extension point. It takes the parser's internal + * accumulator (ParseContext); customize validation via ParseOptions + * rather than by overriding this. Slated to become private in v4.0. + * * @param ParseContext $ctx The email address accumulator from the parser * @return array{valid: bool, reason: ?string, code: ?ParseErrorCode, normalized: ?string} */ diff --git a/src/ParseContext.php b/src/ParseContext.php index e73f0f1..d85b913 100644 --- a/src/ParseContext.php +++ b/src/ParseContext.php @@ -53,7 +53,12 @@ final class ParseContext /** Current parser state (one of Parse::STATE_*). */ public int $state = 0; - /** Current parser sub-state within an addr-spec (one of Parse::STATE_*). */ + /** + * Current parser sub-state within an addr-spec (one of Parse::STATE_*). + * Initialized by the constructor / resetAddress(); the literal 0 default is + * STATE_TRIM, not a valid starting sub-state (which is STATE_START), so it + * must never be relied on un-initialized. + */ public int $subState = 0; /** Current comment nesting depth. */ @@ -148,6 +153,18 @@ final class ParseContext * for the next address in a multi-address parse (matches the historical * "rebuild the $emailAddress array" behaviour). */ + /** + * @param int $state Initial parser state (a Parse::STATE_* value). + * @param int $subState Initial addr-spec sub-state (a Parse::STATE_* value). + */ + public function __construct(int $state, int $subState) + { + // Requiring the initial states makes an un-initialized context + // unrepresentable: every instance is reset before its first use, so no + // caller can start parsing from the misleading zero-value field defaults. + $this->resetAddress($state, $subState); + } + public function resetAddress(int $state, int $subState): void { // Loop-control state, reset here so every per-address field has a single From 78f0796c3da57477832ab45118a3b4081ec0bab3 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 22:09:07 -0700 Subject: [PATCH 07/11] docs: restructure ROADMAP into Released / Quality / Planned The file had drifted into a jumbled mix of shipped and planned work with duplicated and misfiled sections. Reorganized without dropping substance: - Split into three clear parts: Released (v3.1-v3.8 + deprecations + docs), Quality & infrastructure (continuous), and Planned (v4.0 + backlog). - Removed the duplicate v4.0 list (the Deprecation Timeline had its own "v4.0 - planned" subsection overlapping the real v4.0 section); merged and deduped the API-cleanup items into a single v4.0 section. - Refiled the shipped 3.8.0 homoglyph feature out of "v4.0 - Breaking" into Released; its target-list follow-up stays under Planned. - Compressed the now-delivered parse() decomposition to a one-line record linking ARCHITECTURE.md, with the four review follow-ups moved to Backlog. - Dropped the stale proposal text ("currently 99 tests", the pre-refactor complexity pitch) and the trailing stray footnote. --- ROADMAP.md | 180 ++++++++++++++++++----------------------------------- 1 file changed, 60 insertions(+), 120 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 567cdc4..7c37105 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,151 +1,91 @@ # Roadmap -Future plans by version. Items here are intent, not commitment — priority and scope may shift. +Intent, not commitment — priorities and scope may shift. Shipped work is kept +below as a record; planned work follows. -## Deprecation Timeline +## Released -### v3.0 — shipped -- [x] `LengthLimits` switched to readonly constructor promotion (getters/setters removed; see [UPGRADE.md](UPGRADE.md) for migration). -- [x] `ParseOptions` setters marked `@deprecated v3.0` (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) — still functional. -- [x] `RfcMode` class never released (existed only on a feature branch). +### v3.1 — Immutable config, error codes, typed output -### v4.0 — planned -- [ ] Remove all `@deprecated` `ParseOptions` setters above. -- [ ] Make remaining private fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) public readonly via constructor promotion. +- Immutable `ParseOptions`: all 15 boolean rule properties are `readonly` (PHP 8.1), with fluent `withX()` builders that return new instances. The 4 state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) stay mutable via deprecated setters until v4.0. +- `ParseErrorCode` backed enum — 46 cases grouped by category; `invalid_reason_code: ?ParseErrorCode` on every entry alongside the `invalid_reason` string. +- Typed output value objects (non-breaking): `ParsedEmailAddress` and `ParseResult` (readonly), plus `parseSingle()` / `parseMultiple()`. `parse()` is unchanged. +- Validation rules: `validateDisplayNamePhrase` (RFC 5322 §3.2.5 phrase syntax) and `strictIdna` (full IDNA2008 conformance; default in `rfc6531()`). -## v3.1 — Immutable Config, Error Codes, Typed Output — shipped +### v3.2 — Streaming, severity levels, obsolete syntax -**Immutable `ParseOptions` with fluent builders:** -- [x] All 15 boolean rule properties are now `readonly` (PHP 8.1). The 4 state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) remain mutable via deprecated setters until v4.0. -- [x] Fluent builder methods that return new instances: - ```php - ParseOptions::rfc5322()->withBannedChars([...])->withSeparators([...])->withRequireFqdn(true); - ``` -- Deprecated setters continue to work for backward compatibility. +- `parseStream(iterable, string): Generator` — yields one address at a time; each input item may itself hold several. +- `ValidationSeverity` enum (Critical / Warning / Info), `ParseErrorCode::severity()`, and `ParsedEmailAddress::invalidSeverity()`. +- Obsolete syntax (RFC 5322 §4): `obs-route` (`$allowObsRoute`, captured on `$obsRoute`; default in `rfc5322()` / `rfc2822()`), `obs-angle-addr`, `obs-domain-list`, and CFWS look-ahead at dot-atom and angle-addr boundaries. (`obs-local-part` already shipped in v3.0.) -**Structured error codes:** -- [x] `ParseErrorCode` backed enum — 46 cases grouped by category (structural, character, dot placement, local-part content, quoted-string, domain, IP literal, length, display-name). -- [x] `invalid_reason_code: ?ParseErrorCode` on every parsed-address entry, populated alongside the existing `invalid_reason` string. +### v3.3 — Polish, ergonomics -**Typed output value objects (non-breaking):** -- [x] `ParsedEmailAddress` — readonly properties for every per-address field with named-arg constructor and `fromArray()` factory. -- [x] `ParseResult` — readonly `success`, `reason`, `emailAddresses` (array of `ParsedEmailAddress`). -- [x] New methods: `Parse::parseSingle(string): ParsedEmailAddress`, `Parse::parseMultiple(string): ParseResult`. -- Existing `parse()` stays unchanged for backward compatibility. +- Serialization: `ParsedEmailAddress::toArray()` / `toJson()`, `implements \Stringable` (returns `simpleAddress`), and `ParseResult` counterparts. +- `canonical()` — minimal-quoting RFC 5322 display form (§3.2.4 local-part, §3.2.5 phrase). +- Optional local-part normalizer callback via `withLocalPartNormalizer()` — for Gmail dot-insensitivity, `+tag` plus-addressing, and similar domain rules. -**Additional validation rules:** -- [x] `validateDisplayNamePhrase: bool` — enforce RFC 5322 §3.2.5 phrase syntax (atext + WSP only) for unquoted display names. -- [x] `strictIdna: bool` — apply full IDNA2008 conformance (`IDNA_USE_STD3_RULES | IDNA_CHECK_BIDI | IDNA_CHECK_CONTEXTJ | IDNA_NONTRANSITIONAL_TO_ASCII`) per RFC 5891/5892/5893. Enabled by default in `rfc6531()`. -- [x] Extended test coverage: 265 assertions (target: 250+). +### v3.8 — Confusable-domain detection -## v3.2 — Streaming, Severity Levels, Obsolete Syntax — shipped +- Opt-in homoglyph / confusable-domain detection: `withDetectConfusableDomain()` runs the `intl` `Spoofchecker` (mixed-script / confusable) over the U-label domain and surfaces `ParsedEmailAddress::$domainIsSuspicious`. It's a security-policy signal, not a validity check — the address stays valid — and legitimate single-script international domains (`почта.рф`, `münchen.de`) are not flagged. -**Batch streaming:** -- [x] `Parse::parseStream(iterable, string): Generator` — yields one typed address at a time; each input item may itself contain multiple separator-delimited addresses. +### Deprecations -**Validation severity levels:** -- [x] `ValidationSeverity` enum with `Critical`, `Warning`, `Info` cases. -- [x] `ParseErrorCode::severity()` method classifying every code (13 Warning, rest Critical). -- [x] `ParsedEmailAddress::invalidSeverity()` accessor returning the derived severity (or `null` when valid). +- **v3.0:** `LengthLimits` moved to readonly constructor promotion (getters/setters removed — see [UPGRADE.md](UPGRADE.md)). The `ParseOptions` setters (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) are marked `@deprecated` and still functional; removal is targeted for v4.0. +- `RfcMode` never shipped (existed only on a feature branch). -**Obsolete syntax extensions (RFC 5322 §4):** +### Community & documentation -> Note: `obs-local-part` was already supported via `allowObsLocalPart` in v3.0. +- `CONTRIBUTING.md`, GitHub issue + PR templates (parser-tailored YAML forms), `CODE_OF_CONDUCT.md`, and the examples cookbook (`docs/cookbook.md`) — all shipped and linked from the README. -- [x] `obs-route` handling — `ParseOptions::$allowObsRoute` gates acceptance of `<@host1,@host2:user@host3>` source-route prefixes; the route is captured on `ParsedEmailAddress::$obsRoute`. Enabled by default in `rfc5322()` and `rfc2822()`. -- [x] `obs-angle-addr` — implied by obs-route support (it is the outer `[CFWS] "<" obs-route addr-spec ">" [CFWS]` form). -- [x] `obs-domain-list` — the `*("," [CFWS] ["@" domain])` shape is consumed inside `STATE_OBS_ROUTE`. -- [x] CFWS (comments / folding whitespace) improvements — look-ahead in the whitespace handler now absorbs CFWS at dot-atom boundaries (`local @domain`, `local@ domain`, `local @ domain`) and around angle-addr delimiters (`< local@domain >`, ``), including folded whitespace (LF + WSP). Comments in these positions were already supported in v3.0. +## Quality & infrastructure -## v3.3 — Polish, Ergonomics — shipped - -Non-breaking follow-on to v3.2. - -**Serialization ergonomics:** -- [x] `ParsedEmailAddress::toArray(): array` — round-trips to the legacy array shape for callers mixing typed and array-based code. -- [x] `ParsedEmailAddress::toJson(int $flags = 0): string` — convenience wrapper over `json_encode` with `JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES`. -- [x] `implements \Stringable` on `ParsedEmailAddress` — returns `simpleAddress` for valid addresses; empty string otherwise. Drops directly into string contexts. -- [x] `ParseResult::toArray()` and `toJson()` counterparts. - -**Canonicalization (pulled forward from v4.0):** -- [x] `ParsedEmailAddress::canonical(): string` — minimal-quoting RFC 5322 display form per §3.2.4 (local-part) and §3.2.5 (phrase). -- [x] Optional local-part normalizer callback on `ParseOptions` for domain-specific rules (Gmail dot-insensitivity, `+tag` plus-addressing). Attached via `withLocalPartNormalizer(?callable)`. - -**Ecosystem bridges:** *(deferred — out of scope for v3.3 per user direction)* -- [ ] `mmucklo/email-parse-symfony` — Symfony `Constraint` + `ConstraintValidator` attribute. Wraps existing `ParseOptions` presets. -- [ ] `mmucklo/email-parse-laravel` — Laravel validation rule, service provider for DI. -- [ ] PSR-14 event dispatcher integration — emit a `ParsedAddressEvent` per result for observability. - -## Quality and Infrastructure (ongoing) - -Not tied to a specific release; picked up as time allows. +Continuous work, not tied to a specific release. **Testing depth:** -- [~] Mutation testing with Infection — wired in via `composer infect` with thresholds `minMsi=80`, `minCoveredMsi=85` (current baseline, up from 74/79). Target remains ≥85% overall MSI; raise threshold as more error-path tests land. -- [x] Property-based testing — `tests/PropertyTest.php` with 10 invariants across 200 random iterations each: no-crash on arbitrary bytes, determinism, reason+code consistency, severity classification, Stringable contract, toArray ↔ parse() round-trip, valid-address round-trip, and all-presets-never-crash. No extra dependency (native PHPUnit + `mt_rand`; deterministic via `SEED` envvar). -- [~] Parse.php line coverage — now 87.98% (up from 86.69%). Overall project line coverage 91.15% (up from 89.61%). Remaining gaps are obscure error branches, the "shouldn't ever get here" default case, and code paths reachable only via internal state corruption. Target ≥95% aspirational. -- [x] CI matrix: PHP 8.5 added as a required job; PHP 8.6 added as an allowed-to-fail experimental (nightly) job until its stable release (~Nov 2026). +- [~] Mutation testing (Infection) — `composer infect`, thresholds `minMsi=80` / `minCoveredMsi=85` (baseline up from 74/79). Target ≥85% overall MSI; raise as more error-path tests land. +- [x] Property-based tests — `tests/PropertyTest.php`, 10 invariants × 200 random iterations (no-crash on arbitrary bytes, determinism, reason/code consistency, severity, Stringable, `toArray` ↔ `parse()` round-trip, valid-address round-trip, all-presets-never-crash). Native PHPUnit + `mt_rand`; deterministic via `SEED`. +- [~] Coverage — `Parse.php` 87.98%, project 91.15%. Remaining gaps are obscure error branches, the defensive "shouldn't get here" default case, and paths reachable only via internal state corruption. ≥95% aspirational. +- [x] CI matrix — PHP 8.5 required; PHP 8.6 nightly allowed-to-fail until stable (~Nov 2026). -**RFC conformance (gold-standard differential):** +**RFC conformance (differential vs `dominicsayers/isemail`, 164 cases):** +- [x] Drove strict-preset false-accepts from 29 → **1** (the intentional trailing root dot, now toggleable). Clusters resolved: quoted-string boundaries (`"test"test@` rejected, `"word".atom` valid); unclosed domain literal (`test@[1.2.3.4`); comment / CFWS parsing (unbalanced nesting, `\)` quoted-pair, C0 controls, atext-after-comment); quoted-string content (bare CR/LF); the CR/LF & folding-whitespace policy (`withTrimSingleAddressWhitespace`, `withStrictMultiWhitespace`); and the trailing domain dot (`withRejectTrailingDot`). The harness is a local dev tool, not a CI gate; every cluster carries regression tests in `tests/ParseTest.php`. -Differential testing against the `dominicsayers/isemail` reference corpus (164 cases) drove the strict-preset false-accept set from 29 down to **1** — the intentional trailing root dot, now toggleable. All clusters resolved: +**Pre-existing bugs fixed (found in review; outside the isemail corpus):** +- [x] Angle-addr with a domain-literal (``) was wrongly rejected — the `>` handler now accepts `STATE_AFTER_DOMAIN` when a domain/IP is present. +- [x] `word "." word` with quoted-string words (`"x"."y"@`, `x."y"@`, `"a b"."c"@`) now accepted (RFC 5322 §3.4.1). +- [x] `ParserConfusion` no longer reaches callers — `user@a[1.2.3.4]` is rejected up front as `InvalidOpeningBracket`; a 500k-input fuzz confirms the path is unreachable. +- [x] C1 controls (U+0080–U+009F) in comment content now rejected under `rejectC1Controls` (rfc6531), matching local-part and quoted-string handling. -- [x] **Quoted-string boundaries** — `"test"test@` / `"test""test"@` rejected (`AtextAfterQuotedString`); `"word".atom` stays valid. -- [x] **Unclosed domain literal** — `test@[1.2.3.4` rejected; end-of-input unterminated-delimiter check keyed on parser state. -- [x] **Comment (CFWS) parsing** — unbalanced nested comment; backslash quoted-pair (`(comment\)test@` — `\)` no longer closes); C0 controls in comment content (`ControlCharInComment`); atext splitting one atom after a comment (`AtextAfterComment`). -- [x] **Quoted-string content** — C0 controls (bare CR/LF) in a quoted string rejected under the strict presets. -- [x] **CR/LF & folding-whitespace** — resolved via the whitespace policy: single-address mode rejects surrounding/dangling CR/LF by default (`withTrimSingleAddressWhitespace` loosens); multi-address mode stays loose by default with an opt-in `withStrictMultiWhitespace` for per-address strictness. Whitespace still separates addresses in batch mode. -- [x] **Trailing domain dot** — `test@iana.org.` accepted by default (RFC 5321 §2.3.5); `withRejectTrailingDot(true)` rejects it. The one remaining corpus divergence, by design. +**Static analysis:** +- [x] PHPStan level 6 → 8 (tighter generics; four nullable-return guards, one local docblock shape on `parseMultiple()`). +- [x] Psalm level 3 with baseline as a cross-check — no genuinely new bugs vs PHPStan level 8. `composer psalm`. -The comparison harness remains a local dev tool (not a CI gate). Every fixed cluster carries regression tests in `tests/ParseTest.php`. +**Performance:** +- [x] PhpBench suite (`composer bench`) plus baseline/compare (`bench:baseline`, `bench:compare`; reference figures in `benchmarks/BASELINE.md`) and a non-blocking `benchmarks` CI job. +- [x] Hot-path fix — per-character `mb_substr` (O(n²) for multi-byte encodings) replaced with a single `mb_str_split` pass and array indexing. ~10–27% faster across the suite. -**Pre-existing bugs (found during review; not in the isemail corpus, so not covered above):** -- [x] **Angle-addr with a domain-literal rejected** — `` was wrongly rejected; the `>` handler now accepts `STATE_AFTER_DOMAIN` (which `]` reaches) when a domain/IP is present. Fixed with a metamorphic angle-wrap property test. -- [x] **`word "." word` with quoted-string words** — `"x"."y"@`, `x."y"@`, `"a b"."c"@` (a quoted-string as a non-first obs-local-part word) are now accepted; the final quoted word is flushed onto the local part like earlier words (RFC 5322 §3.4.1). -- [x] **`ParserConfusion` no longer reaches callers** — the remaining path (`user@a[1.2.3.4]`, a domain literal after domain characters) is rejected up front as `InvalidOpeningBracket`. A 500k-input fuzz confirms the code is now unreachable. -- [x] **C1 controls (U+0080–U+009F) in comment content** — now rejected when `rejectC1Controls` is set (rfc6531), matching local-part and quoted-string handling. +**Maintainability:** +- [x] **`parse()` decomposition** (delivered; unreleased). The ~772-line state-machine loop is now a ~185-line dispatch loop over per-state handler methods, backed by a typed, per-parse `ParseContext` (a fresh instance per call keeps the parser reentrant). Behavior-preserving — same logic, conditions, ordering, and output. See [ARCHITECTURE.md](ARCHITECTURE.md). Follow-ups in the backlog below. -**Static analysis:** -- [x] PHPStan level 6 → 8 — tighter generics and inference; required four small nullable-return guards (`idn_to_ascii`, `mb_split`, `file_get_contents`) and one local docblock shape on `parseMultiple()`. -- [x] Psalm alongside PHPStan — level 3 with baseline (66 entries, all false positives or duplicates of PHPStan findings). Found no genuinely new bugs vs PHPStan level 8; serves as a cross-check for future regressions. `composer psalm`. +## Planned -**Performance:** -- [x] PhpBench suite — `benchmarks/ParseBench.php` covers single ASCII, name-addr, UTF-8 local-part, IDN, obs-route, 10-address comma batch, 100-address `parseStream` batch, invalid inputs, and comment extraction. Run with `composer bench`. -- [x] Benchmark baseline + regression comparison — `composer bench:baseline` records a tagged reference (5 iterations, 5% retry threshold for stable numbers); `composer bench:compare` diffs a run against it. Reference figures and host context in `benchmarks/BASELINE.md`. Local storage (`.phpbench/`) is git-ignored since wall-clock times are machine-specific. -- [x] Wire `bench:compare` into CI — a non-blocking `benchmarks` job records a baseline from the PR base's `src/` and compares the head against it on the same runner. Generous 50%-regression assertion (shared runners are noisy) and `continue-on-error`, so it reports without blocking. -- [x] Main-loop hot path — replaced per-character `mb_substr($emails, $i, 1)` (O(n²) for multi-byte encodings, which rescan from the start each call) with a single `mb_str_split()` pass and array indexing. ~10–27% faster across the suite; biggest gains on longer inputs. Measured against the baseline via `composer bench:compare`. -- [ ] Further profiling under mailing-list-sized inputs if needed — the `mb_str_split` array now dominates memory for very large batches; a streaming/chunked reader could bound that. - -**Maintainability / readability:** -- [x] **Reorganize `Parse::parse()` for readability.** The main state machine has grown deeply nested (a `switch ($state)` with a nested `switch/if` on `$subState`, plus per-character CFWS/comment/quote handling), and several correctness fixes have added flags and edge branches that are hard to follow. Decompose the loop body into named per-state handlers (e.g. `handleTrim`/`handleAddress`/`handleQuote`/`handleComment`) so each state's logic is isolated and independently readable. Also fold the accumulated tracking flags (`after_closing_quote`, `comment_after_local_atext`, `comment_escaped`, …) into a clearer per-parse context object. - - **Hard constraint: no performance regression.** Benchmark before and after with `composer bench:baseline` (on the pre-refactor commit) then `composer bench:compare` on the refactor; every subject must stay within noise. A prior spike proved this is achievable — decomposing the switch into method-per-character dispatch dropped `parse()` cyclomatic complexity 168 → 23 with **no measurable slowdown** (PHP 8's method calls are cheap; smaller methods can even help I-cache). Prefer passing a context object over instance properties, to keep the parser reentrant (a user `localPartNormalizer` callback can re-enter `parse()`). - - Keep it behavior-preserving: it is a pure structural refactor, gated by the full test suite (currently 99 tests) + PHPStan level 8 + Psalm, with no changes to parsing logic, conditions, or ordering. - - **Delivered** as `ParseContext` (per-parse mutable state, reentrancy-safe) plus per-state handler methods. Follow-ups from review, not blocking: - - [ ] Migrate `ParseContext`'s per-address accumulator fields from `snake_case` to the codebase's `camelCase`. Kept `snake_case` during the extraction so the diff was a pure move of the original loop locals; rename once the dust settles. - - [ ] Encode `ParseContext`'s three concerns structurally rather than by convention: the immutable input snapshot (`chars`/`len`/`emails`), the hoisted read-only config (`separators`, `bannedChars`, …), and the mutable per-address accumulator are all public fields today, so nothing stops a handler from writing config. Consider grouping/readonly-marking the stable fields. - - [ ] Remove the `chars`/`len` double source of truth: they exist both as `parse()` loop locals and as `ParseContext` properties. Read from one (kept duplicated for hot-loop locality; measure before changing). - - [ ] Decompose `handleStateAddress` further (~200 lines). CFWS/`@`/non-atext handling is already split into helpers; the remaining bulk is inherent to the address sub-state machine, so this is diminishing-returns polish. - -**Community / documentation:** -- [x] `CONTRIBUTING.md` — dev setup, all `composer` scripts, test-case guidance, code-style rules, RFC citation expectations. -- [x] GitHub issue + pull-request templates — YAML issue forms (parser-tailored bug report + feature request) with a config linking Discussions/cookbook, plus a PR template. -- [x] `CODE_OF_CONDUCT.md` — minimal statement + report contact (mmucklo@gmail.com). -- [x] Examples cookbook — `docs/cookbook.md` (parsing, presets, streaming, UTF-8/IDN, error codes/severity, `canonical()`, local-part normalizer, confusable-domain detection, legacy array API). Linked from the README. -- [ ] README cleanup — split the large reference tables into `docs/` sub-pages if the top-level README grows further. - -## v4.0 — Breaking Modernization +### v4.0 — Breaking modernization **API cleanup:** -- [ ] Remove deprecated `ParseOptions` setters (see Deprecation Timeline above). -- [ ] Remove `parse()` in favor of `parseSingle()` / `parseMultiple()` with typed returns — eliminates the polymorphic `$multiple` boolean parameter. +- [ ] Remove the `@deprecated` `ParseOptions` setters (deprecated in v3.0). +- [ ] Promote the `ParseOptions` state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) to public `readonly` via constructor promotion with named arguments. +- [ ] Remove the polymorphic `parse()` in favor of `parseSingle()` / `parseMultiple()` with typed returns — drops the `$multiple` boolean parameter. - [ ] Deprecate or remove the `getInstance()` singleton (recommend explicit instantiation). -- [ ] Constructor promotion on `ParseOptions` with named arguments. -- [ ] Make the internal validation helpers `private` (notably `validateLocalPart`, which takes the parser-internal `ParseContext`, and `validateDomainName`). They are `protected` only for historical reasons and were never a supported extension point — validation is customized through `ParseOptions`. Marked `@internal` when the `parse()` decomposition landed, which already changed `validateLocalPart`'s signature (`array` → `ParseContext`). +- [ ] Make the internal validation helpers (`validateLocalPart`, `validateDomainName`) `private`. They are already `@internal`; `validateLocalPart`'s signature became `ParseContext` in the `parse()` decomposition. They take the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. + +**New capabilities (breaking or late-binding):** +- [ ] DNS/MX validation via a `DnsValidator` callback interface — breaking because the `Parse` constructor grows, and synchronous lookups change performance characteristics. +- [ ] Group syntax (RFC 6854: `Group Name: addr1, addr2;`) — introduces a new output-container shape for grouped results. +- [ ] Confusable-against-a-target-list matching — compare the domain's Unicode skeleton against a caller-supplied brand/skeleton set (`Spoofchecker::areConfusable()`), following on from the v3.8 single-string check. Deferred until the caller-provided target list is designed. -**New capabilities (genuinely breaking or late-binding):** -- [ ] Optional DNS/MX validation via callback interface (`DnsValidator`). Breaking because the Parse constructor signature grows, and because synchronous DNS lookups change performance characteristics meaningfully. -- [ ] Group syntax support (RFC 6854: `Group Name: addr1, addr2;`). Breaking because it introduces a new output-container shape for grouped results. -- [x] **Optional homoglyph / confusable-domain detection** (shipped in 3.8.0). A domain like `аpple.com` (Cyrillic `а`, U+0430) is valid RFC syntax but a visual spoof of `apple.com`. `withDetectConfusableDomain()` runs the `intl` `Spoofchecker` (mixed-script / confusable) over the U-label domain and surfaces `ParsedEmailAddress::$domainIsSuspicious` — a security-policy signal, not a validity check: the address stays valid. Opt-in (default off), and legitimate single-script international domains (`почта.рф`, `münchen.de`) are not flagged. -- [ ] **Confusable-against-a-target-list matching** (follow-up to the above; not yet done). Detect "looks like `paypal.com`" by comparing the domain's Unicode skeleton against a caller-supplied brand/skeleton set (`Spoofchecker::areConfusable()` or skeleton maps). Deferred because it needs the caller to provide the target list — it isn't a self-contained check like single-string suspicion. +### Backlog (unversioned) -*Note: `canonicalize()` and the local-part normalizer callback were moved to v3.3 as additive (non-breaking) features.* +- [ ] **`parse()` refactor follow-ups** (from review; non-blocking): rename `ParseContext`'s accumulator fields `snake_case` → `camelCase`; encode its three concerns (immutable input snapshot / read-only config / mutable accumulator) structurally rather than by convention; drop the `chars` / `len` duplication (loop locals vs context properties — kept for hot-loop locality, measure before changing); decompose `handleStateAddress` further (~200 lines; diminishing returns). +- [ ] **Ecosystem bridges:** `mmucklo/email-parse-symfony` (`Constraint` + `ConstraintValidator`), `mmucklo/email-parse-laravel` (validation rule + service provider), PSR-14 `ParsedAddressEvent` for observability. +- [ ] **Large-batch profiling:** the `mb_str_split` array dominates memory for very large batches; a streaming/chunked reader could bound it. +- [ ] **README cleanup:** split the large reference tables into `docs/` sub-pages if the top-level README keeps growing. From 2c1c75d35e464cabfcf4879e317e9f600d421ba0 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 22:22:07 -0700 Subject: [PATCH 08/11] ci(psalm): prune baseline entries made stale by the parse() refactor The decomposition changed where Psalm narrows $ctx->state, so 15 baseline suppressions no longer match any code and CI (findUnusedBaselineEntry) fails them: 1 InvalidCast, 1 ParadoxicalCondition, 1 RedundantCondition, and 12 TypeDoesNotContainType. Regenerated with --update-baseline (69 -> 54 entries); no new suppressions added. Psalm, PHPStan L8, CS, and 108 tests all green. --- psalm-baseline.xml | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 47553ec..cf9501c 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -6,20 +6,6 @@
- - state]]> - - - state? - $emailAddress->original_address .= $curChar; - $emailAddress->invalid = true; - $emailAddress->invalid_reason = 'Error during parsing'; - $emailAddress->invalid_reason_code = Err::ParseError; - $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$emailAddress->state}\n\$subState: {$emailAddress->subState}\n\$i: {$i}\n\$curChar: {$curChar}"); - - break;]]> - @@ -29,23 +15,6 @@ - - - - - state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)]]> - - - ['No closing parenthesis: \')\'', Err::UnterminatedComment]]]> - state]]> - state]]> - - ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress]]]> - - ['No ending quote: \'"\'', Err::UnterminatedQuote]]]> - - ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket]]]> - From 7fba7a0ae7dc8244db4824f4e461c7bd48a390d3 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 22:47:19 -0700 Subject: [PATCH 09/11] test+docs: reentrancy test, @internal ParseContext, changelog, roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-driven follow-ups on the parse() decomposition. - Add a reentrancy regression test: a localPartNormalizer that re-enters the same parser mid-parse. Locks in the property the whole ParseContext design exists for — a fresh per-call context, never on the instance — so a future change that stored parse state on $this would fail here. - Mark ParseContext @internal (its ~24-field shape is not a stable API) and fix an orphaned docblock: resetAddress()'s description had been stranded above __construct() when the constructor was added. - CHANGELOG [Unreleased]: record the internal decomposition and the validateLocalPart() array -> ParseContext / @internal change. - ROADMAP: expand the refactor follow-ups with the SOTA items surfaced in review — readonly snapshot/config fields, a ParserState backed enum (benchmark-gated), and decomposing addAddress(). 109 tests / 7177 assertions, PHPStan L8, Psalm, CS all green. --- CHANGELOG.md | 4 ++++ ROADMAP.md | 7 ++++++- src/ParseContext.php | 16 +++++++++++----- tests/ParseTest.php | 42 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 188ea0f..4808e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed +- **Internal: `Parse::parse()` decomposed** into a per-state handler dispatch loop backed by a new `ParseContext` accumulator object. Pure structural refactor — no change to parsing logic, conditions, ordering, error codes, or output shape; the address arrays and `ParsedEmailAddress` objects are byte-identical. A fresh `ParseContext` is created per call and never stored on the parser, so `parse()` is reentrant across a `localPartNormalizer` callback. See [ARCHITECTURE.md](ARCHITECTURE.md). +- **`protected Parse::validateLocalPart()` signature changed** (`array` → `ParseContext`) and is now marked `@internal`, as is `ParseContext` itself. These are implementation details, not extension points — customize validation via `ParseOptions`. Both are slated to become `private` in v4.0. + ## [3.8.0] Adds opt-in homoglyph / confusable-domain detection. Additive and off by default — no behavior change unless you enable it. diff --git a/ROADMAP.md b/ROADMAP.md index 7c37105..8cb4f3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,7 +85,12 @@ Continuous work, not tied to a specific release. ### Backlog (unversioned) -- [ ] **`parse()` refactor follow-ups** (from review; non-blocking): rename `ParseContext`'s accumulator fields `snake_case` → `camelCase`; encode its three concerns (immutable input snapshot / read-only config / mutable accumulator) structurally rather than by convention; drop the `chars` / `len` duplication (loop locals vs context properties — kept for hot-loop locality, measure before changing); decompose `handleStateAddress` further (~200 lines; diminishing returns). +- [ ] **`parse()` refactor & modernization follow-ups** (from review; non-blocking, each behavior-preserving and test-gated): + - [ ] Rename `ParseContext`'s accumulator fields `snake_case` → `camelCase` to match the codebase. Output-array keys stay `snake_case` (public API); only the internal properties change. Kept as-is during extraction so the diff was a pure move. + - [ ] **Encode `ParseContext`'s three concerns structurally.** The immutable input snapshot (`chars`/`len`/`emails`), the read-only hoisted config (`separators`, `bannedChars`, …), and the mutable per-address accumulator are all plain public fields today, so nothing stops a handler writing config. Promote the snapshot + config to `readonly` (constructor-promoted) so only the accumulator stays mutable — the clearest SOTA/correctness win, but it needs `parse()`'s construction reworked (the snapshot is currently assigned after `new`). + - [ ] **Consider a `ParserState: int` backed enum** in place of the 13 `STATE_*` int constants. Gives type-safety on `$ctx->state`/`$subState` and would likely retire the Psalm state-narrowing baseline entries. Gate on a benchmark: the dispatch is a hot loop, so measure enum-vs-int comparison/array-key overhead before committing (the no-regression constraint still applies). + - [ ] Drop the `chars`/`len` double source of truth (loop locals vs context properties — kept for hot-loop locality; measure before changing). + - [ ] Decompose the two remaining large methods — `handleStateAddress` (~209 lines; CFWS/`@`/non-atext already peeled off, the rest is inherent to the addr-spec sub-machine) and `addAddress` (~221 lines, pre-existing; splits into IP-literal detection, validation, and output-array assembly). Both diminishing-returns polish. - [ ] **Ecosystem bridges:** `mmucklo/email-parse-symfony` (`Constraint` + `ConstraintValidator`), `mmucklo/email-parse-laravel` (validation rule + service provider), PSR-14 `ParsedAddressEvent` for observability. - [ ] **Large-batch profiling:** the `mb_str_split` array dominates memory for very large batches; a streaming/chunked reader could bound it. - [ ] **README cleanup:** split the large reference tables into `docs/` sub-pages if the top-level README keeps growing. diff --git a/src/ParseContext.php b/src/ParseContext.php index d85b913..5565ada 100644 --- a/src/ParseContext.php +++ b/src/ParseContext.php @@ -18,6 +18,9 @@ * so they thread through the validation helpers unchanged; the public output * array shape is built separately in {@see Parse::addAddress()} and is * unaffected by this object. + * + * @internal Implementation detail of {@see Parse}. The field shape is not a + * stable API and may change between minor versions. */ final class ParseContext { @@ -148,11 +151,6 @@ final class ParseContext */ public string $obs_route = ''; - /** - * Resets every accumulator field to its initial value, reusing the instance - * for the next address in a multi-address parse (matches the historical - * "rebuild the $emailAddress array" behaviour). - */ /** * @param int $state Initial parser state (a Parse::STATE_* value). * @param int $subState Initial addr-spec sub-state (a Parse::STATE_* value). @@ -165,6 +163,14 @@ public function __construct(int $state, int $subState) $this->resetAddress($state, $subState); } + /** + * Resets every accumulator field to its initial value, reusing the instance + * for the next address in a multi-address parse (matches the historical + * "rebuild the $emailAddress array" behaviour). + * + * @param int $state Parser state to start the next address in (Parse::STATE_*). + * @param int $subState Addr-spec sub-state to start it in (Parse::STATE_*). + */ public function resetAddress(int $state, int $subState): void { // Loop-control state, reset here so every per-address field has a single diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 1150377..06086d4 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1684,4 +1684,46 @@ public function testLocalPartNormalizerCanBeClearedByPassingNull(): void $this->assertNotNull($a->localPartNormalizer); $this->assertNull($b->localPartNormalizer); } + + /** + * Reentrancy: parse() keeps its state in a fresh per-call ParseContext, never + * on the Parse instance, so a localPartNormalizer callback may re-enter the + * SAME parser mid-parse without corrupting the outer parse. If any parse + * state were stored on $this, the inner call would clobber the outer one. + */ + public function testParserIsReentrantAcrossLocalPartNormalizer(): void + { + /** @var Parse|null $parser */ + $parser = null; + $innerResult = null; + /** @var bool $reentered */ + $reentered = false; + + // Runs while the outer parse is finalizing its address; re-enters the + // same parser once. The flag is set BEFORE re-entering so the nested + // call's own normalizer skips it (otherwise it recurses forever). + $normalizer = function (string $localPart, string $domain) use (&$parser, &$reentered, &$innerResult): string { + if (!$reentered) { + $reentered = true; + \assert($parser instanceof Parse); + $innerResult = $parser->parseSingle('inner.user@nested.example.org'); + } + + return $localPart; // pass through unchanged + }; + + $parser = new Parse(null, (new ParseOptions())->withLocalPartNormalizer($normalizer)); + $outer = $parser->parseSingle('outer.name@outer.example.com'); + + // Outer parse is intact despite the re-entrant inner parse. + $this->assertFalse($outer->invalid); + $this->assertSame('outer.name', $outer->localPart); + $this->assertSame('outer.example.com', $outer->domain); + + // The inner (re-entrant) parse ran and returned its own correct result. + $this->assertInstanceOf(\Email\ParsedEmailAddress::class, $innerResult, 'normalizer did not re-enter parse()'); + $this->assertFalse($innerResult->invalid); + $this->assertSame('inner.user', $innerResult->localPart); + $this->assertSame('nested.example.org', $innerResult->domain); + } } From 6eb4ae6cd0b6d1d626a07323d107c9acd21fa406 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 23:06:44 -0700 Subject: [PATCH 10/11] refactor: keep validateLocalPart() array signature for full BC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the parse() decomposition fully backward compatible: no public or protected signature changes vs the previous release. validateLocalPart() reads only local_part_parsed and local_part_quoted from the accumulator, so instead of changing its signature to ParseContext it keeps its original array signature and is dispatched through a small legacy-array bridge at the one call site. It stays a live (virtual) extension point — a subclass override is still invoked — but is now @deprecated and removed in 4.0, where local-part validation folds into a private ParseContext-based method. The supported way to customize validation remains ParseOptions. - Added a BC regression test: a Parse subclass overriding validateLocalPart() still changes the outcome (rejects a 'blocked' local part). - ParseContext stays @internal. - One @psalm-suppress DeprecatedMethod on the intentional internal BC hook. 110 tests / 7193 assertions, PHPStan L8, Psalm, CS all green. --- CHANGELOG.md | 6 ++++-- ROADMAP.md | 2 +- src/Parse.php | 26 +++++++++++++++++--------- tests/ParseTest.php | 29 +++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4808e4a..39d652a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] ### Changed -- **Internal: `Parse::parse()` decomposed** into a per-state handler dispatch loop backed by a new `ParseContext` accumulator object. Pure structural refactor — no change to parsing logic, conditions, ordering, error codes, or output shape; the address arrays and `ParsedEmailAddress` objects are byte-identical. A fresh `ParseContext` is created per call and never stored on the parser, so `parse()` is reentrant across a `localPartNormalizer` callback. See [ARCHITECTURE.md](ARCHITECTURE.md). -- **`protected Parse::validateLocalPart()` signature changed** (`array` → `ParseContext`) and is now marked `@internal`, as is `ParseContext` itself. These are implementation details, not extension points — customize validation via `ParseOptions`. Both are slated to become `private` in v4.0. +- **Internal: `Parse::parse()` decomposed** into a per-state handler dispatch loop backed by a new `ParseContext` accumulator object. Pure structural refactor — no change to parsing logic, conditions, ordering, error codes, or output shape; the address arrays and `ParsedEmailAddress` objects are byte-identical, and **no public or protected method signature changed** (fully backward compatible). A fresh `ParseContext` is created per call and never stored on the parser, so `parse()` is reentrant across a `localPartNormalizer` callback. See [ARCHITECTURE.md](ARCHITECTURE.md). + +### Deprecated +- **`protected Parse::validateLocalPart(array $emailAddress)`** — deprecated, removed in 4.0. It keeps its original `array` signature and remains a live extension point (a subclass override is still invoked), so existing subclasses keep working; going forward, customize validation through `ParseOptions` instead. The new `ParseContext` accumulator is `@internal` — its field shape is not a stable API. ## [3.8.0] diff --git a/ROADMAP.md b/ROADMAP.md index 8cb4f3d..435c0f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -76,7 +76,7 @@ Continuous work, not tied to a specific release. - [ ] Promote the `ParseOptions` state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) to public `readonly` via constructor promotion with named arguments. - [ ] Remove the polymorphic `parse()` in favor of `parseSingle()` / `parseMultiple()` with typed returns — drops the `$multiple` boolean parameter. - [ ] Deprecate or remove the `getInstance()` singleton (recommend explicit instantiation). -- [ ] Make the internal validation helpers (`validateLocalPart`, `validateDomainName`) `private`. They are already `@internal`; `validateLocalPart`'s signature became `ParseContext` in the `parse()` decomposition. They take the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. +- [ ] Remove the deprecated `Parse::validateLocalPart(array)` extension point (deprecated when the `parse()` decomposition landed) and fold local-part validation into a `private`, `ParseContext`-based method; likewise make `validateDomainName()` `private`. They take the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. **New capabilities (breaking or late-binding):** - [ ] DNS/MX validation via a `DnsValidator` callback interface — breaking because the `Parse` constructor grows, and synchronous lookups change performance characteristics. diff --git a/src/Parse.php b/src/Parse.php index efff849..e205782 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -1301,9 +1301,15 @@ private function addAddress( $ctx->invalid_reason_code = Err::InvalidDisplayNamePhrase; } - // Unified local-part validation + // Unified local-part validation. Dispatched through validateLocalPart(), + // a deprecated but backward-compatible extension point (removed in 4.0), + // so it still receives the legacy accumulator-array shape it always took. if (!$ctx->invalid) { - $result = $this->validateLocalPart($ctx); + /** @psalm-suppress DeprecatedMethod Intentional BC hook so subclass overrides still fire; see validateLocalPart(). */ + $result = $this->validateLocalPart([ + 'local_part_parsed' => $ctx->local_part_parsed, + 'local_part_quoted' => $ctx->local_part_quoted, + ]); if (!$result['valid']) { $ctx->invalid = true; $ctx->invalid_reason = $result['reason']; @@ -1429,18 +1435,20 @@ private function isDomainConfusable(string $domain): bool /** * Unified local-part validation based on ParseOptions rule properties. * - * @internal Not a supported extension point. It takes the parser's internal - * accumulator (ParseContext); customize validation via ParseOptions - * rather than by overriding this. Slated to become private in v4.0. + * @deprecated 3.9.0 Not a supported extension point going forward — customize + * validation through ParseOptions, not by overriding this. Kept + * with its original array signature for backward compatibility + * and removed in 4.0. Receives the accumulator keys it reads: + * `local_part_parsed` (string) and `local_part_quoted` (bool). * - * @param ParseContext $ctx The email address accumulator from the parser + * @param array{local_part_parsed: string, local_part_quoted: bool} $emailAddress * @return array{valid: bool, reason: ?string, code: ?ParseErrorCode, normalized: ?string} */ - protected function validateLocalPart(ParseContext $ctx): array + protected function validateLocalPart(array $emailAddress): array { $opts = $this->options; - $localPart = $ctx->local_part_parsed; - $quoted = $ctx->local_part_quoted; + $localPart = $emailAddress['local_part_parsed']; + $quoted = $emailAddress['local_part_quoted']; // RFC 6531 §3.3 / RFC 6532 §3.2: gate UTF-8 presence before other checks // (allowUtf8LocalPart is false in rfc5321() and rfc5322() presets) diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 06086d4..0aa3186 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1726,4 +1726,33 @@ public function testParserIsReentrantAcrossLocalPartNormalizer(): void $this->assertSame('inner.user', $innerResult->localPart); $this->assertSame('nested.example.org', $innerResult->domain); } + + /** + * Backward compatibility: validateLocalPart() keeps its original array + * signature as a deprecated extension point (removed in 4.0). A subclass + * override must still be invoked and able to change the outcome — the parser + * dispatches through $this->validateLocalPart(), not a renamed internal. + */ + public function testDeprecatedValidateLocalPartOverrideStillTakesEffect(): void + { + $parser = new class () extends Parse { + protected function validateLocalPart(array $emailAddress): array + { + if ('blocked' === $emailAddress['local_part_parsed']) { + return ['valid' => false, 'reason' => 'blocked local part', 'code' => null, 'normalized' => null]; + } + + return parent::validateLocalPart($emailAddress); + } + }; + + // Un-blocked address flows through parent::validateLocalPart() unchanged. + $ok = $parser->parseSingle('allowed@example.com'); + $this->assertFalse($ok->invalid); + + // The override fires and rejects an otherwise-valid address. + $blocked = $parser->parseSingle('blocked@example.com'); + $this->assertTrue($blocked->invalid, 'subclass validateLocalPart() override was not honored'); + $this->assertSame('blocked local part', $blocked->invalidReason); + } } From 1ed22be53781838066f64c191c284494aa3b28f9 Mon Sep 17 00:00:00 2001 From: Matthew J Mucklo Date: Tue, 25 Aug 2026 23:09:29 -0700 Subject: [PATCH 11/11] docs(roadmap): record validateLocalPart() deprecation in the ledger The v4.0 removal was already listed under Planned; add the matching entry to the Deprecations record so "what's deprecated / when removed" is complete. --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index 435c0f1..21af298 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,6 +31,7 @@ below as a record; planned work follows. ### Deprecations - **v3.0:** `LengthLimits` moved to readonly constructor promotion (getters/setters removed — see [UPGRADE.md](UPGRADE.md)). The `ParseOptions` setters (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) are marked `@deprecated` and still functional; removal is targeted for v4.0. +- **v3.9:** `protected Parse::validateLocalPart(array $emailAddress)` marked `@deprecated`. Still functional and still a live extension point (subclass overrides are invoked), but customizing validation via `ParseOptions` is the supported path; removal is targeted for v4.0 (see Planned below). - `RfcMode` never shipped (existed only on a feature branch). ### Community & documentation