feature #36178 [Mime] allow non-ASCII characters in local part of email (dmaicher)

This PR was merged into the 5.2-dev branch.

Discussion
----------

[Mime] allow non-ASCII characters in local part of email

| Q             | A
| ------------- | ---
| Branch?       | 4.4
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Tickets       | https://github.com/symfony/symfony/issues/34932
| License       | MIT
| Doc PR        | -

This fixes https://github.com/symfony/symfony/issues/34932 by allowing non-ASCII characters in the local part of emails.

I tried this using 3 different smtp servers (gmail, mailgun and local postfix) and for me this just works in case there are non-ASCII characters in the local part of emails. Emails are correctly delivered.

PHPMailer does this in the same way: https://github.com/PHPMailer/PHPMailer/blob/master/src/PHPMailer.php#L1411

This is also in line with the behavior of Swiftmailer (< 6.1) **before** this commit that introduced the `IdnAddressEncoder`: 6a87efd39b (diff-e5f85d26733017e183b2633ae3c433f0R31)

I'm not an expert when it comes to SMTP and all the different RFCs out there 😕 But for me this exception seems not needed.

Maybe @c960657 can help here?

Commits
-------

d057dffcd6 [Mime] allow non-ASCII characters in local part of email
This commit is contained in:
Fabien Potencier 2020-07-10 18:20:57 +02:00
commit 63f8827cf0
7 changed files with 36 additions and 26 deletions

View File

@ -41,11 +41,11 @@ final class DelayedEnvelope extends Envelope
public function getSender(): Address public function getSender(): Address
{ {
if ($this->senderSet) { if (!$this->senderSet) {
return parent::getSender(); parent::setSender(self::getSenderFromHeaders($this->message->getHeaders()));
} }
return self::getSenderFromHeaders($this->message->getHeaders()); return parent::getSender();
} }
public function setRecipients(array $recipients): void public function setRecipients(array $recipients): void

View File

@ -44,6 +44,10 @@ class Envelope
public function setSender(Address $sender): void public function setSender(Address $sender): void
{ {
// to ensure deliverability of bounce emails independent of UTF-8 capabilities of SMTP servers
if (!preg_match('/^[^@\x80-\xFF]++@/', $sender->getAddress())) {
throw new InvalidArgumentException(sprintf('Invalid sender "%s": non-ASCII characters not supported in local-part of email.', $sender->getAddress()));
}
$this->sender = new Address($sender->getAddress()); $this->sender = new Address($sender->getAddress());
} }

View File

