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
21 changes: 17 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
tests:
name: Tests (PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
Expand All @@ -18,7 +19,17 @@ jobs:
- '8.2'
- '8.3'
- '8.4'

- '8.5'
experimental:
- false
# PHP 8.6 is not yet released (stable ~Nov 2026); test against nightly
# builds so removed/deprecated functions surface early, but allow it to
# fail — nightly breakage and PHPUnit 9.6's own deprecations must not
# block PRs.
include:
- php-version: '8.6'
experimental: true

steps:
- name: Checkout code
uses: actions/checkout@v4
Expand All @@ -41,11 +52,13 @@ jobs:
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-${{ matrix.php-version }}-

# Dev dependencies may not yet declare support for the unreleased PHP 8.6;
# ignore only the php upper-bound so the suite can still run there.
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-suggest
run: composer install --prefer-dist --no-progress ${{ matrix.experimental && '--ignore-platform-req=php+' || '' }}

- name: Run test suite
run: bin/phpunit
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Fixed
- **PHP 8.6 deprecation** ([#57](https://github.com/mmucklo/email-parse/issues/57)): `validateDomainName()` no longer calls `mb_regex_encoding()`/`mb_split()`, both of which emit `E_DEPRECATED` under PHP 8.6 (the underlying oniguruma library is unmaintained). The domain is already ASCII at that point (post-punycode, via `normalizeDomainAscii()`), so label splitting now uses a plain `explode('.', …)` — behavior is unchanged.

## [3.3.1]

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Not tied to a specific release; picked up as time allows.
- [~] 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.
- [ ] CI matrix: add PHP 8.5 once released.
- [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).

**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()`.
Expand Down
7 changes: 0 additions & 7 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@
</PossiblyUnusedMethod>
</file>
<file src="src/Parse.php">
<InvalidScalarArgument>
<code><![CDATA[$origEncoding]]></code>
</InvalidScalarArgument>
<PossiblyInvalidArgument>
<code><![CDATA[$tempIp]]></code>
</PossiblyInvalidArgument>
Expand Down Expand Up @@ -42,10 +39,6 @@
<code><![CDATA[self::STATE_NAME === $subState]]></code>
<code><![CDATA[self::STATE_NAME === $subState]]></code>
</TypeDoesNotContainType>
<UnusedFunctionCall>
<code><![CDATA[mb_regex_encoding]]></code>
<code><![CDATA[mb_regex_encoding]]></code>
</UnusedFunctionCall>
</file>
<file src="src/ParseOptions.php">
<PossiblyUnusedMethod>
Expand Down
27 changes: 10 additions & 17 deletions src/Parse.php
Original file line number Diff line number Diff line change
Expand Up @@ -1374,30 +1374,23 @@ protected function normalizeDomainAscii(string $domain): ?string
* - RFC 1123 §2.1 relaxed the original restriction that allowed labels starting
* with a letter only, permitting labels that start with a digit.
*
* @param string $domain The ASCII domain name to validate (after punycode conversion)
* @param string $encoding The encoding of the string (if not UTF-8)
* @param string $domain The ASCII domain name to validate (after punycode conversion)
*
* @return array{valid: bool, reason?: string, code?: ParseErrorCode}
*/
protected function validateDomainName(string $domain, string $encoding = 'UTF-8'): array
protected function validateDomainName(string $domain): array
{
// RFC 5321 §4.5.3.1.2: total domain length limit is in octets
if (strlen($domain) > 255) {
return ['valid' => false, 'reason' => 'Domain name too long', 'code' => Err::DomainTooLong];
} else {
// mb_regex_encoding() can return false on failure; only restore when
// we got back a real encoding name.
$origEncoding = mb_regex_encoding();
mb_regex_encoding($encoding);
$parts = mb_split('\\.', $domain);
if ($origEncoding) {
mb_regex_encoding($origEncoding);
}
// mb_split() can return false on failure; treat that as a validation
// failure rather than iterating over a bogus value.
if ($parts === false) {
return ['valid' => false, 'reason' => 'Domain name could not be tokenized', 'code' => Err::DomainInvalid];
}
// $domain is always ASCII here (post-punycode, via normalizeDomainAscii),
// so a plain explode on the label separator is sufficient. This avoids
// mb_regex_encoding(), deprecated since PHP 8.6 (the underlying oniguruma
// library is no longer maintained). See GitHub issue #57.
// Labels are guaranteed non-empty: the state machine rejects consecutive
// and edge dots (ConsecutiveDots) before the domain validator runs.
$parts = explode('.', $domain);
$maxLabelLen = $this->options->getLengthLimits()->maxDomainLabelLength;
foreach ($parts as $part) {
if (strlen($part) > $maxLabelLen) {
Expand All @@ -1406,7 +1399,7 @@ protected function validateDomainName(string $domain, string $encoding = 'UTF-8'
if (!preg_match('/^[a-zA-Z0-9\-]+$/', $part)) {
return ['valid' => false, 'reason' => "Domain name '{$domain}' can only contain letters a through z, numbers 0 through 9 and hyphen. The part '{$part}' contains characters outside of that range.", 'code' => Err::DomainContainsInvalidChars];
}
if ('-' == mb_substr($part, 0, 1, $encoding) || '-' == mb_substr($part, mb_strlen($part) - 1, 1, $encoding)) {
if ('-' == substr($part, 0, 1) || '-' == substr($part, -1)) {
return ['valid' => false, 'reason' => "Parts of the domain name '{$domain}' can not start or end with '-'. This part does: {$part}", 'code' => Err::DomainLabelStartsOrEndsWithHyphen];
}
}
Expand Down
134 changes: 134 additions & 0 deletions tests/ParseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,140 @@ public function testStrictIdnaRejectsBareLeadingHyphenLabel(): void
$this->assertTrue($result->invalid);
}

/**
* The main-loop tokenizer walks the input with mb_substr($emails, $i, 1, $encoding),
* so a non-UTF-8 caller encoding must be honored: the ISO-8859-1 byte 0xF6 ("ö") is
* one character, not an invalid UTF-8 lead byte. Guards the encoding-threading path,
* which the YAML spec harness (always UTF-8) never exercises.
*/
public function testNonUtf8EncodingIsHonoredByTokenizer(): void
{
// "Jörg <j@example.com>" with ö as the single ISO-8859-1 byte 0xF6.
$input = "J\xF6rg <j@example.com>";
$result = Parse::getInstance()->parseSingle($input, 'ISO-8859-1');

$this->assertFalse($result->invalid);
$this->assertSame('j', $result->localPart);
$this->assertSame('example.com', $result->domain);
// The 0xF6 byte is preserved verbatim as one character in the display name.
$this->assertSame("J\xF6rg", $result->name);
}

/**
* Latin-1 is single-byte, so it does not exercise mb_substr's variable-width
* character indexing. Shift-JIS does: the kanji 日 is two bytes (0x93 0xFA) but
* one character, and the tokenizer must advance by character, not by byte.
*/
public function testVariableWidthEncodingIsHonoredByTokenizer(): void
{
$input = mb_convert_encoding('日本 <j@example.com>', 'SJIS', 'UTF-8');
$result = Parse::getInstance()->parseSingle($input, 'SJIS');

$this->assertFalse($result->invalid);
$this->assertSame('j', $result->localPart);
$this->assertSame('example.com', $result->domain);
// The two 2-byte kanji round-trip intact — proof the byte offsets never split a char.
$this->assertSame('日本', mb_convert_encoding($result->name, 'UTF-8', 'SJIS'));
}

/**
* Malformed UTF-8 in the local part (a lone 0x80 continuation byte). Whether the
* mb_check_encoding guard ever sees the bad byte depends on mbstring: PHP 8.1/8.2
* preserve it through mb_substr (so it is rejected as InvalidUtf8Encoding), while
* 8.3+ substitute it during tokenization (so it never reaches the guard). Probe the
* runtime behavior rather than the version, and require rejection only where the
* tokenizer preserves invalid bytes; everywhere else the parser must still return a
* deterministic, crash-free result.
*/
public function testMalformedUtf8LocalPartHandling(): void
{
$loneByte = pack('C', 0x80); // a lone UTF-8 continuation byte
$input = 'us'.$loneByte.'er@example.com';
$result = Parse::getInstance()->parseSingle($input);

if (mb_substr($loneByte, 0, 1, 'UTF-8') === $loneByte) {
$this->assertTrue($result->invalid);
$this->assertSame(\Email\ParseErrorCode::InvalidUtf8Encoding, $result->invalidReasonCode);
} else {
// Byte sanitized before validation; result must be deterministic.
$this->assertSame($result->invalid, Parse::getInstance()->parseSingle($input)->invalid);
}
}

/**
* RFC 5321 §4.5.3.1 octet limits are exclusive upper bounds; verify each comparison
* accepts the exact maximum and rejects one octet past it (guards `>` vs `>=`):
* - local part 64 (§4.5.3.1.1), domain label 63 (§4.5.3.1.2), whole address 254.
*/
public function testLengthBoundariesAcceptMaxAndRejectOneOver(): void
{
$p = Parse::getInstance();
$Err = \Email\ParseErrorCode::class;

// Local part: 64 octets is the maximum; 65 is over.
$this->assertFalse($p->parseSingle(str_repeat('a', 64).'@example.com')->invalid);
$this->assertSame($Err::LocalPartTooLong, $p->parseSingle(str_repeat('a', 65).'@example.com')->invalidReasonCode);

// Domain label: 63 octets is the maximum; 64 is over.
$this->assertFalse($p->parseSingle('u@'.str_repeat('a', 63).'.com')->invalid);
$this->assertSame($Err::DomainLabelTooLong, $p->parseSingle('u@'.str_repeat('a', 64).'.com')->invalidReasonCode);

// Whole address: 254 octets is the maximum; 255 is over. Labels kept <= 63.
$at254 = str_repeat('a', 64).'@'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 61);
$this->assertSame(254, strlen($at254));
$this->assertFalse($p->parseSingle($at254)->invalid);
$at255 = str_repeat('a', 64).'@'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 62);
$this->assertSame($Err::TotalLengthExceeded, $p->parseSingle($at255)->invalidReasonCode);
}

/**
* When idn_to_ascii() cannot produce a valid A-label (here a U-label whose punycode
* expansion overflows the 63-octet limit), normalizeDomainAscii() returns null and
* the address is rejected with PunycodeConversionFailed rather than crashing.
*/
public function testPunycodeConversionFailureIsReported(): void
{
$result = Parse::getInstance()->parseSingle('user@'.str_repeat('ä', 70).'.de');
$this->assertTrue($result->invalid);
$this->assertSame(\Email\ParseErrorCode::PunycodeConversionFailed, $result->invalidReasonCode);
}

/**
* IDN conversion assumes UTF-8. A non-ASCII domain supplied under a mismatched caller
* encoding (Shift-JIS bytes reinterpreted by idn_to_ascii) must fail gracefully — a
* clean invalid result, never an exception or warning.
*/
public function testMismatchedEncodingDomainFailsGracefully(): void
{
$input = mb_convert_encoding('user@日本.com', 'SJIS', 'UTF-8');
$result = Parse::getInstance()->parseSingle($input, 'SJIS');
$this->assertTrue($result->invalid);
$this->assertNotNull($result->invalidReasonCode);
}

/**
* With NFC normalization enabled (rfc6531), Normalizer::normalize() runs before the
* mb_check_encoding guard and returns false on malformed UTF-8, so a preserved bad
* byte surfaces as LocalPartCannotBeNormalized (RFC 6532 §3.1) rather than
* InvalidUtf8Encoding. Gated on the same mbstring tokenizer behavior as
* testMalformedUtf8LocalPartHandling: 8.3+ substitutes the byte before it is reached.
*/
public function testLocalPartNormalizationFailureIsReported(): void
{
$opts = ParseOptions::rfc6531()->withRequireFqdn(false);
$parser = new Parse(null, $opts);
$loneByte = pack('C', 0x80);
$input = 'us'.$loneByte.'er@example.com';
$result = $parser->parseSingle($input);

if (mb_substr($loneByte, 0, 1, 'UTF-8') === $loneByte) {
$this->assertTrue($result->invalid);
$this->assertSame(\Email\ParseErrorCode::LocalPartCannotBeNormalized, $result->invalidReasonCode);
} else {
$this->assertSame($result->invalid, $parser->parseSingle($input)->invalid);
}
}

/**
* Exercises every `withX()` fluent builder. Each call must return a new
* instance with the targeted field flipped and every other field preserved.
Expand Down
58 changes: 58 additions & 0 deletions tests/testspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,64 @@
invalid: true
invalid_reason: "Domain invalid: Parts of the domain name '-bad-domain.com' can not start or end with '-'. This part does: -bad-domain"
comments: []
-
emails: testing@baddomain-.com
multiple: false
result:
address: ''
simple_address: ''
original_address: testing@baddomain-.com
name: ''
name_parsed: ''
local_part: testing
local_part_parsed: testing
domain_part: 'baddomain-.com'
domain: 'baddomain-.com'
domain_ascii: null
ip: ''
invalid: true
invalid_reason: "Domain invalid: Parts of the domain name 'baddomain-.com' can not start or end with '-'. This part does: baddomain-"
comments: []
-
# Domain label containing a char outside [A-Za-z0-9-] reaches validateDomainName's
# per-label preg_match check (underscore survives the state machine but fails here).
emails: user@bad_domain.com
multiple: false
result:
address: ''
simple_address: ''
original_address: user@bad_domain.com
name: ''
name_parsed: ''
local_part: user
local_part_parsed: user
domain_part: 'bad_domain.com'
domain: 'bad_domain.com'
domain_ascii: null
ip: ''
invalid: true
invalid_reason: "Domain invalid: Domain name 'bad_domain.com' can only contain letters a through z, numbers 0 through 9 and hyphen. The part 'bad_domain' contains characters outside of that range."
comments: []
-
# Domain exceeding RFC 5321 §4.5.3.1.2 total octet limit (256 > 255) trips the
# length guard before label splitting.
emails: user@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com
multiple: false
result:
address: ''
simple_address: ''
original_address: user@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com
name: ''
name_parsed: ''
local_part: user
local_part_parsed: user
domain_part: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com'
domain: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com'
domain_ascii: null
ip: ''
invalid: true
invalid_reason: "Domain invalid: Domain name too long"
comments: []
-
emails: testing@192.168.0.1
multiple: false
Expand Down
Loading