Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

## [3.1.0]

Immutable `ParseOptions`, typed value-object output, structured error codes, and two new validation rules. All additions are non-breaking for v3.0 callers; readonly rule properties are a hard cutover for code that was mutating them directly (the factory methods and deprecated setters continue to work).

### Added
- `ParseErrorCode` backed enum exposing 46 distinct failure codes grouped by category (structural, character-class, dot placement, local-part content, quoted-string, domain, IP literal, length, display-name). Stable string backing values.
- `invalid_reason_code: ?ParseErrorCode` field on every parsed-address entry, populated at every `invalid_reason` emission site alongside the existing string.
- `ParsedEmailAddress` value object — immutable, readonly properties for all per-address fields (`address`, `originalAddress`, `simpleAddress`, `name`, `nameParsed`, `localPart`, `localPartParsed`, `domain`, `domainAscii`, `ip`, `domainPart`, `invalid`, `invalidReason`, `invalidReasonCode`, `comments`). `fromArray()` factory for conversion from the legacy array shape.
- `ParseResult` value object — immutable container for multi-address results (`success`, `reason`, `emailAddresses: list<ParsedEmailAddress>`).
- `Parse::parseSingle(string, string): ParsedEmailAddress` — typed single-address entry point.
- `Parse::parseMultiple(string, string): ParseResult` — typed multi-address entry point.
- `ParseOptions::withX()` fluent builders returning new instances: `withBannedChars`, `withSeparators`, `withUseWhitespaceAsSeparator`, `withLengthLimits`, plus one per rule property (19 builders in total).
- `validateDisplayNamePhrase: bool` rule — enforce RFC 5322 §3.2.5 phrase syntax (atext + WSP only) for unquoted display names. Adds `ParseErrorCode::InvalidDisplayNamePhrase`.
- `strictIdna: bool` rule — apply full IDNA2008 conformance on U-label domains (`IDNA_USE_STD3_RULES | IDNA_CHECK_BIDI | IDNA_CHECK_CONTEXTJ | IDNA_NONTRANSITIONAL_TO_ASCII`) per RFC 5891/5892/5893. Enabled by default in `rfc6531()`.

### Changed
- `ParseOptions`: the 15 boolean rule properties are now `readonly` and set via constructor named arguments or the factory presets. Direct assignment such as `$options->requireFqdn = false` now throws `Error` (use `withRequireFqdn(false)` instead).
- `ParseOptions::rfc6531()` preset now includes `strictIdna: true`.
- Existing `parse()` method unchanged — returns the same array shape plus the new `invalid_reason_code` key.

### Fixed
- None — no behavior regressions; only additions.

## [3.0.0]

Configurable RFC compliance presets, immutable length limits, stricter validation, and substantial documentation. See [UPGRADE.md](UPGRADE.md) for migration steps.
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,19 @@ Usage:
```php
use Email\Parse;

// Array-based API (v2.x-compatible)
$result = Parse::getInstance()->parse("a@aaa.com b@bbb.com");

// Typed value objects (v3.1+, recommended for new code)
$address = Parse::getInstance()->parseSingle('john@example.com');
echo $address->localPart; // "john"
echo $address->domain; // "example.com"
if ($address->invalid) {
echo $address->invalidReasonCode->value;
}

$result = Parse::getInstance()->parseMultiple('a@a.com, b@b.com');
foreach ($result->emailAddresses as $addr) { /* ... */ }
```

### Advanced Usage with ParseOptions
Expand Down Expand Up @@ -124,12 +136,12 @@ $result = $parser->parse('.user@example.com', false);

### Customizing Rules

Each preset sets a combination of boolean rule properties. You can override any of them after creating a preset:
Each preset sets a combination of boolean rule properties. Rule properties are **readonly** (v3.1+) — override them via fluent `withX()` builders that return new instances:

```php
$options = ParseOptions::rfc6531();
$options->requireFqdn = false; // Allow single-label domains
$options->includeDomainAscii = false; // Don't output punycode domain
$options = ParseOptions::rfc6531()
->withRequireFqdn(false) // Allow single-label domains
->withIncludeDomainAscii(false); // Don't output punycode domain
$parser = new Parse(null, $options);
```

Expand All @@ -152,6 +164,8 @@ $parser = new Parse(null, $options);
| `rejectC0Controls` | `false` | Reject C0 control characters U+0000-U+001F (RFC 5321) |
| `rejectC1Controls` | `false` | Reject C1 control characters U+0080-U+009F (RFC 6530) |
| `applyNfcNormalization` | `false` | Apply NFC Unicode normalization (RFC 6532 §3.1) |
| `validateDisplayNamePhrase` | `false` | Enforce RFC 5322 §3.2.5 phrase syntax on unquoted display names |
| `strictIdna` | `false` | Apply full IDNA2008 conformance on U-label domains (RFC 5891/5892/5893) |
| **Length & Output** | | |
| `enforceLengthLimits` | `true` | Enforce RFC 5321 length limits (64/254/63) |
| `includeDomainAscii` | `false` | Include punycode `domain_ascii` in output |
Expand Down
28 changes: 14 additions & 14 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,30 @@ Future plans by version. Items here are intent, not commitment — priority and
- [ ] Remove all `@deprecated` `ParseOptions` setters above.
- [ ] Make remaining private fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) public readonly via constructor promotion.

## v3.1 — Immutable Config, Error Codes, Typed Output
## v3.1 — Immutable Config, Error Codes, Typed Output — shipped

