diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9d593..c4699aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ CHANGELOG ========= +7.3 +--- + +* Add support for custom headers in ses+api + +7.1 +--- + +* Add support for `X-SES-LIST-MANAGEMENT-OPTIONS` + 6.1 --- diff --git a/Tests/Transport/SesApiAsyncAwsTransportTest.php b/Tests/Transport/SesApiAsyncAwsTransportTest.php index 42fbf74..8610a9d 100644 --- a/Tests/Transport/SesApiAsyncAwsTransportTest.php +++ b/Tests/Transport/SesApiAsyncAwsTransportTest.php @@ -91,6 +91,8 @@ public function testSend() $this->assertSame('aws-source-arn', $content['FromEmailAddressIdentityArn']); $this->assertSame('bounces@example.com', $content['FeedbackForwardingEmailAddress']); $this->assertSame([['Name' => 'tagName1', 'Value' => 'tag Value1'], ['Name' => 'tagName2', 'Value' => 'tag Value2']], $content['EmailTags']); + $this->assertSame(['ContactListName' => 'TestContactList', 'TopicName' => 'TestNewsletter'], $content['ListManagementOptions']); + $this->assertSame([['Name' => 'X-Custom-Header', 'Value' => 'foobar']], $content['Content']['Simple']['Headers']); $json = '{"MessageId": "foobar"}'; @@ -113,6 +115,8 @@ public function testSend() $mail->getHeaders()->addTextHeader('X-SES-CONFIGURATION-SET', 'aws-configuration-set-name'); $mail->getHeaders()->addTextHeader('X-SES-SOURCE-ARN', 'aws-source-arn'); + $mail->getHeaders()->addTextHeader('X-SES-LIST-MANAGEMENT-OPTIONS', 'contactListName=TestContactList;topicName=TestNewsletter'); + $mail->getHeaders()->addTextHeader('X-Custom-Header', 'foobar'); $mail->getHeaders()->add(new MetadataHeader('tagName1', 'tag Value1')); $mail->getHeaders()->add(new MetadataHeader('tagName2', 'tag Value2')); diff --git a/Tests/Transport/SesHttpAsyncAwsTransportTest.php b/Tests/Transport/SesHttpAsyncAwsTransportTest.php index e4cd8ca..0a3bbbe 100644 --- a/Tests/Transport/SesHttpAsyncAwsTransportTest.php +++ b/Tests/Transport/SesHttpAsyncAwsTransportTest.php @@ -88,6 +88,7 @@ public function testSend() $this->assertSame('aws-configuration-set-name', $body['ConfigurationSetName']); $this->assertSame('aws-source-arn', $body['FromEmailAddressIdentityArn']); $this->assertSame([['Name' => 'tagName1', 'Value' => 'tag Value1'], ['Name' => 'tagName2', 'Value' => 'tag Value2']], $body['EmailTags']); + $this->assertSame(['ContactListName' => 'TestContactList', 'TopicName' => 'TestNewsletter'], $body['ListManagementOptions']); $json = '{"MessageId": "foobar"}'; @@ -106,6 +107,7 @@ public function testSend() $mail->getHeaders()->addTextHeader('X-SES-CONFIGURATION-SET', 'aws-configuration-set-name'); $mail->getHeaders()->addTextHeader('X-SES-SOURCE-ARN', 'aws-source-arn'); + $mail->getHeaders()->addTextHeader('X-SES-LIST-MANAGEMENT-OPTIONS', 'contactListName=TestContactList;topicName=TestNewsletter'); $mail->getHeaders()->add(new MetadataHeader('tagName1', 'tag Value1')); $mail->getHeaders()->add(new MetadataHeader('tagName2', 'tag Value2')); diff --git a/Tests/Transport/SesTransportFactoryTest.php b/Tests/Transport/SesTransportFactoryTest.php index 4452c9c..b529c3c 100644 --- a/Tests/Transport/SesTransportFactoryTest.php +++ b/Tests/Transport/SesTransportFactoryTest.php @@ -19,12 +19,15 @@ use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesHttpAsyncAwsTransport; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesSmtpTransport; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; -use Symfony\Component\Mailer\Test\TransportFactoryTestCase; +use Symfony\Component\Mailer\Test\AbstractTransportFactoryTestCase; +use Symfony\Component\Mailer\Test\IncompleteDsnTestTrait; use Symfony\Component\Mailer\Transport\Dsn; use Symfony\Component\Mailer\Transport\TransportFactoryInterface; -class SesTransportFactoryTest extends TransportFactoryTestCase +class SesTransportFactoryTest extends AbstractTransportFactoryTestCase { + use IncompleteDsnTestTrait; + public function getFactory(): TransportFactoryInterface { return new SesTransportFactory(null, new MockHttpClient(), new NullLogger()); diff --git a/Transport/SesApiAsyncAwsTransport.php b/Transport/SesApiAsyncAwsTransport.php index 5ce2cad..67ace33 100644 --- a/Transport/SesApiAsyncAwsTransport.php +++ b/Transport/SesApiAsyncAwsTransport.php @@ -38,7 +38,7 @@ public function __toString(): string $host = $configuration->get('region'); } - return sprintf('ses+api://%s@%s', $configuration->get('accessKeyId'), $host); + return \sprintf('ses+api://%s@%s', $configuration->get('accessKeyId'), $host); } protected function getRequest(SentMessage $message): SendEmailRequest @@ -46,7 +46,7 @@ protected function getRequest(SentMessage $message): SendEmailRequest try { $email = MessageConverter::toEmail($message->getOriginalMessage()); } catch (\Exception $e) { - throw new RuntimeException(sprintf('Unable to send message with the "%s" transport: ', __CLASS__).$e->getMessage(), 0, $e); + throw new RuntimeException(\sprintf('Unable to send message with the "%s" transport: ', __CLASS__).$e->getMessage(), 0, $e); } if ($email->getAttachments()) { @@ -98,10 +98,19 @@ protected function getRequest(SentMessage $message): SendEmailRequest if ($header = $email->getHeaders()->get('X-SES-SOURCE-ARN')) { $request['FromEmailAddressIdentityArn'] = $header->getBodyAsString(); } + if ($header = $email->getHeaders()->get('X-SES-LIST-MANAGEMENT-OPTIONS')) { + if (preg_match('/^(contactListName=)*(?[^;]+)(;\s?topicName=(?.+))?$/ix', $header->getBodyAsString(), $listManagementOptions)) { + $request['ListManagementOptions'] = array_filter($listManagementOptions, fn ($e) => \in_array($e, ['ContactListName', 'TopicName']), \ARRAY_FILTER_USE_KEY); + } + } if ($email->getReturnPath()) { $request['FeedbackForwardingEmailAddress'] = $email->getReturnPath()->toString(); } + if ($customHeaders = $this->getCustomHeaders($email->getHeaders())) { + $request['Content']['Simple']['Headers'] = $customHeaders; + } + foreach ($email->getHeaders()->all() as $header) { if ($header instanceof MetadataHeader) { $request['EmailTags'][] = ['Name' => $header->getKey(), 'Value' => $header->getValue()]; @@ -118,6 +127,29 @@ private function getRecipients(Email $email, Envelope $envelope): array return array_filter($envelope->getRecipients(), fn (Address $address) => !\in_array($address, $emailRecipients, true)); } + private function getCustomHeaders(Headers $headers): array + { + $headersPrepared = []; + + $headersToBypass = ['from', 'to', 'cc', 'bcc', 'return-path', 'subject', 'reply-to', 'sender', 'content-type', 'x-ses-configuration-set', 'x-ses-source-arn', 'x-ses-list-management-options']; + foreach ($headers->all() as $name => $header) { + if (\in_array($name, $headersToBypass, true)) { + continue; + } + + if ($header instanceof MetadataHeader) { + continue; + } + + $headersPrepared[] = [ + 'Name' => $header->getName(), + 'Value' => $header->getBodyAsString(), + ]; + } + + return $headersPrepared; + } + protected function stringifyAddresses(array $addresses): array { return array_map(fn (Address $a) => $this->stringifyAddress($a), $addresses); @@ -127,7 +159,7 @@ protected function stringifyAddress(Address $a): string { // AWS does not support UTF-8 address if (preg_match('~[\x00-\x08\x10-\x19\x7F-\xFF\r\n]~', $name = $a->getName())) { - return sprintf('=?UTF-8?B?%s?= <%s>', + return \sprintf('=?UTF-8?B?%s?= <%s>', base64_encode($name), $a->getEncodedAddress() ); diff --git a/Transport/SesHttpAsyncAwsTransport.php b/Transport/SesHttpAsyncAwsTransport.php index 1ff41f1..e8c0b8e 100644 --- a/Transport/SesHttpAsyncAwsTransport.php +++ b/Transport/SesHttpAsyncAwsTransport.php @@ -28,13 +28,11 @@ */ class SesHttpAsyncAwsTransport extends AbstractTransport { - /** @var SesClient */ - protected $sesClient; - - public function __construct(SesClient $sesClient, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null) - { - $this->sesClient = $sesClient; - + public function __construct( + protected SesClient $sesClient, + ?EventDispatcherInterface $dispatcher = null, + ?LoggerInterface $logger = null, + ) { parent::__construct($dispatcher, $logger); } @@ -48,7 +46,7 @@ public function __toString(): string $host = $configuration->get('region'); } - return sprintf('ses+https://%s@%s', $configuration->get('accessKeyId'), $host); + return \sprintf('ses+https://%s@%s', $configuration->get('accessKeyId'), $host); } protected function doSend(SentMessage $message): void @@ -60,7 +58,7 @@ protected function doSend(SentMessage $message): void $message->setMessageId($result->getMessageId()); $message->appendDebug($response->getInfo('debug') ?? ''); } catch (HttpException $e) { - $exception = new HttpTransportException(sprintf('Unable to send an email: %s (code %s).', $e->getAwsMessage() ?: $e->getMessage(), $e->getAwsCode() ?: $e->getCode()), $e->getResponse(), $e->getCode(), $e); + $exception = new HttpTransportException(\sprintf('Unable to send an email: %s (code %s).', $e->getAwsMessage() ?: $e->getMessage(), $e->getAwsCode() ?: $e->getCode()), $e->getResponse(), $e->getCode(), $e); $exception->appendDebug($e->getResponse()->getInfo('debug') ?? ''); throw $exception; @@ -80,16 +78,20 @@ protected function getRequest(SentMessage $message): SendEmailRequest ], ]; - if (($message->getOriginalMessage() instanceof Message) - && $configurationSetHeader = $message->getOriginalMessage()->getHeaders()->get('X-SES-CONFIGURATION-SET')) { - $request['ConfigurationSetName'] = $configurationSetHeader->getBodyAsString(); - } - if (($message->getOriginalMessage() instanceof Message) - && $sourceArnHeader = $message->getOriginalMessage()->getHeaders()->get('X-SES-SOURCE-ARN')) { - $request['FromEmailAddressIdentityArn'] = $sourceArnHeader->getBodyAsString(); - } - if ($message->getOriginalMessage() instanceof Message) { - foreach ($message->getOriginalMessage()->getHeaders()->all() as $header) { + $originalMessage = $message->getOriginalMessage(); + if ($originalMessage instanceof Message) { + if ($configurationSetHeader = $message->getOriginalMessage()->getHeaders()->get('X-SES-CONFIGURATION-SET')) { + $request['ConfigurationSetName'] = $configurationSetHeader->getBodyAsString(); + } + if ($sourceArnHeader = $message->getOriginalMessage()->getHeaders()->get('X-SES-SOURCE-ARN')) { + $request['FromEmailAddressIdentityArn'] = $sourceArnHeader->getBodyAsString(); + } + if ($header = $message->getOriginalMessage()->getHeaders()->get('X-SES-LIST-MANAGEMENT-OPTIONS')) { + if (preg_match('/^(contactListName=)*(?[^;]+)(;\s?topicName=(?.+))?$/ix', $header->getBodyAsString(), $listManagementOptions)) { + $request['ListManagementOptions'] = array_filter($listManagementOptions, fn ($e) => \in_array($e, ['ContactListName', 'TopicName']), \ARRAY_FILTER_USE_KEY); + } + } + foreach ($originalMessage->getHeaders()->all() as $header) { if ($header instanceof MetadataHeader) { $request['EmailTags'][] = ['Name' => $header->getKey(), 'Value' => $header->getValue()]; } diff --git a/Transport/SesSmtpTransport.php b/Transport/SesSmtpTransport.php index da85b5c..1495259 100644 --- a/Transport/SesSmtpTransport.php +++ b/Transport/SesSmtpTransport.php @@ -31,7 +31,7 @@ class SesSmtpTransport extends EsmtpTransport public function __construct(string $username, #[\SensitiveParameter] string $password, ?string $region = null, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null, string $host = 'default') { if ('default' === $host) { - $host = sprintf('email-smtp.%s.amazonaws.com', $region ?: 'eu-west-1'); + $host = \sprintf('email-smtp.%s.amazonaws.com', $region ?: 'eu-west-1'); } parent::__construct($host, 465, true, $dispatcher, $logger); diff --git a/composer.json b/composer.json index 3dcdc62..3b8cd7c 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,12 @@ } ], "require": { - "php": ">=8.1", - "async-aws/ses": "^1.0", - "symfony/mailer": "^5.4.21|^6.2.7|^7.0" + "php": ">=8.2", + "async-aws/ses": "^1.8", + "symfony/mailer": "^7.2" }, "require-dev": { - "symfony/http-client": "^5.4|^6.0|^7.0" + "symfony/http-client": "^6.4|^7.0" }, "autoload": { "psr-4": { "Symfony\\Component\\Mailer\\Bridge\\Amazon\\": "" },