@ -13,9 +13,11 @@ namespace Symfony\Component\Mailer\Tests;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\InvalidArgumentException;
use Symfony\Component\Mailer\Exception\LogicException; use Symfony\Component\Mailer\Exception\LogicException;
use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Header\Headers; use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Header\PathHeader;
use Symfony\Component\Mime\Message; use Symfony\Component\Mime\Message;
use Symfony\Component\Mime\RawMessage; use Symfony\Component\Mime\RawMessage;
@ -27,6 +29,13 @@ class EnvelopeTest extends TestCase
$this->assertEquals(new Address('fabien@symfony.com'), $e->getSender()); $this->assertEquals(new Address('fabien@symfony.com'), $e->getSender());
} }
public function testConstructorWithAddressSenderAndNonAsciiCharactersInLocalPartOfAddress()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid sender "fabièn@symfony.com": non-ASCII characters not supported in local-part of email.');
new Envelope(new Address('fabièn@symfony.com'), [new Address('thomas@symfony.com')]);
}
public function testConstructorWithNamedAddressSender() public function testConstructorWithNamedAddressSender()
{ {
$e = new Envelope(new Address('fabien@symfony.com', 'Fabien'), [new Address('thomas@symfony.com')]); $e = new Envelope(new Address('fabien@symfony.com', 'Fabien'), [new Address('thomas@symfony.com')]);
@ -57,19 +66,27 @@ class EnvelopeTest extends TestCase
$headers->addPathHeader('Return-Path', new Address('return@symfony.com', 'return')); $headers->addPathHeader('Return-Path', new Address('return@symfony.com', 'return'));
$headers->addMailboxListHeader('To', ['from@symfony.com']); $headers->addMailboxListHeader('To', ['from@symfony.com']);
$e = Envelope::create(new Message($headers)); $e = Envelope::create(new Message($headers));
$this->assertEquals(new Address('return@symfony.com', 'return'), $e->getSender()); $this->assertEquals(new Address('return@symfony.com'), $e->getSender());
$headers = new Headers(); $headers = new Headers();
$headers->addMailboxHeader('Sender', new Address('sender@symfony.com', 'sender')); $headers->addMailboxHeader('Sender', new Address('sender@symfony.com', 'sender'));
$headers->addMailboxListHeader('To', ['from@symfony.com']); $headers->addMailboxListHeader('To', ['from@symfony.com']);
$e = Envelope::create(new Message($headers)); $e = Envelope::create(new Message($headers));
$this->assertEquals(new Address('sender@symfony.com', 'sender'), $e->getSender()); $this->assertEquals(new Address('sender@symfony.com'), $e->getSender());
$headers = new Headers(); $headers = new Headers();
$headers->addMailboxListHeader('From', [new Address('from@symfony.com', 'from'), 'some@symfony.com']); $headers->addMailboxListHeader('From', [new Address('from@symfony.com', 'from'), 'some@symfony.com']);
$headers->addMailboxListHeader('To', ['from@symfony.com']); $headers->addMailboxListHeader('To', ['from@symfony.com']);
$e = Envelope::create(new Message($headers)); $e = Envelope::create(new Message($headers));
$this->assertEquals(new Address('from@symfony.com', 'from'), $e->getSender()); $this->assertEquals(new Address('from@symfony.com'), $e->getSender());
}
public function testSenderFromHeadersFailsWithNonAsciiCharactersInLocalPart()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid sender "fabièn@symfony.com": non-ASCII characters not supported in local-part of email.');
$message = new Message(new Headers(new PathHeader('Return-Path', new Address('fabièn@symfony.com'))));
Envelope::create($message)->getSender();
} }
public function testSenderFromHeadersWithoutFrom() public function testSenderFromHeadersWithoutFrom()
@ -78,7 +95,7 @@ class EnvelopeTest extends TestCase
$headers->addMailboxListHeader('To', ['from@symfony.com']); $headers->addMailboxListHeader('To', ['from@symfony.com']);
$e = Envelope::create($message = new Message($headers)); $e = Envelope::create($message = new Message($headers));
$message->getHeaders()->addMailboxListHeader('From', [new Address('from@symfony.com', 'from')]); $message->getHeaders()->addMailboxListHeader('From', [new Address('from@symfony.com', 'from')]);
$this->assertEquals(new Address('from@symfony.com', 'from'), $e->getSender()); $this->assertEquals(new Address('from@symfony.com'), $e->getSender());
} }
public function testRecipientsFromHeaders() public function testRecipientsFromHeaders()

View File

