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/CHANGELOG.md b/CHANGELOG.md index 188ea0f..39d652a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ 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, 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] Adds opt-in homoglyph / confusable-domain detection. Additive and off by default — no behavior change unless you enable it. diff --git a/README.md b/README.md index afe3f35..9035163 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:** [Cookbook (recipes)](docs/cookbook.md) · [CHANGELOG](CHANGELOG.md) · [UPGRADE guide (v2.x → v3.0)](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ROADMAP](ROADMAP.md) +**Other docs:** [Cookbook (recipes)](docs/cookbook.md) · [CHANGELOG](CHANGELOG.md) · [UPGRADE guide (v2.x → v3.0)](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ARCHITECTURE](ARCHITECTURE.md) · [ROADMAP](ROADMAP.md) Installation: ------------- diff --git a/ROADMAP.md b/ROADMAP.md index a2bbca4..21af298 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,145 +1,97 @@ # 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. +- **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). -**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:** -- [ ] **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. - -**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. - -**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. - -*Note: `canonicalize()` and the local-part normalizer callback were moved to v3.3 as additive (non-breaking) features.* +- [ ] 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. +- [ ] 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. + +### Backlog (unversioned) + +- [ ] **`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/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..cf9501c 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -6,30 +6,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -39,15 +15,6 @@ - - - - - - - - - diff --git a/src/Parse.php b/src/Parse.php index b61b2ac..e205782 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -274,19 +274,16 @@ 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. 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; - // Current state of the parser - $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; - // 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 @@ -297,10 +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(); - $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. @@ -308,648 +301,70 @@ 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. + $ctx->chars = $chars; + $ctx->len = $len; + $ctx->multiple = $multiple; + $ctx->emails = $emails; + $ctx->separators = $this->options->getSeparators(); + $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 ($state) { + switch ($ctx->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($ctx, $curChar); break; /* @noinspection PhpMissingBreakStatementInspection — STATE_TRIM falls through to STATE_ADDRESS */ case self::STATE_TRIM: - if (isset($allowedWhitespace[$curChar])) { + if (!$this->handleStateTrim($ctx, $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($ctx, $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($ctx, $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($ctx, $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($ctx, $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($ctx, $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; - $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$state}\n\$subState: {$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 == $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 ); @@ -962,10 +377,8 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } } - // Reset all local variables used during parsing - $emailAddress = $this->buildEmailAddressArray(); - $subState = self::STATE_START; - $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 @@ -973,9 +386,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) { - $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; + 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; } } @@ -983,20 +396,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 (!$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 { @@ -1008,23 +421,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) { @@ -1044,78 +457,715 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } /** - * Resolves a pending quoted or temp buffer into the display name. + * 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 $ctx, string $curChar): void + { + $isWhitespaceSeparator = $ctx->useWhitespaceAsSeparator && isset($ctx->allowedWhitespace[$curChar]); + + if ($ctx->multiple && ($isWhitespaceSeparator || isset($ctx->separators[$curChar]))) { + $ctx->state = self::STATE_END_ADDRESS; + } else { + $ctx->original_address .= $curChar; + } + } + + /** + * STATE_TRIM: skip leading whitespace and detect a leading quote/comment. * - * Called when a display name is followed by an angle-addr (). - * 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. + * @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 $ctx, string $curChar): bool + { + if (isset($ctx->allowedWhitespace[$curChar])) { + return false; + } + $ctx->state = self::STATE_ADDRESS; + if ('"' == $curChar) { + $ctx->original_address .= $curChar; + $ctx->state = self::STATE_QUOTE; + + return false; + } + if ('(' == $curChar) { + $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 ")". + $ctx->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 handleQuote(array &$emailAddress): void + private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $prevChar, int $i): 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 (!isset($ctx->separators[$curChar]) || !$ctx->multiple) { + $ctx->original_address .= $curChar; + } + + 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)) { + $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 ($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)) { + $ctx->local_atom_split_by_comment = true; } } + + if ('(' == $curChar) { + // Handle comment + $ctx->state = self::STATE_COMMENT; + $ctx->commentNestLevel = 1; + + return; + } elseif (isset($ctx->separators[$curChar])) { + // Handle separator (comma, semicolon, etc.) + 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 + $ctx->state = self::STATE_END_ADDRESS; + + return; + } else { + $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 { + $ctx->invalid_reason = 'Separator not permitted - only one email address allowed'; + $ctx->invalid_reason_code = Err::SeparatorNotPermitted; + } + } + } elseif (isset($ctx->allowedWhitespace[$curChar])) { + if ($this->handleAddressWhitespace($ctx, $curChar, $i)) { + return; + } + } elseif ('<' == $curChar) { + // Start of the local part + 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 + $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. + $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 + // (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 == $ctx->subState + || (self::STATE_AFTER_DOMAIN == $ctx->subState + && ('' !== $ctx->domain || '' !== $ctx->ip))) { + $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->in_angle_addr = false; + } else { + $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 == $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 { + $ctx->state = self::STATE_QUOTE; + } + } elseif ('@' == $curChar) { + $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 != $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 { + $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 + $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 + $ctx->invalid = true; + $ctx->invalid_reason = "Email address can not start with '.'"; + $ctx->invalid_reason_code = Err::LeadingDot; + } else { + $ctx->local_part_parsed .= $curChar; + } + } 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 = ''; + } + $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. + $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($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) && + !$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 ($ctx->quote_temp) { + $ctx->local_part_parsed .= $ctx->quote_temp; + $ctx->quote_temp = ''; + $ctx->local_part_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; + } + $ctx->name_parsed .= $curChar; + } elseif (self::STATE_DOMAIN == $ctx->subState) { + $ctx->domain .= $curChar; + } else { + if ($ctx->quote_temp) { + $ctx->address_temp .= $ctx->quote_temp; + $ctx->address_temp_quoted = true; + $ctx->quote_temp = ''; + } + $ctx->address_temp .= $curChar; + } + } else { + $this->handleAddressNonAtext($ctx, $curChar); + } } /** - * Returns a fresh email address accumulator array with all fields zeroed. - * @return array + * 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 buildEmailAddressArray(): array + private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int $i): bool { - 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' => '', - ]; + // 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 < $ctx->len; ++$j) { + $c = $ctx->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 && !($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 ( + $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 === $ctx->subState) { + if ($ctx->domain === '' && $ctx->ip === '') { + // Leading CFWS of the domain dot-atom: "local@ domain". + $cfwsAbsorbed = true; + } + } elseif ( + self::STATE_START === $ctx->subState + && '@' === $lookAheadChar + && $ctx->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 == $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 ( + $ctx->in_angle_addr + && self::STATE_DOMAIN == $ctx->subState + && $lookAheadChar === '>' + ) { + // Trailing CFWS inside angle-addr before `>`: "". + // Absorb and transition as if we saw `>` next. + $ctx->subState = self::STATE_AFTER_DOMAIN; + } elseif ( + $ctx->multiple + && $lookAheadChar !== null + && 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). + $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 (!$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; + } + } + } + $ctx->state = self::STATE_END_ADDRESS; + + return true; + } else { + 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($ctx); + $ctx->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 $ctx): void + { + 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. + $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 + && $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. + && !$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. + $ctx->state = self::STATE_OBS_ROUTE; + $ctx->obs_route = '@'; + } else { + $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 ($ctx->address_temp && $ctx->quote_temp) { + $ctx->address_temp .= $ctx->quote_temp; + $ctx->address_temp_quoted = true; + $ctx->quote_temp = ''; + } + 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; + } + } + } + + /** + * 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 $ctx, string $curChar): void + { + if (self::STATE_DOMAIN == $ctx->subState) { + if ($this->isUtf8Char($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)) { + $ctx->domain .= $curChar; + } else { + $ctx->invalid = true; + } + } catch (\Exception $e) { + $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 ($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 === $ctx->subState || self::STATE_LOCAL_PART === $ctx->subState) { + // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently + 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 ($ctx->subState === self::STATE_START) { + $ctx->address_temp .= $curChar; + } else { + $ctx->local_part_parsed .= $curChar; + } + } elseif ($isUtf8) { + // UTF-8 present but not allowed by rules — collect and reject in validateLocalPart() + if ($ctx->subState === self::STATE_START) { + $ctx->address_temp .= $curChar; + // ??= preserves the first invalid character seen; later chars must not overwrite it + $ctx->special_char_in_substate ??= $curChar; + } else { + $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 ($ctx->subState === self::STATE_START) { + // ??= preserves the first invalid character seen; later chars must not overwrite it + $ctx->special_char_in_substate ??= $curChar; + $ctx->address_temp .= $curChar; + } else { + $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 === $ctx->subState) { + if ($ctx->quote_temp) { + $ctx->name_parsed .= $ctx->quote_temp; + $ctx->quote_temp = ''; + $ctx->name_quoted = true; + } + $ctx->special_char_in_substate = $curChar; + $ctx->name_parsed .= $curChar; + } else { + $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 $ctx, string $curChar): void + { + $ctx->original_address .= $curChar; + if (']' == $curChar) { + $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->state = self::STATE_ADDRESS; + } else { + $ctx->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 $ctx, string $curChar): void + { + $ctx->original_address .= $curChar; + if (':' == $curChar) { + $ctx->state = self::STATE_ADDRESS; + $ctx->subState = self::STATE_LOCAL_PART; + } elseif ('>' == $curChar) { + // `<@host>` without a colon — incomplete obs-route. + $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 { + $ctx->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 $ctx, string $curChar, int $i): void + { + $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 + // 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 ('\\' == $ctx->chars[$j]) { + ++$backslashCount; + } else { + break; + } + } + if ($backslashCount && 1 == $backslashCount % 2) { + // Odd number of backslashes = this quote is escaped + $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 + // (`""@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. + $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). + $ctx->invalid = true; + $ctx->invalid_reason = 'Control character in quoted string'; + $ctx->invalid_reason_code = Err::InvalidCharInQuotedString; + } else { + $ctx->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 $ctx, string $curChar): void + { + $ctx->original_address .= $curChar; + if ($ctx->comment_escaped) { + // Target of a quoted-pair — literal, never structural. + $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). + $ctx->comment_escaped = true; + } elseif (')' == $curChar) { + --$ctx->commentNestLevel; + if ($ctx->commentNestLevel <= 0) { + // End of comment - save it + if ($ctx->comment_temp) { + $ctx->comments[] = $ctx->comment_temp; + $ctx->comment_temp = ''; + } + $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 === $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 + $ctx->comment_temp .= $curChar; + } + } elseif ('(' == $curChar) { + ++$ctx->commentNestLevel; + if ($ctx->commentNestLevel > 1) { + // Nested comment opening parenthesis + $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. + $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. + $ctx->invalid = true; + $ctx->invalid_reason = 'Control character in comment'; + $ctx->invalid_reason_code = Err::ControlCharInComment; + } else { + // Regular comment character + $ctx->comment_temp .= $curChar; + } + } + + /** + * Resolves a pending quoted or temp buffer into the display name. + * + * Called when a display name is followed by an angle-addr (). + * 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 $ctx): void + { + 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; + } + } } /** @@ -1125,111 +1175,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 $ctx, + 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 (!$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; } } @@ -1239,30 +1290,36 @@ 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); + // 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) { + /** @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']) { - $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 @@ -1272,66 +1329,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']) { @@ -1378,7 +1435,13 @@ 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 + * @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 array{local_part_parsed: string, local_part_quoted: bool} $emailAddress * @return array{valid: bool, reason: ?string, code: ?ParseErrorCode, normalized: ?string} */ protected function validateLocalPart(array $emailAddress): array diff --git a/src/ParseContext.php b/src/ParseContext.php new file mode 100644 index 0000000..5565ada --- /dev/null +++ b/src/ParseContext.php @@ -0,0 +1,209 @@ + 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_*). + * 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. */ + public int $commentNestLevel = 0; + + // --- Accumulator fields (reset per address via resetAddress()). --- + + /** Raw address as given, comments included. */ + public string $original_address = ''; + + /** Display name without quotes. */ + public string $name_parsed = ''; + + /** Local-part without quotes. */ + public string $local_part_parsed = ''; + + /** Domain after '@' (may be Unicode/U-label). */ + public string $domain = ''; + + /** Punycode A-label domain, populated when it differs from $domain. */ + public ?string $domain_ascii = null; + + /** IP address if a domain-literal was used. */ + public string $ip = ''; + + public bool $invalid = false; + + public ?string $invalid_reason = null; + + public ?ParseErrorCode $invalid_reason_code = null; + + public bool $local_part_quoted = false; + + public bool $name_quoted = false; + + public bool $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. + */ + public bool $after_closing_quote = false; + + public string $quote_temp = ''; + + public string $address_temp = ''; + + public int $address_temp_period = 0; + + public ?string $special_char_in_substate = null; + + public string $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). + */ + public bool $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. + */ + public bool $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). + */ + public bool $local_atom_split_by_comment = false; + + /** @var array 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 = ''; + + /** + * @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); + } + + /** + * 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 + // 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 = ''; + $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 = ''; + } +} diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 1150377..0aa3186 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -1684,4 +1684,75 @@ 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); + } + + /** + * 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); + } }