From 9f6adcf1a5713dec7faf361d8201ba52778838ac 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: quote an encoded display name only when it could be re-parsed --- docs/internals.md | 11 ++++ src/Mail/MimePart.php | 14 +++-- tests/Mail/Mail.email.encodedName.phpt | 81 ++++++++++++++++++++++++++ tests/Mail/Mail.email.multiple.phpt | 6 +- tests/Mail/Mail.email.phpt | 10 +++- tests/Mail/Mail.headers.002.phpt | 2 + tests/Mail/Mail.longLines.phpt | 6 +- 7 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 tests/Mail/Mail.email.encodedName.phpt diff --git a/docs/internals.md b/docs/internals.md index 8990095..5d15b62 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -66,6 +66,17 @@ 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. +- **A display name is quoted on two different tests, one per path.** Emitted + literally, a `phrase` holding anything outside `atext` (a dot, a comma, ...) has + to become a `quoted-string` — that is real RFC 5322 syntax and the quotes are + delimiters. Emitted as an encoded-word, quotes are *not* syntax: per RFC 2047 + §6.2 decoding happens **after** the field body is parsed into tokens, so the + encoding already shields whatever the name contains, and quotes added before + encoding land inside the base64 payload and decode as part of the name the + recipient sees. The encoded path therefore quotes only for receivers that + re-parse the decoded phrase, and only for `,;:<>@"\` — characters able to + restructure an address list (#102 was a Gmail DKIM failure caused by a comma). + Diacritics and dots cannot restructure anything, so they stay bare. - `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..0423811 100644 --- a/src/Mail/MimePart.php +++ b/src/Mail/MimePart.php @@ -10,7 +10,7 @@ use Nette; use Nette\Utils\Arrays; use Nette\Utils\Strings; -use function addcslashes, array_keys, base64_encode, chunk_split, iconv_mime_encode, is_array, is_string, ltrim, preg_match, preg_replace, quoted_printable_encode, rtrim, sprintf, str_ends_with, str_repeat, str_replace, strcasecmp, stripslashes, strlen, strrpos, strspn, substr; +use function addcslashes, array_keys, base64_encode, chunk_split, iconv_mime_encode, is_array, is_string, ltrim, preg_match, preg_replace, quoted_printable_encode, rtrim, sprintf, str_ends_with, str_repeat, str_replace, strcasecmp, stripslashes, strlen, strpbrk, strrpos, strspn, substr; /** @@ -303,15 +303,14 @@ public function getEncodedMessage(): string */ private static function encodeSequence(string $s, int &$offset = 0, ?int $type = null): string { - $escape = fn($s) => preg_match('#[^ a-zA-Z0-9!\#$%&\'*+/?^_`{|}~-]#', $s) === 1 // RFC 2822 atext except = - ? sprintf('"%s"', addcslashes($s, '"\\')) - : $s; + $escape = fn($s) => sprintf('"%s"', addcslashes($s, '"\\')); if ( (strlen($s) < self::LineLength - 3) && // 3 is tab + quotes strspn($s, "!\"#$%&\\'()*+,-./0123456789:;<>@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^`abcdefghijklmnopqrstuvwxyz{|}~=? _\r\n\t") === strlen($s) ) { - if ($type !== null) { + // emitted literally, so anything outside atext has to become a quoted-string + if ($type !== null && preg_match('#[^ a-zA-Z0-9!\#$%&\'*+/?^_`{|}~-]#', $s) === 1) { // RFC 2822 atext except = $s = $escape($s); } @@ -324,7 +323,10 @@ private static function encodeSequence(string $s, int &$offset = 0, ?int $type = $offset = 1; } - if ($type === self::SequenceWord) { + // RFC 2047 decoding happens after the field is parsed, so the encoding already shields + // the phrase. Quote it only for receivers that re-parse the decoded text, and only for + // characters able to restructure the address list - quotes added here stay visible. + if ($type === self::SequenceWord && strpbrk($s, ',;:<>@"\\') !== false) { $s = $escape($s); } diff --git a/tests/Mail/Mail.email.encodedName.phpt b/tests/Mail/Mail.email.encodedName.phpt new file mode 100644 index 0000000..90819b1 --- /dev/null +++ b/tests/Mail/Mail.email.encodedName.phpt @@ -0,0 +1,81 @@ +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('a dot does not make an encoded name quoted', function () { + $mail = new Message; + $mail->setFrom('doe@example.com', 'Objednávky domena.cz'); + + Assert::same( + 'Objednávky domena.cz ', + iconv_mime_decode($mail->getEncodedHeader('From'), 0, 'UTF-8'), + ); +}); + + +test('a comma keeps the encoded name quoted', function () { + $mail = new Message; + $mail->setFrom('doe@example.com', 'Nováček, Jan'); + + Assert::same( + '"Nováček, Jan" ', + iconv_mime_decode($mail->getEncodedHeader('From'), 0, 'UTF-8'), + ); +}); + + +test('a quote in an encoded name is escaped', 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('an address-like name stays quoted', function () { + $mail = new Message; + $mail->setFrom('doe@example.com', 'Nováček '); + + Assert::contains( + '"Nováček "', + iconv_mime_decode($mail->getEncodedHeader('From'), 0, 'UTF-8'), + ); +}); 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..f5d9cf2 100644 --- a/tests/Mail/Mail.email.phpt +++ b/tests/Mail/Mail.email.phpt @@ -39,17 +39,21 @@ 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==?= , 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); + +// a plain diacritic name decodes bare, one holding a quote keeps the quoted-string +Assert::match('Žluťoučký kůň', iconv_mime_decode('=?UTF-8?B?xb1sdcWlb3XEjWvDvSBrxa/FiA==?=')); +Assert::match('"Žluťoučký \"kůň\""', iconv_mime_decode('=?UTF-8?B?IsW9bHXFpW91xI1rw70gXCJrxa/FiFwiIg==?=')); diff --git a/tests/Mail/Mail.headers.002.phpt b/tests/Mail/Mail.headers.002.phpt index 15d0ff0..243953d 100644 --- a/tests/Mail/Mail.headers.002.phpt +++ b/tests/Mail/Mail.headers.002.phpt @@ -49,4 +49,6 @@ Assert::match(<<<'EOD' Content-Transfer-Encoding: 7bit EOD, TestMailer::$output); +// the comma could restructure the address list for a receiver that re-parses the decoded +// phrase, so this name keeps its quotes (see #102) Assert::match('"Kdo uteče, obědvá"', iconv_mime_decode('=?UTF-8?B?IktkbyB1dGXEjWUsIG9ixJtkdsOhIg==?=')); 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