@ -11,16 +11,14 @@
namespace Symfony\Component\Mime\Encoder; namespace Symfony\Component\Mime\Encoder;
use Symfony\Component\Mime\Exception\AddressEncoderException;
/** /**
* An IDN email address encoder. * An IDN email address encoder.
* *
* Encodes the domain part of an address using IDN. This is compatible will all * Encodes the domain part of an address using IDN. This is compatible will all
* SMTP servers. * SMTP servers.
* *
* This encoder does not support email addresses with non-ASCII characters in * Note: It leaves the local part as is. In case there are non-ASCII characters
* local-part (the substring before @). * in the local part then it depends on the SMTP Server if this is supported.
* *
* @author Christian Schmidt * @author Christian Schmidt
*/ */
@ -28,8 +26,6 @@ final class IdnAddressEncoder implements AddressEncoderInterface
{ {
/** /**
* Encodes the domain part of an address using IDN. * Encodes the domain part of an address using IDN.
*
* @throws AddressEncoderException If local-part contains non-ASCII characters
*/ */
public function encodeString(string $address): string public function encodeString(string $address): string
{ {
@ -38,10 +34,6 @@ final class IdnAddressEncoder implements AddressEncoderInterface
$local = substr($address, 0, $i); $local = substr($address, 0, $i);
$domain = substr($address, $i + 1); $domain = substr($address, $i + 1);
if (preg_match('/[^\x00-\x7F]/', $local)) {
throw new AddressEncoderException(sprintf('Non-ASCII characters not supported in local-part os "%s".', $address));
}
if (preg_match('/[^\x00-\x7F]/', $domain)) { if (preg_match('/[^\x00-\x7F]/', $domain)) {
$address = sprintf('%s@%s', $local, idn_to_ascii($domain, 0, INTL_IDNA_VARIANT_UTS46)); $address = sprintf('%s@%s', $local, idn_to_ascii($domain, 0, INTL_IDNA_VARIANT_UTS46));
} }

View File

@ -58,11 +58,10 @@ class MailboxHeaderTest extends TestCase
$this->assertEquals('Fabien =?'.$header->getCharset().'?Q?P=8Ftencier?= <fabien@symfony.com>', $header->getBodyAsString()); $this->assertEquals('Fabien =?'.$header->getCharset().'?Q?P=8Ftencier?= <fabien@symfony.com>', $header->getBodyAsString());
} }
public function testUtf8CharsInLocalPartThrows() public function testUtf8CharsInLocalPart()
{ {
$this->expectException('Symfony\Component\Mime\Exception\AddressEncoderException');
$header = new MailboxHeader('Sender', new Address('fabïen@symfony.com')); $header = new MailboxHeader('Sender', new Address('fabïen@symfony.com'));
$header->getBodyAsString(); $this->assertSame('fabïen@symfony.com', $header->getBodyAsString());
} }
public function testToString() public function testToString()

View File

@ -55,11 +55,10 @@ class MailboxListHeaderTest extends TestCase
$this->assertEquals(['Chris Corbyn <chris@xn--swftmailer-78a.org>'], $header->getAddressStrings()); $this->assertEquals(['Chris Corbyn <chris@xn--swftmailer-78a.org>'], $header->getAddressStrings());
} }
public function testUtf8CharsInLocalPartThrows() public function testUtf8CharsInLocalPart()
{ {
$this->expectException('Symfony\Component\Mime\Exception\AddressEncoderException');
$header = new MailboxListHeader('From', [new Address('chrïs@swiftmailer.org', 'Chris Corbyn')]); $header = new MailboxListHeader('From', [new Address('chrïs@swiftmailer.org', 'Chris Corbyn')]);
$header->getAddressStrings(); $this->assertSame(['Chris Corbyn <chrïs@swiftmailer.org>'], $header->getAddressStrings());
} }
public function testGetMailboxesReturnsNameValuePairs() public function testGetMailboxesReturnsNameValuePairs()

View File

@ -49,11 +49,10 @@ class PathHeaderTest extends TestCase
$this->assertEquals('<chris@xn--swftmailer-78a.org>', $header->getBodyAsString()); $this->assertEquals('<chris@xn--swftmailer-78a.org>', $header->getBodyAsString());
} }
public function testAddressMustBeEncodable() public function testAddressMustBeEncodableWithUtf8CharsInLocalPart()
{ {
$this->expectException('Symfony\Component\Mime\Exception\AddressEncoderException');
$header = new PathHeader('Return-Path', new Address('chrïs@swiftmailer.org')); $header = new PathHeader('Return-Path', new Address('chrïs@swiftmailer.org'));
$header->getBodyAsString(); $this->assertSame('<chrïs@swiftmailer.org>', $header->getBodyAsString());
} }
public function testSetBody() public function testSetBody()