**Immutable `ParseOptions` with fluent builders:**
- [ ] Make all 15 boolean rule properties `readonly` (PHP 8.1) to prevent accidental mutation of shared instances (e.g. via DI container).
- [ ] Add fluent builder methods that return new instances:
- [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([...]);
ParseOptions::rfc5322()->withBannedChars([...])->withSeparators([...])->withRequireFqdn(true);
```
- Existing deprecated setters continue to work for backward compatibility.
- Deprecated setters continue to work for backward compatibility.

**Structured error codes:**
- [ ] Add a `ParseErrorCode` backed enum (e.g. `InvalidLocalPart`, `InvalidDomain`, `MissingDomain`, `Utf8NotAllowed`, `LengthExceeded`).
- [ ] Return `invalid_reason_code: ?ParseErrorCode` alongside the existing `invalid_reason` string — enables programmatic error handling without breaking existing consumers.
- [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.

**Typed output value objects (non-breaking):**
- [ ] `ParsedEmailAddress` — readonly properties for all per-address fields (`address`, `localPart`, `localPartParsed`, `domain`, `domainAscii`, `ip`, `domainPart`, `invalid`, `invalidReason`, `invalidReasonCode`, `comments`, etc.).
- [ ] `ParseResult` — readonly `success`, `reason`, `emailAddresses` (array of `ParsedEmailAddress`).
- [ ] New methods: `parseSingle(string): ParsedEmailAddress`, `parseMultiple(string): ParseResult`.
- Existing `parse()` stays for backward compatibility.
- [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.

**Additional validation rules:**
- [ ] `validateDisplayNamePhrase: bool` — enforce RFC 5322 §3.4 phrase syntax for display names.
- [ ] Stricter IDNA U-label validation for the `rfc6531()` preset (CONTEXTJ/CONTEXTO checks, Bidi rule per RFC 5891 §4 / RFC 5893). UTS#46 punycode conversion already done in v3.0.
- [ ] Extended test coverage (currently 224 assertions; target 250+).
- [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.2 — Streaming, Severity Levels, Obsolete Syntax

Expand Down
32 changes: 32 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Upgrade Guide

## v3.0 → v3.1

v3.1 is additive with one hard cutover: the 15 `ParseOptions` rule properties are now `readonly`. Factory presets and the deprecated setters still work. Everything else is new and non-breaking.

### Breaking Change

**`ParseOptions` rule properties are now readonly.** Direct assignment raises `Error`.

```php
// v3.0 — worked
$options = ParseOptions::rfc5322();
$options->requireFqdn = false;

// v3.1 — throws Error
$options = ParseOptions::rfc5322();
$options->requireFqdn = false; // Error: Cannot modify readonly property

// v3.1 migration — fluent builder returns a new instance
$options = ParseOptions::rfc5322()->withRequireFqdn(false);
```

There is a `withX()` builder for each of the 15 rule properties plus the 4 state fields (`withBannedChars`, `withSeparators`, `withUseWhitespaceAsSeparator`, `withLengthLimits`). Builders can be chained; each returns a new immutable instance with a single field replaced.

### Additions (Non-Breaking)

- **Typed output**: `Parse::parseSingle()` and `Parse::parseMultiple()` return `ParsedEmailAddress` / `ParseResult` value objects with readonly properties. The existing `parse()` method still returns arrays.
- **Structured error codes**: every parsed-address entry now includes `invalid_reason_code: ?ParseErrorCode` alongside the existing `invalid_reason` string. Match codes instead of error text:
```php
if ($result->invalidReasonCode === ParseErrorCode::MultipleAtSymbols) { … }
```
- **New rules**: `validateDisplayNamePhrase` (RFC 5322 §3.2.5) and `strictIdna` (RFC 5891/5892/5893). `strictIdna` is enabled by default in `ParseOptions::rfc6531()`.

## v2.x → v3.0

v3.0 introduces configurable RFC compliance presets, immutable length limits, and stricter validation rules. The default behavior of `new ParseOptions()` is preserved for backward compatibility, but a few public APIs have been removed or renamed. This guide lists every observable change.
Expand Down
20 changes: 20 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
coverage:
status:
project:
default:
# Overall coverage must not drop more than 1 percentage point vs. base.
target: auto
threshold: 1%
patch:
default:
# New lines in a PR must be at least 70% covered. Many new lines are
# invalid_reason_code assignments paired with pre-existing untested
# error branches; demanding 80% on those would require contrived
# tests for every parser edge case.
target: 70%
threshold: 0%

comment:
layout: "reach, diff, flags, files"
behavior: default
require_changes: false
30 changes: 30 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,36 @@ parameters:
count: 1
path: tests/ParseTest.php

-
message: '#^Method Email\\Tests\\ParseTest\:\:fillReasonCode\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/ParseTest.php

-
message: '#^Method Email\\Tests\\ParseTest\:\:normalizeActual\(\) has parameter \$result with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/ParseTest.php

-
message: '#^Method Email\\Tests\\ParseTest\:\:normalizeActual\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/ParseTest.php

-
message: '#^Method Email\\Tests\\ParseTest\:\:normalizeExpected\(\) has parameter \$result with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/ParseTest.php

-
message: '#^Method Email\\Tests\\ParseTest\:\:normalizeExpected\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/ParseTest.php

-
message: '#^Method Email\\Tests\\ParseTest\:\:testParseEmailAddresses\(\) has no return type specified\.$#'
identifier: missingType.return
Expand Down
Loading
Loading