diff --git a/src/Symfony/Component/Mailer/CHANGELOG.md b/src/Symfony/Component/Mailer/CHANGELOG.md index 01fb57558f366..ac98eb242df1b 100644 --- a/src/Symfony/Component/Mailer/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Make `TransportFactoryTestCase` compatible with PHPUnit 10+ + * Support unicode email addresses such as "dømi@dømi.fo" 7.1 --- diff --git a/src/Symfony/Component/Mailer/Envelope.php b/src/Symfony/Component/Mailer/Envelope.php index 45e1db36397b6..9bcb1a3c8bc93 100644 --- a/src/Symfony/Component/Mailer/Envelope.php +++ b/src/Symfony/Component/Mailer/Envelope.php @@ -85,4 +85,33 @@ public function getRecipients(): array { return $this->recipients; } + + /** + * Returns true if any address' localpart contains at least one + * non-ASCII character, and false if all addresses have all-ASCII + * localparts. + * + * This helps to decide whether to the SMTPUTF8 extensions (RFC + * 6530 and following) for any given message. + * + * The SMTPUTF8 extension is strictly required if any address + * contains a non-ASCII character in its localpart. If non-ASCII + * is only used in domains (e.g. horst@freiherr-von-mühlhausen.de) + * then it is possible to to send the message using IDN encoding + * instead of SMTPUTF8. The most common software will display the + * message as intended. + */ + public function anyAddressHasUnicodeLocalpart(): bool + { + if ($this->getSender()->hasUnicodeLocalpart()) { + return true; + } + foreach ($this->getRecipients() as $r) { + if ($r->hasUnicodeLocalpart()) { + return true; + } + } + + return false; + } } diff --git a/src/Symfony/Component/Mailer/Tests/EnvelopeTest.php b/src/Symfony/Component/Mailer/Tests/EnvelopeTest.php index de4eb0e0810a2..80feba96b38a2 100644 --- a/src/Symfony/Component/Mailer/Tests/EnvelopeTest.php +++ b/src/Symfony/Component/Mailer/Tests/EnvelopeTest.php @@ -98,7 +98,7 @@ public function testSenderFromHeadersWithoutFrom() $this->assertEquals($from, $e->getSender()); } - public function testSenderFromHeadersWithMulitpleHeaders() + public function testSenderFromHeadersWithMultipleHeaders() { $headers = new Headers(); $headers->addMailboxListHeader('From', [new Address('from@symfony.com', 'from'), 'some@symfony.com']); @@ -127,6 +127,19 @@ public function testRecipientsFromHeaders() $this->assertEquals([new Address('to@symfony.com'), new Address('cc@symfony.com'), new Address('bcc@symfony.com')], $e->getRecipients()); } + public function testUnicodeLocalparts() + { + /* dømi means example and is reserved by the .fo registry */ + $i = new Address('info@dømi.fo'); + $d = new Address('dømi@dømi.fo'); + $e = new Envelope($i, [$i]); + $this->assertFalse($e->anyAddressHasUnicodeLocalpart()); + $e = new Envelope($i, [$d]); + $this->assertTrue($e->anyAddressHasUnicodeLocalpart()); + $e = new Envelope($i, [$i, $d]); + $this->assertTrue($e->anyAddressHasUnicodeLocalpart()); + } + public function testRecipientsFromHeadersWithNames() { $headers = new Headers(); diff --git a/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportTest.php b/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportTest.php index 977d2a05e5981..9b4eacbf1b7f0 100644 --- a/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportTest.php +++ b/src/Symfony/Component/Mailer/Tests/Transport/Smtp/EsmtpTransportTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Mailer\Tests\Transport\Smtp; use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Transport\Smtp\Auth\CramMd5Authenticator; use Symfony\Component\Mailer\Transport\Smtp\Auth\LoginAuthenticator; @@ -62,6 +63,53 @@ public function testExtensibility() $this->assertContains("RCPT TO: NOTIFY=FAILURE\r\n", $stream->getCommands()); } + public function testSmtpUtf8() + { + $stream = new DummyStream(); + $transport = new SmtpUtf8EsmtpTransport(stream: $stream); + + $message = new Email(); + $message->from('info@dømi.fo'); + $message->addTo('dømi@dømi.fo'); + $message->text('.'); + + $transport->send($message); + + $this->assertContains("MAIL FROM: SMTPUTF8\r\n", $stream->getCommands()); + $this->assertContains("RCPT TO:\r\n", $stream->getCommands()); + } + + public function testMissingSmtpUtf8() + { + $stream = new DummyStream(); + $transport = new EsmtpTransport(stream: $stream); + + $message = new Email(); + $message->from('info@dømi.fo'); + $message->addTo('dømi@dømi.fo'); + $message->text('.'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid addresses: non-ASCII characters not supported in local-part of email.'); + $transport->send($message); + } + + public function testSmtpUtf8FallbackToIDN() + { + $stream = new DummyStream(); + $transport = new EsmtpTransport(stream: $stream); + + $message = new Email(); + $message->from('info@dømi.fo'); // UTF8 only in the domain + $message->addTo('example@example.com'); + $message->text('.'); + + $transport->send($message); + + $this->assertContains("MAIL FROM:\r\n", $stream->getCommands()); + $this->assertContains("RCPT TO:\r\n", $stream->getCommands()); + } + public function testConstructorWithDefaultAuthenticators() { $stream = new DummyStream(); @@ -270,3 +318,17 @@ public function executeCommand(string $command, array $codes): string return $response; } } + +class SmtpUtf8EsmtpTransport extends EsmtpTransport +{ + public function executeCommand(string $command, array $codes): string + { + $response = parent::executeCommand($command, $codes); + + if (str_starts_with($command, 'EHLO ')) { + $response .= "250 SMTPUTF8\r\n"; + } + + return $response; + } +} diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php index e3e12443be4a7..3663dc6f5604c 100644 --- a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php +++ b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php @@ -195,6 +195,11 @@ private function parseCapabilities(string $ehloResponse): array return $capabilities; } + protected function serverSupportsSmtpUtf8(): bool + { + return \array_key_exists('SMTPUTF8', $this->capabilities); + } + private function handleAuth(array $modes): void { if (!$this->username) { diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php b/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php index 432394dad5fef..9d99c061660a8 100644 --- a/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php +++ b/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php @@ -14,6 +14,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Exception\LogicException; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; @@ -211,7 +212,7 @@ protected function doSend(SentMessage $message): void try { $envelope = $message->getEnvelope(); - $this->doMailFromCommand($envelope->getSender()->getEncodedAddress()); + $this->doMailFromCommand($envelope->getSender()->getEncodedAddress(), $envelope->anyAddressHasUnicodeLocalpart()); foreach ($envelope->getRecipients() as $recipient) { $this->doRcptToCommand($recipient->getEncodedAddress()); } @@ -244,14 +245,22 @@ protected function doSend(SentMessage $message): void } } + protected function serverSupportsSmtpUtf8(): bool + { + return false; + } + private function doHeloCommand(): void { $this->executeCommand(\sprintf("HELO %s\r\n", $this->domain), [250]); } - private function doMailFromCommand(string $address): void + private function doMailFromCommand(string $address, bool $smtputf8): void { - $this->executeCommand(\sprintf("MAIL FROM:<%s>\r\n", $address), [250]); + if ($smtputf8 && !$this->serverSupportsSmtpUtf8()) { + throw new InvalidArgumentException('Invalid addresses: non-ASCII characters not supported in local-part of email.'); + } + $this->executeCommand(\sprintf("MAIL FROM:<%s>%s\r\n", $address, $smtputf8 ? ' SMTPUTF8' : ''), [250]); } private function doRcptToCommand(string $address): void diff --git a/src/Symfony/Component/Mailer/composer.json b/src/Symfony/Component/Mailer/composer.json index 76e2d9d486ffb..4336e725133fc 100644 --- a/src/Symfony/Component/Mailer/composer.json +++ b/src/Symfony/Component/Mailer/composer.json @@ -21,7 +21,7 @@ "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", + "symfony/mime": "^7.2", "symfony/service-contracts": "^2.5|^3" }, "require-dev": { diff --git a/src/Symfony/Component/Mime/Address.php b/src/Symfony/Component/Mime/Address.php index a78b64321d7fb..e05781ce5ead4 100644 --- a/src/Symfony/Component/Mime/Address.php +++ b/src/Symfony/Component/Mime/Address.php @@ -117,4 +117,24 @@ public static function createArray(array $addresses): array return $addrs; } + + /** + * Returns true if this address' localpart contains at least one + * non-ASCII character, and false if it is only ASCII (or empty). + * + * This is a helper for Envelope, which has to decide whether to + * the SMTPUTF8 extensions (RFC 6530 and following) for any given + * message. + * + * The SMTPUTF8 extension is strictly required if any address + * contains a non-ASCII character in its localpart. If non-ASCII + * is only used in domains (e.g. horst@freiherr-von-mühlhausen.de) + * then it is possible to to send the message using IDN encoding + * instead of SMTPUTF8. The most common software will display the + * message as intended. + */ + public function hasUnicodeLocalpart(): bool + { + return (bool) preg_match('/[\x80-\xFF].*@/', $this->address); + } } diff --git a/src/Symfony/Component/Mime/Tests/AddressTest.php b/src/Symfony/Component/Mime/Tests/AddressTest.php index baef170efc4f6..58c90161346f1 100644 --- a/src/Symfony/Component/Mime/Tests/AddressTest.php +++ b/src/Symfony/Component/Mime/Tests/AddressTest.php @@ -81,6 +81,13 @@ public function testCreateArray() $this->assertEquals([$fabien], Address::createArray(['fabien@symfony.com'])); } + public function testUnicodeLocalpart() + { + /* dømi means example and is reserved by the .fo registry */ + $this->assertFalse((new Address('info@dømi.fo'))->hasUnicodeLocalpart()); + $this->assertTrue((new Address('dømi@dømi.fo'))->hasUnicodeLocalpart()); + } + public function testCreateArrayWrongArg() { $this->expectException(\TypeError::class);