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);
+ }
}