From 3672f5f5277df5b6a3dc9770481c134c852e9485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hub=C3=ADk?= Date: Fri, 14 Aug 2026 14:18:39 +0200 Subject: [PATCH] MimePart: do not quote a display name that is MIME-encoded --- docs/internals.md | 7 +++ src/Mail/MimePart.php | 6 +-- tests/Mail/Mail.email.encodedName.phpt | 61 ++++++++++++++++++++++++++ tests/Mail/Mail.email.multiple.phpt | 6 +-- tests/Mail/Mail.email.phpt | 11 +++-- tests/Mail/Mail.headers.002.phpt | 5 ++- tests/Mail/Mail.longLines.phpt | 6 +-- 7 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 tests/Mail/Mail.email.encodedName.phpt diff --git a/docs/internals.md b/docs/internals.md index 8990095..638ffa7 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -66,6 +66,13 @@ Sub-parts are serialized recursively; a `boundary=` is appended to `Content-Type `Content-Disposition` filenames (quoted, RFC 2231 value), and plain values. Folding at `LineLength` (76) with tab continuation is volatile line-level mechanics — do not treat its exact offsets as contract. +- **Quoting a display name applies only to the literal path.** A `phrase` holding + characters outside `atext` (a dot, a comma, ...) is wrapped in a `quoted-string` + when it is emitted as-is. It must *not* be wrapped when it goes through + `iconv_mime_encode`: an encoded-word is built from atext-safe characters, so + nothing inside it needs protecting, and per RFC 2047 §5 an encoded-word must not + appear within a `quoted-string`. Quoting first would put the quotes inside the + base64 payload, where they decode as part of the name the recipient sees. - `Message::$defaultHeaders` is a **mutable static** (`MIME-Version`, `X-Mailer`), applied in the constructor — changing it affects every subsequently created message, and since `X-Mailer` is in DKIM's default diff --git a/src/Mail/MimePart.php b/src/Mail/MimePart.php index f970d10..0b9d0d3 100644 --- a/src/Mail/MimePart.php +++ b/src/Mail/MimePart.php @@ -324,10 +324,8 @@ private static function encodeSequence(string $s, int &$offset = 0, ?int $type = $offset = 1; } - if ($type === self::SequenceWord) { - $s = $escape($s); - } - + // no quoting here: an encoded-word is atext-safe, and per RFC 2047 it must not appear + // inside a quoted-string, the quotes would decode as part of the name $s = iconv_mime_encode(str_repeat(' ', $old = $offset), $s, [ 'scheme' => 'B', // Q is broken 'input-charset' => 'UTF-8', diff --git a/tests/Mail/Mail.email.encodedName.phpt b/tests/Mail/Mail.email.encodedName.phpt new file mode 100644 index 0000000..ee6306f --- /dev/null +++ b/tests/Mail/Mail.email.encodedName.phpt @@ -0,0 +1,61 @@ +setFrom('doe@example.com', 'John.Doe'); + + Assert::same('"John.Doe" ', $mail->getEncodedHeader('From')); +}); + + +test('ASCII name within atext is left alone', function () { + $mail = new Message; + $mail->setFrom('doe@example.com', 'John Doe'); + + Assert::same('John Doe ', $mail->getEncodedHeader('From')); +}); + + +test('encoded name carries no quotes', function () { + $mail = new Message; + $mail->setFrom('doe@example.com', 'Jan Nováček'); + + $header = $mail->getEncodedHeader('From'); + Assert::same('=?UTF-8?B?SmFuIE5vdsOhxI1law==?= ', $header); + Assert::same('Jan Nováček ', iconv_mime_decode($header, 0, 'UTF-8')); +}); + + +test('quotes in an encoded name are neither escaped nor doubled', function () { + $mail = new Message; + $mail->setFrom('doe@example.com', 'Jan "Honza" Nováček'); + + Assert::same( + 'Jan "Honza" Nováček ', + iconv_mime_decode($mail->getEncodedHeader('From'), 0, 'UTF-8'), + ); +}); + + +test('a comma in an encoded name cannot split the address list', function () { + $mail = new Message; + $mail->addTo('jane@example.com', 'Doe, Jane'); + $mail->addTo('john@example.com', 'Nováček, Jan'); + + $header = $mail->getEncodedHeader('To'); + + // the comma sits inside the base64 payload, so it cannot be read as an address separator + Assert::contains('=?UTF-8?B?Tm92w6HEjWVrLCBKYW4=?=', $header); + Assert::contains('"Doe, Jane" ', $header); +}); diff --git a/tests/Mail/Mail.email.multiple.phpt b/tests/Mail/Mail.email.multiple.phpt index 1a341bf..f79e555 100644 --- a/tests/Mail/Mail.email.multiple.phpt +++ b/tests/Mail/Mail.email.multiple.phpt @@ -32,9 +32,9 @@ Assert::match(<<<'EOD' X-Mailer: Nette Framework Date: %a% From: John Doe - To: Lady Jane ,williams@example.foo,=?UTF-8?B?Ig==?= - =?UTF-8?B?xZhlaG/FmSDFmMOtemVrIg==?= , - =?UTF-8?B?Ikx1Ym/FoSBTbWHFvsOhayI=?= + To: Lady Jane ,williams@example.foo,=?UTF-8?B?xZg=?= + =?UTF-8?B?ZWhvxZkgxZjDrXplaw==?= , + =?UTF-8?B?THVib8WhIFNtYcW+w6Fr?= Subject: Hello Jane! Message-ID: <%S%@%S%> Content-Type: text/plain; charset=UTF-8 diff --git a/tests/Mail/Mail.email.phpt b/tests/Mail/Mail.email.phpt index 0cf6431..058e781 100644 --- a/tests/Mail/Mail.email.phpt +++ b/tests/Mail/Mail.email.phpt @@ -39,17 +39,20 @@ Assert::match(<<<'EOD' MIME-Version: 1.0 X-Mailer: Nette Framework Date: %a% - From: =?UTF-8?B?IsW9bHXFpW91xI1rw70ga8WvxYgi?= - Reply-To: =?UTF-8?B?IsW9bHXFpW91xI1rw70ga8WvxYgi?= , + From: =?UTF-8?B?xb1sdcWlb3XEjWvDvSBrxa/FiA==?= + Reply-To: =?UTF-8?B?xb1sdcWlb3XEjWvDvSBrxa/FiA==?= , John Doe - To: =?UTF-8?B?IsW9bHXFpW91xI1rw70gXCJrxa/FiFwiIg==?= , + To: =?UTF-8?B?xb1sdcWlb3XEjWvDvSAia8WvxYgi?= , John 'jd' Doe Cc: TheMail ,John Doe ,"The.Mail" - Bcc: =?UTF-8?B?IsW9bHXFpW91xI1rw70ga8WvxYgi?= , + Bcc: =?UTF-8?B?xb1sdcWlb3XEjWvDvSBrxa/FiA==?= , doe5@example.com Return-Path: doe@example.com Message-ID: <%a%@%a%> Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit EOD, TestMailer::$output); + +// the encoded-word decodes to the name as it was passed in, quotes included +Assert::match('Žluťoučký "kůň"', iconv_mime_decode('=?UTF-8?B?xb1sdcWlb3XEjWvDvSAia8WvxYgi?=')); diff --git a/tests/Mail/Mail.headers.002.phpt b/tests/Mail/Mail.headers.002.phpt index 15d0ff0..b521351 100644 --- a/tests/Mail/Mail.headers.002.phpt +++ b/tests/Mail/Mail.headers.002.phpt @@ -35,7 +35,7 @@ Assert::match(<<<'EOD' MIME-Version: 1.0 X-Mailer: Nette Framework Date: %a% - From: =?UTF-8?B?IktkbyB1dGXEjWUsIG9ixJtkdsOhIg==?= + From: =?UTF-8?B?S2RvIHV0ZcSNZSwgb2LEm2R2w6E=?= To: Lady Jane Cc: jane@example.info Bcc: bcc@example.com @@ -49,4 +49,5 @@ Assert::match(<<<'EOD' Content-Transfer-Encoding: 7bit EOD, TestMailer::$output); -Assert::match('"Kdo uteče, obědvá"', iconv_mime_decode('=?UTF-8?B?IktkbyB1dGXEjWUsIG9ixJtkdsOhIg==?=')); +// the comma is safe inside the encoded-word, so it cannot split the address list +Assert::match('Kdo uteče, obědvá', iconv_mime_decode('=?UTF-8?B?S2RvIHV0ZcSNZSwgb2LEm2R2w6E=?=')); diff --git a/tests/Mail/Mail.longLines.phpt b/tests/Mail/Mail.longLines.phpt index 1e62144..6971efd 100644 --- a/tests/Mail/Mail.longLines.phpt +++ b/tests/Mail/Mail.longLines.phpt @@ -32,9 +32,9 @@ Assert::match(<<<'EOD' X-Mailer: Nette Framework Date: %a% From: John Doe - To: Lady Jane ,williams@example.foo,=?UTF-8?B?Ig==?= - =?UTF-8?B?xZhlaG/FmSDFmMOtemVrIg==?= , - =?UTF-8?B?Ikx1Ym/FoSBTbWHFvsOhayI=?= + To: Lady Jane ,williams@example.foo,=?UTF-8?B?xZg=?= + =?UTF-8?B?ZWhvxZkgxZjDrXplaw==?= , + =?UTF-8?B?THVib8WhIFNtYcW+w6Fr?= Subject: Hello Jane! Message-ID: <%S%@%S%> Content-Type: text/plain; charset=UTF-8