diff --git a/UPGRADE-8.2.md b/UPGRADE-8.2.md index e4e350a4d4ea5..887729e99e54d 100644 --- a/UPGRADE-8.2.md +++ b/UPGRADE-8.2.md @@ -28,6 +28,11 @@ SecurityBundle * Deprecate the `remember_me` option of the `form_login`, `json_login`, `login_link`, and `access_token` authenticators, as it has no effect +Mailer +------ + + * Deprecate the "templateid" and "params" email headers in the Brevo bridge, use a `RemoteTemplateEmail` instead + Serializer ---------- diff --git a/src/Symfony/Component/Mailer/Bridge/Brevo/CHANGELOG.md b/src/Symfony/Component/Mailer/Bridge/Brevo/CHANGELOG.md index 1db703587e1f7..92d21b90f1792 100644 --- a/src/Symfony/Component/Mailer/Bridge/Brevo/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/Bridge/Brevo/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +8.2 +--- + +* Deprecate the "templateid" and "params" email headers, use a `RemoteTemplateEmail` instead + 6.4 --- diff --git a/src/Symfony/Component/Mailer/Bridge/Brevo/Tests/Transport/BrevoApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Brevo/Tests/Transport/BrevoApiTransportTest.php index 5d5085aa93810..94bb2d8fb8a8d 100644 --- a/src/Symfony/Component/Mailer/Bridge/Brevo/Tests/Transport/BrevoApiTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Brevo/Tests/Transport/BrevoApiTransportTest.php @@ -12,7 +12,10 @@ namespace Symfony\Component\Mailer\Bridge\Brevo\Tests\Transport; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\JsonMockResponse; use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoApiTransport; @@ -21,6 +24,7 @@ use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Part\DataPart; @@ -28,6 +32,8 @@ class BrevoApiTransportTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + #[DataProvider('getTransportData')] public function testToString(BrevoApiTransport $transport, string $expected) { @@ -54,15 +60,12 @@ public static function getTransportData(): \Generator public function testCustomHeader() { - $params = ['param1' => 'foo', 'param2' => 'bar']; $json = json_encode(['"custom_header_1' => 'custom_value_1']); $email = new Email(); $email->getHeaders() ->add(new MetadataHeader('custom', $json)) ->add(new TagHeader('TagInHeaders')) - ->addTextHeader('templateId', 1) - ->addParameterizedHeader('params', 'params', $params) ->addTextHeader('foo', 'bar'); $envelope = new Envelope(new Address('alice@system.com', 'Alice'), [new Address('bob@system.com', 'Bob')]); @@ -75,15 +78,48 @@ public function testCustomHeader() $this->assertArrayHasKey('tags', $payload); $this->assertEquals('TagInHeaders', current($payload['tags'])); - $this->assertArrayHasKey('templateId', $payload); - $this->assertEquals(1, $payload['templateId']); - $this->assertArrayHasKey('params', $payload); - $this->assertEquals('foo', $payload['params']['param1']); - $this->assertEquals('bar', $payload['params']['param2']); $this->assertArrayHasKey('foo', $payload['headers']); $this->assertEquals('bar', $payload['headers']['foo']); } + public function testRemoteTemplate() + { + $email = (new RemoteTemplateEmail()) + ->template('42', ['param1' => 'foo', 'param2' => 'bar']); + $envelope = new Envelope(new Address('alice@system.com', 'Alice'), [new Address('bob@system.com', 'Bob')]); + + $transport = new BrevoApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(BrevoApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertSame(42, $payload['templateId']); + $this->assertSame(['param1' => 'foo', 'param2' => 'bar'], $payload['params']); + $this->assertArrayNotHasKey('subject', $payload); + $this->assertArrayNotHasKey('textContent', $payload); + $this->assertArrayNotHasKey('htmlContent', $payload); + } + + #[IgnoreDeprecations] + #[Group('legacy')] + public function testDeprecatedTemplateHeaders() + { + $this->expectUserDeprecationMessage(\sprintf('Since symfony/brevo-mailer 8.2: Using the "templateid" email header to select a Brevo template is deprecated, use a "%s" instead.', RemoteTemplateEmail::class)); + $this->expectUserDeprecationMessage(\sprintf('Since symfony/brevo-mailer 8.2: Using the "params" email header to define the variables of a Brevo template is deprecated, use a "%s" instead.', RemoteTemplateEmail::class)); + + $email = new Email(); + $email->getHeaders() + ->addTextHeader('templateId', 1) + ->addParameterizedHeader('params', 'params', ['param1' => 'foo', 'param2' => 'bar']); + $envelope = new Envelope(new Address('alice@system.com', 'Alice'), [new Address('bob@system.com', 'Bob')]); + + $transport = new BrevoApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(BrevoApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertSame(1, $payload['templateId']); + $this->assertSame(['param1' => 'foo', 'param2' => 'bar'], $payload['params']); + } + public function testSendThrowsForErrorResponse() { $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { diff --git a/src/Symfony/Component/Mailer/Bridge/Brevo/Transport/BrevoApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Brevo/Transport/BrevoApiTransport.php index e3b329f0121da..06713bc586f2a 100644 --- a/src/Symfony/Component/Mailer/Bridge/Brevo/Transport/BrevoApiTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Brevo/Transport/BrevoApiTransport.php @@ -17,8 +17,10 @@ use Symfony\Component\Mailer\Exception\HttpTransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractApiTransport; +use Symfony\Component\Mailer\Transport\RemoteTemplateTransportInterface; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Header\Headers; @@ -30,7 +32,7 @@ /** * @author Pierre TANGUY */ -final class BrevoApiTransport extends AbstractApiTransport +final class BrevoApiTransport extends AbstractApiTransport implements RemoteTemplateTransportInterface { public function __construct( #[\SensitiveParameter] private string $key, @@ -88,11 +90,21 @@ private function formatAddresses(array $addresses): array private function getPayload(Email $email, Envelope $envelope): array { + $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; + $payload = [ 'sender' => $this->formatAddress($envelope->getSender()), 'to' => $this->formatAddresses($this->getRecipients($email, $envelope)), - 'subject' => $email->getSubject(), ]; + if (null === $template || null !== $email->getSubject()) { + $payload['subject'] = $email->getSubject(); + } + if (null !== $template) { + $payload['templateId'] = (int) $template->getReference(); + if ($template->getVariables()) { + $payload['params'] = $template->getVariables(); + } + } if ($attachments = $this->prepareAttachments($email)) { $payload['attachment'] = $attachments; } @@ -154,11 +166,13 @@ private function prepareHeadersAndTags(Headers $headers): array continue; } if ('templateid' === $name) { + trigger_deprecation('symfony/brevo-mailer', '8.2', 'Using the "templateid" email header to select a Brevo template is deprecated, use a "%s" instead.', RemoteTemplateEmail::class); $headersAndTags[$header->getName()] = (int) $header->getValue(); continue; } if ('params' === $name) { + trigger_deprecation('symfony/brevo-mailer', '8.2', 'Using the "params" email header to define the variables of a Brevo template is deprecated, use a "%s" instead.', RemoteTemplateEmail::class); $headersAndTags[$header->getName()] = $header->getParameters(); continue; diff --git a/src/Symfony/Component/Mailer/Bridge/Brevo/composer.json b/src/Symfony/Component/Mailer/Bridge/Brevo/composer.json index a9ae38cd5c137..075ff27f9631f 100644 --- a/src/Symfony/Component/Mailer/Bridge/Brevo/composer.json +++ b/src/Symfony/Component/Mailer/Bridge/Brevo/composer.json @@ -17,7 +17,8 @@ ], "require": { "php": ">=8.4.1", - "symfony/mailer": "^7.4|^8.0" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/mailer": "^8.2" }, "require-dev": { "symfony/http-client": "^7.4|^8.0", diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendApiTransportTest.php index e3b8e7e8f51a0..9296be80e9e8e 100644 --- a/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendApiTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Tests/Transport/ResendApiTransportTest.php @@ -20,6 +20,7 @@ use Symfony\Component\Mailer\Exception\HttpTransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Part\DataPart; @@ -81,6 +82,37 @@ public function testCustomHeader() $this->assertEquals('bar', $payload['headers']['foo']); } + public function testRemoteTemplate() + { + $email = (new RemoteTemplateEmail()) + ->template('tpl_123', ['firstName' => 'Fabien']); + $envelope = new Envelope(new Address('alice@system.com', 'Alice'), [new Address('bob@system.com', 'Bob')]); + + $transport = new ResendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(ResendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertSame(['id' => 'tpl_123', 'variables' => ['firstName' => 'Fabien']], $payload['template']); + $this->assertArrayNotHasKey('subject', $payload); + $this->assertArrayNotHasKey('text', $payload); + $this->assertArrayNotHasKey('html', $payload); + } + + public function testRemoteTemplateWithSubject() + { + $email = (new RemoteTemplateEmail()) + ->subject('Hello!') + ->template('tpl_123'); + $envelope = new Envelope(new Address('alice@system.com', 'Alice'), [new Address('bob@system.com', 'Bob')]); + + $transport = new ResendApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(ResendApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertSame('Hello!', $payload['subject']); + $this->assertSame(['id' => 'tpl_123'], $payload['template']); + } + public function testSendThrowsForErrorResponse() { $client = new MockHttpClient(function (string $method, string $url, array $options): ResponseInterface { diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendApiTransport.php index 846ae9af36d3e..0cc4123d1fcf2 100644 --- a/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendApiTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Resend/Transport/ResendApiTransport.php @@ -17,8 +17,10 @@ use Symfony\Component\Mailer\Exception\HttpTransportException; use Symfony\Component\Mailer\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractApiTransport; +use Symfony\Component\Mailer\Transport\RemoteTemplateTransportInterface; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Header\Headers; @@ -30,7 +32,7 @@ /** * @author Mathieu Santostefano */ -final class ResendApiTransport extends AbstractApiTransport +final class ResendApiTransport extends AbstractApiTransport implements RemoteTemplateTransportInterface { public function __construct( #[\SensitiveParameter] private readonly string $apiKey, @@ -94,11 +96,21 @@ private function formatAddresses(array $addresses): array private function getPayload(Email $email, Envelope $envelope): array { + $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; + $payload = [ 'from' => $this->formatAddress($envelope->getSender()), 'to' => $this->formatAddresses($this->getRecipients($email, $envelope)), - 'subject' => $email->getSubject(), ]; + if (null === $template || null !== $email->getSubject()) { + $payload['subject'] = $email->getSubject(); + } + if (null !== $template) { + $payload['template'] = ['id' => $template->getReference()]; + if ($template->getVariables()) { + $payload['template']['variables'] = $template->getVariables(); + } + } if ($attachments = $this->prepareAttachments($email)) { $payload['attachments'] = $attachments; } diff --git a/src/Symfony/Component/Mailer/Bridge/Resend/composer.json b/src/Symfony/Component/Mailer/Bridge/Resend/composer.json index 92e53c1d6bd05..c1025cf1d1f82 100644 --- a/src/Symfony/Component/Mailer/Bridge/Resend/composer.json +++ b/src/Symfony/Component/Mailer/Bridge/Resend/composer.json @@ -17,7 +17,7 @@ ], "require": { "php": ">=8.4.1", - "symfony/mailer": "^7.4|^8.0" + "symfony/mailer": "^8.2" }, "require-dev": { "symfony/http-client": "^7.4|^8.0", diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Transport/SendgridApiTransportTest.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Transport/SendgridApiTransportTest.php index b43e1495a2487..6151eb1ab8dea 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Transport/SendgridApiTransportTest.php +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Tests/Transport/SendgridApiTransportTest.php @@ -18,6 +18,7 @@ use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Part\DataPart; @@ -177,6 +178,22 @@ public function testCustomHeader() $this->assertEquals('bar', $payload['headers']['foo']); } + public function testRemoteTemplate() + { + $email = (new RemoteTemplateEmail()) + ->template('d-12345', ['firstName' => 'Fabien']); + $envelope = new Envelope(new Address('alice@system.com'), [new Address('bob@system.com')]); + + $transport = new SendgridApiTransport('ACCESS_KEY'); + $method = new \ReflectionMethod(SendgridApiTransport::class, 'getPayload'); + $payload = $method->invoke($transport, $email, $envelope); + + $this->assertSame('d-12345', $payload['template_id']); + $this->assertSame(['firstName' => 'Fabien'], $payload['personalizations'][0]['dynamic_template_data']); + $this->assertArrayNotHasKey('content', $payload); + $this->assertArrayNotHasKey('subject', $payload['personalizations'][0]); + } + public function testReplyTo() { $from = 'from@example.com'; diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Transport/SendgridApiTransport.php b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Transport/SendgridApiTransport.php index dca18290565ca..592c02cf58551 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sendgrid/Transport/SendgridApiTransport.php +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/Transport/SendgridApiTransport.php @@ -19,8 +19,10 @@ use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mailer\Header\TagHeader; +use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractApiTransport; +use Symfony\Component\Mailer\Transport\RemoteTemplateTransportInterface; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Header\DateHeader; @@ -32,7 +34,7 @@ /** * @author Kevin Verschaeve */ -class SendgridApiTransport extends AbstractApiTransport +class SendgridApiTransport extends AbstractApiTransport implements RemoteTemplateTransportInterface { private const HOST = 'api.%region_dot%sendgrid.com'; @@ -91,20 +93,32 @@ private function getPayload(Email $email, Envelope $envelope): array return $stringified; }; + $template = $email instanceof RemoteTemplateEmail ? $email->getRemoteTemplate() : null; + $payload = [ 'personalizations' => [], 'from' => $addressStringifier($envelope->getSender()), - 'content' => $this->getContent($email), ]; + if (null !== $template) { + $payload['template_id'] = $template->getReference(); + } else { + $payload['content'] = $this->getContent($email); + } + if ($email->getAttachments()) { $payload['attachments'] = $this->getAttachments($email); } $personalization = [ 'to' => array_map($addressStringifier, $this->getRecipients($email, $envelope)), - 'subject' => $email->getSubject(), ]; + if (null === $template || null !== $email->getSubject()) { + $personalization['subject'] = $email->getSubject(); + } + if (null !== $template && $template->getVariables()) { + $personalization['dynamic_template_data'] = $template->getVariables(); + } if ($emails = array_map($addressStringifier, $email->getCc())) { $personalization['cc'] = $emails; } diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json b/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json index 0f7d2da1919f8..5ab53a9afd847 100644 --- a/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json +++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json @@ -17,7 +17,7 @@ ], "require": { "php": ">=8.4.1", - "symfony/mailer": "^7.4|^8.0" + "symfony/mailer": "^8.2" }, "require-dev": { "symfony/http-client": "^7.4|^8.0", diff --git a/src/Symfony/Component/Mailer/CHANGELOG.md b/src/Symfony/Component/Mailer/CHANGELOG.md index 3093fac6048be..4de082ae968b1 100644 --- a/src/Symfony/Component/Mailer/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 8.2 --- + * Add `RemoteTemplateEmail`, `RemoteTemplate` and `RemoteTemplateTransportInterface` to send emails rendered by the mail provider from a template hosted on its side * Reorder EsmtpTransport authenticators to prefer PLAIN over obsolete LOGIN 8.0 diff --git a/src/Symfony/Component/Mailer/RemoteTemplate.php b/src/Symfony/Component/Mailer/RemoteTemplate.php new file mode 100644 index 0000000000000..a99576bae7176 --- /dev/null +++ b/src/Symfony/Component/Mailer/RemoteTemplate.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer; + +/** + * The reference and variables of a template hosted by a mail provider. + * + * @author Florent Blaison + */ +final class RemoteTemplate +{ + /** + * @param string $reference The provider-side reference of the template (an id, uuid, name or alias, depending on the provider) + * @param array $variables The variables used by the provider to render the template + */ + public function __construct( + private string $reference, + private array $variables = [], + ) { + } + + public function getReference(): string + { + return $this->reference; + } + + /** + * @return array + */ + public function getVariables(): array + { + return $this->variables; + } +} diff --git a/src/Symfony/Component/Mailer/RemoteTemplateEmail.php b/src/Symfony/Component/Mailer/RemoteTemplateEmail.php new file mode 100644 index 0000000000000..e6f21bc2c2839 --- /dev/null +++ b/src/Symfony/Component/Mailer/RemoteTemplateEmail.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer; + +use Symfony\Component\Mailer\Transport\RemoteTemplateTransportInterface; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Exception\LogicException; +use Symfony\Component\Mime\Part\AbstractPart; +use Symfony\Component\Mime\Part\TextPart; + +/** + * An email rendered by the mail provider from a template hosted on its side. + * + * Such emails can only be sent through transports implementing + * {@see RemoteTemplateTransportInterface}; other transports refuse them. + * + * @author Florent Blaison + */ +class RemoteTemplateEmail extends Email +{ + private ?RemoteTemplate $template = null; + + /** + * @param string|null $template The provider-side reference of the template (an id, uuid, name or alias, depending on the provider) + * @param array $variables The variables used by the provider to render the template + * + * @return $this + */ + public function template(?string $template, array $variables = []): static + { + $this->template = null === $template ? null : new RemoteTemplate($template, $variables); + + return $this; + } + + public function getRemoteTemplate(): ?RemoteTemplate + { + return $this->template; + } + + public function getBody(): AbstractPart + { + if (null === $this->template) { + return parent::getBody(); + } + + return new TextPart(\sprintf('This email is rendered by the mail provider from its "%s" template.', $this->template->getReference())); + } + + protected function ensureBodyValid(): void + { + if (null === $this->template) { + parent::ensureBodyValid(); + + return; + } + + if (null !== $this->getTextBody() || null !== $this->getHtmlBody()) { + throw new LogicException('An email using a remote template cannot have a text or an HTML part; its body is rendered by the mail provider.'); + } + } + + /** + * @internal + */ + public function __serialize(): array + { + return [$this->template, parent::__serialize()]; + } + + /** + * @internal + */ + public function __unserialize(array $data): void + { + if (null !== ($data[0] ?? null) && !$data[0] instanceof RemoteTemplate) { + throw new \BadMethodCallException('Cannot unserialize '.self::class); + } + + [$this->template, $parentData] = $data; + + parent::__unserialize($parentData); + } +} diff --git a/src/Symfony/Component/Mailer/Tests/RemoteTemplateEmailTest.php b/src/Symfony/Component/Mailer/Tests/RemoteTemplateEmailTest.php new file mode 100644 index 0000000000000..17fe85e843b35 --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/RemoteTemplateEmailTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\RemoteTemplateEmail; +use Symfony\Component\Mime\Exception\LogicException; +use Symfony\Component\Mime\Part\TextPart; + +class RemoteTemplateEmailTest extends TestCase +{ + public function testTemplate() + { + $email = new RemoteTemplateEmail(); + $this->assertNull($email->getRemoteTemplate()); + + $email->template('welcome', ['firstName' => 'Fabien']); + $this->assertSame('welcome', $email->getRemoteTemplate()->getReference()); + $this->assertSame(['firstName' => 'Fabien'], $email->getRemoteTemplate()->getVariables()); + + $email->template(null); + $this->assertNull($email->getRemoteTemplate()); + } + + public function testEnsureValidityWithTemplateAndNoBody() + { + $email = new RemoteTemplateEmail(); + $email->from('fabien@symfony.com'); + $email->to('you@example.com'); + $email->template('welcome'); + + $email->ensureValidity(); + + $this->addToAssertionCount(1); + } + + public function testEnsureValidityWithTemplateAndTextBody() + { + $email = new RemoteTemplateEmail(); + $email->from('fabien@symfony.com'); + $email->to('you@example.com'); + $email->template('welcome'); + $email->text('some text'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('An email using a remote template cannot have a text or an HTML part; its body is rendered by the mail provider.'); + + $email->ensureValidity(); + } + + public function testEnsureValidityWithTemplateAndHtmlBody() + { + $email = new RemoteTemplateEmail(); + $email->from('fabien@symfony.com'); + $email->to('you@example.com'); + $email->template('welcome'); + $email->html('some html'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('An email using a remote template cannot have a text or an HTML part; its body is rendered by the mail provider.'); + + $email->ensureValidity(); + } + + public function testEnsureValidityWithoutTemplateStillRequiresABody() + { + $email = new RemoteTemplateEmail(); + $email->from('fabien@symfony.com'); + $email->to('you@example.com'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('A message must have a text or an HTML part or attachments.'); + + $email->ensureValidity(); + } + + public function testGetBodyReturnsAPlaceholderWhenATemplateIsSet() + { + $email = new RemoteTemplateEmail(); + $email->template('welcome'); + + $body = $email->getBody(); + + $this->assertInstanceOf(TextPart::class, $body); + $this->assertStringContainsString('welcome', $body->getBody()); + } + + public function testSerialization() + { + $email = new RemoteTemplateEmail(); + $email->from('fabien@symfony.com'); + $email->to('you@example.com'); + $email->template('welcome', ['firstName' => 'Fabien']); + + $email = unserialize(serialize($email)); + + $this->assertSame('welcome', $email->getRemoteTemplate()->getReference()); + $this->assertSame(['firstName' => 'Fabien'], $email->getRemoteTemplate()->getVariables()); + $this->assertSame('fabien@symfony.com', $email->getFrom()[0]->getAddress()); + } +} diff --git a/src/Symfony/Component/Mailer/Tests/Transport/AbstractTransportTest.php b/src/Symfony/Component/Mailer/Tests/Transport/AbstractTransportTest.php index 18013e2bda285..d8d4c32c43ddf 100644 --- a/src/Symfony/Component/Mailer/Tests/Transport/AbstractTransportTest.php +++ b/src/Symfony/Component/Mailer/Tests/Transport/AbstractTransportTest.php @@ -21,6 +21,7 @@ use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mailer\EventListener\MessageListener; use Symfony\Component\Mailer\Exception\LogicException; +use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractTransport; use Symfony\Component\Mailer\Transport\NullTransport; @@ -82,6 +83,42 @@ public function testRenderedTemplatedEmail() $this->assertMatchesRegularExpression('/Some message/', $sentMessage->getMessage()->toString()); } + public function testSendingRemoteTemplateEmailWithUnsupportedTransport() + { + $transport = new class(new EventDispatcher()) extends AbstractTransport { + protected function doSend(SentMessage $message): void + { + } + + public function __toString(): string + { + return 'fake://'; + } + }; + + $email = (new RemoteTemplateEmail()) + ->from('fabien@example.com') + ->to('helene@example.com') + ->template('welcome', ['firstName' => 'Fabien']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('does not support sending emails rendered from a remote template'); + + $transport->send($email); + } + + public function testSendingRemoteTemplateEmailWithSupportedTransport() + { + $transport = new NullTransport(new EventDispatcher()); + + $email = (new RemoteTemplateEmail()) + ->from('fabien@example.com') + ->to('helene@example.com') + ->template('welcome', ['firstName' => 'Fabien']); + + $this->assertNotNull($transport->send($email)); + } + public function testRejectMessage() { $dispatcher = new EventDispatcher(); diff --git a/src/Symfony/Component/Mailer/Tests/UnserializeTrampolineTest.php b/src/Symfony/Component/Mailer/Tests/UnserializeTrampolineTest.php new file mode 100644 index 0000000000000..8f2c38a1b008d --- /dev/null +++ b/src/Symfony/Component/Mailer/Tests/UnserializeTrampolineTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\RemoteTemplateEmail; + +class UnserializeTrampolineGadget +{ + public static bool $fired = false; + + public function __toString(): string + { + self::$fired = true; + + return ''; + } +} + +/** + * A serialized object whose __unserialize() restores a typed string property + * must reject \Stringable values, otherwise a forged payload turns that + * property assignment into a __toString trampoline firing a gadget. + */ +class UnserializeTrampolineTest extends TestCase +{ + #[DataProvider('provideStringSlots')] + public function testUnserializeRejectsStringableTrampoline(string $class, int $size, int $slot) + { + $data = array_fill(0, $size, null); + $data[$slot] = new UnserializeTrampolineGadget(); + + $payload = \sprintf('O:%d:"%s":%d:{', \strlen($class), $class, \count($data)); + foreach ($data as $key => $value) { + $payload .= serialize($key).serialize($value); + } + $payload .= '}'; + + UnserializeTrampolineGadget::$fired = false; + + try { + unserialize($payload); + $this->fail('Expected BadMethodCallException.'); + } catch (\BadMethodCallException $e) { + } + + $this->assertFalse(UnserializeTrampolineGadget::$fired, '__toString gadget must not fire during unserialize'); + } + + public static function provideStringSlots(): iterable + { + yield 'RemoteTemplateEmail::$template' => [RemoteTemplateEmail::class, 2, 0]; + } +} diff --git a/src/Symfony/Component/Mailer/Transport/AbstractTransport.php b/src/Symfony/Component/Mailer/Transport/AbstractTransport.php index 9a47ddd6ff0bc..87d0a67442131 100644 --- a/src/Symfony/Component/Mailer/Transport/AbstractTransport.php +++ b/src/Symfony/Component/Mailer/Transport/AbstractTransport.php @@ -20,6 +20,7 @@ use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mailer\Event\SentMessageEvent; use Symfony\Component\Mailer\Exception\LogicException; +use Symfony\Component\Mailer\RemoteTemplateEmail; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\BodyRendererInterface; @@ -65,6 +66,7 @@ public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMess try { if (!$this->dispatcher) { + $this->ensureRemoteTemplateSupport($message); $sentMessage = new SentMessage($message, $envelope); $this->doSend($sentMessage); @@ -84,6 +86,8 @@ public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMess throw new LogicException(\sprintf('You must configure a "%s" when a "%s" instance has a text or HTML template set.', BodyRendererInterface::class, get_debug_type($message))); } + $this->ensureRemoteTemplateSupport($message); + $sentMessage = new SentMessage($message, $envelope); try { @@ -120,6 +124,13 @@ protected function getLogger(): LoggerInterface return $this->logger; } + private function ensureRemoteTemplateSupport(RawMessage $message): void + { + if ($message instanceof RemoteTemplateEmail && null !== $message->getRemoteTemplate() && !$this instanceof RemoteTemplateTransportInterface) { + throw new LogicException(\sprintf('The "%s" transport does not support sending emails rendered from a remote template.', get_debug_type($this))); + } + } + private function checkThrottling(): void { if (0 == $this->rate) { diff --git a/src/Symfony/Component/Mailer/Transport/NullTransport.php b/src/Symfony/Component/Mailer/Transport/NullTransport.php index 92fb82a478cbd..065578d99520c 100644 --- a/src/Symfony/Component/Mailer/Transport/NullTransport.php +++ b/src/Symfony/Component/Mailer/Transport/NullTransport.php @@ -18,7 +18,7 @@ * * @author Fabien Potencier */ -final class NullTransport extends AbstractTransport +final class NullTransport extends AbstractTransport implements RemoteTemplateTransportInterface { protected function doSend(SentMessage $message): void { diff --git a/src/Symfony/Component/Mailer/Transport/RemoteTemplateTransportInterface.php b/src/Symfony/Component/Mailer/Transport/RemoteTemplateTransportInterface.php new file mode 100644 index 0000000000000..7efa98199410b --- /dev/null +++ b/src/Symfony/Component/Mailer/Transport/RemoteTemplateTransportInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mailer\Transport; + +use Symfony\Component\Mailer\RemoteTemplateEmail; + +/** + * Implemented by transports that can send a {@see RemoteTemplateEmail} by + * delegating the rendering of the email to a template hosted by the mail provider. + * + * @author Florent Blaison + */ +interface RemoteTemplateTransportInterface +{ +} diff --git a/src/Symfony/Component/Mailer/composer.json b/src/Symfony/Component/Mailer/composer.json index 1528d22377d60..89521399721fd 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": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", + "symfony/mime": "^8.2", "symfony/service-contracts": "^2.5|^3" }, "require-dev": { diff --git a/src/Symfony/Component/Mime/CHANGELOG.md b/src/Symfony/Component/Mime/CHANGELOG.md index afabeaa23305d..09169eabca7ad 100644 --- a/src/Symfony/Component/Mime/CHANGELOG.md +++ b/src/Symfony/Component/Mime/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +8.2 +--- + + * Make `Email::ensureBodyValid()` protected to allow subclasses adjusting the body validation + 8.0 --- diff --git a/src/Symfony/Component/Mime/Email.php b/src/Symfony/Component/Mime/Email.php index b6b8d39e958ff..d9f5bada7ffaa 100644 --- a/src/Symfony/Component/Mime/Email.php +++ b/src/Symfony/Component/Mime/Email.php @@ -399,7 +399,7 @@ public function ensureValidity(): void parent::ensureValidity(); } - private function ensureBodyValid(): void + protected function ensureBodyValid(): void { if (null === $this->text && null === $this->html && !$this->attachments && null === parent::getBody()) { throw new LogicException('A message must have a text or an HTML part or attachments.');