Skip to content
Closed
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
11 changes: 11 additions & 0 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/Mail/MimePart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;


/**
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}

Expand Down
81 changes: 81 additions & 0 deletions tests/Mail/Mail.email.encodedName.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php declare(strict_types=1);

/**
* Test: Nette\Mail\Message quotes an encoded display name only when it could be re-parsed.
*/

use Nette\Mail\Message;
use Tester\Assert;


require __DIR__ . '/../bootstrap.php';


test('ASCII name outside atext is a real quoted-string', function () {
$mail = new Message;
$mail->setFrom('doe@example.com', 'John.Doe');

Assert::same('"John.Doe" <doe@example.com>', $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 <doe@example.com>', $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==?= <doe@example.com>', $header);
Assert::same('Jan Nováček <doe@example.com>', 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 <doe@example.com>',
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" <doe@example.com>',
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" <doe@example.com>',
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 <spoof@example.com>');

Assert::contains(
'"Nováček <spoof@example.com>"',
iconv_mime_decode($mail->getEncodedHeader('From'), 0, 'UTF-8'),
);
});
6 changes: 3 additions & 3 deletions tests/Mail/Mail.email.multiple.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ Assert::match(<<<'EOD'
X-Mailer: Nette Framework
Date: %a%
From: John Doe <doe@example.com>
To: Lady Jane <jane@example.foo>,williams@example.foo,=?UTF-8?B?Ig==?=
=?UTF-8?B?xZhlaG/FmSDFmMOtemVrIg==?= <rizek@example.foo>,
=?UTF-8?B?Ikx1Ym/FoSBTbWHFvsOhayI=?= <smazak@example.foo>
To: Lady Jane <jane@example.foo>,williams@example.foo,=?UTF-8?B?xZg=?=
=?UTF-8?B?ZWhvxZkgxZjDrXplaw==?= <rizek@example.foo>,
=?UTF-8?B?THVib8WhIFNtYcW+w6Fr?= <smazak@example.foo>
Subject: Hello Jane!
Message-ID: <%S%@%S%>
Content-Type: text/plain; charset=UTF-8
Expand Down
10 changes: 7 additions & 3 deletions tests/Mail/Mail.email.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,21 @@ Assert::match(<<<'EOD'
MIME-Version: 1.0
X-Mailer: Nette Framework
Date: %a%
From: =?UTF-8?B?IsW9bHXFpW91xI1rw70ga8WvxYgi?= <kun1@example.com>
Reply-To: =?UTF-8?B?IsW9bHXFpW91xI1rw70ga8WvxYgi?= <kun2@example.com>,
From: =?UTF-8?B?xb1sdcWlb3XEjWvDvSBrxa/FiA==?= <kun1@example.com>
Reply-To: =?UTF-8?B?xb1sdcWlb3XEjWvDvSBrxa/FiA==?= <kun2@example.com>,
John Doe <doe2@example.com>
To: =?UTF-8?B?IsW9bHXFpW91xI1rw70gXCJrxa/FiFwiIg==?= <kun3@example.com>,
John 'jd' Doe <doe3@example.com>
Cc: TheMail <nette@example.com>,John Doe <doe4@example.com>,"The.Mail"
<nette2@example.com>
Bcc: =?UTF-8?B?IsW9bHXFpW91xI1rw70ga8WvxYgi?= <kun5@example.com>,
Bcc: =?UTF-8?B?xb1sdcWlb3XEjWvDvSBrxa/FiA==?= <kun5@example.com>,
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==?='));
2 changes: 2 additions & 0 deletions tests/Mail/Mail.headers.002.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -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==?='));
6 changes: 3 additions & 3 deletions tests/Mail/Mail.longLines.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ Assert::match(<<<'EOD'
X-Mailer: Nette Framework
Date: %a%
From: John Doe <doe@example.com>
To: Lady Jane <jane@example.foo>,williams@example.foo,=?UTF-8?B?Ig==?=
=?UTF-8?B?xZhlaG/FmSDFmMOtemVrIg==?= <rizek@example.foo>,
=?UTF-8?B?Ikx1Ym/FoSBTbWHFvsOhayI=?= <smazak@example.foo>
To: Lady Jane <jane@example.foo>,williams@example.foo,=?UTF-8?B?xZg=?=
=?UTF-8?B?ZWhvxZkgxZjDrXplaw==?= <rizek@example.foo>,
=?UTF-8?B?THVib8WhIFNtYcW+w6Fr?= <smazak@example.foo>
Subject: Hello Jane!
Message-ID: <%S%@%S%>
Content-Type: text/plain; charset=UTF-8
Expand Down