Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings
Open
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
[Mailer] Add RemoteTemplateEmail to send emails rendered from provide…
…r-hosted templates

Add a first-class abstraction for emails rendered by the mail provider
from a template hosted on its side:

* `RemoteTemplateEmail` carries a template reference and its variables,
  and allows sending without a local text/HTML body;
* transports opt in by implementing `RemoteTemplateTransportInterface`
  and mapping the template to their native API payload; other transports
  fail with an explicit exception instead of silently dropping the
  template;
* implement the interface in the Brevo, Resend and Sendgrid bridges;
* deprecate the "templateid" and "params" magic headers of the Brevo
  bridge in favor of the new abstraction.
  • Loading branch information
Orkin committed Jul 23, 2026
commit 12e15e5d93dd646b726398081fdb29a1d0f82d11
5 changes: 5 additions & 0 deletions 5 UPGRADE-8.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------

Expand Down
5 changes: 5 additions & 0 deletions 5 src/Symfony/Component/Mailer/Bridge/Brevo/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

8.2
---

* Deprecate the "templateid" and "params" email headers, use a `RemoteTemplateEmail` instead

6.4
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,13 +24,16 @@
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;
use Symfony\Contracts\HttpClient\ResponseInterface;

class BrevoApiTransportTest extends TestCase
{
use ExpectUserDeprecationMessageTrait;

#[DataProvider('getTransportData')]
public function testToString(BrevoApiTransport $transport, string $expected)
{
Expand All @@ -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')]);

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion 3 src/Symfony/Component/Mailer/Bridge/Brevo/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,7 +32,7 @@
/**
* @author Mathieu Santostefano <msantostefano@proton.me>
*/
final class ResendApiTransport extends AbstractApiTransport
final class ResendApiTransport extends AbstractApiTransport implements RemoteTemplateTransportInterface
{
public function __construct(
#[\SensitiveParameter] private readonly string $apiKey,
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion 2 src/Symfony/Component/Mailer/Bridge/Resend/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion 2 src/Symfony/Component/Mailer/Bridge/Sendgrid/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions 1 src/Symfony/Component/Mailer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.