Sending Mail
Build an email with Mailer, render it with Twig, attach a file, send it through a queue, and assert in a test that it went.
MailerInterface::send(RawMessage $message): voidThe Mailer component builds an email and hands it to a transport. The email is a Symfony\Component\Mime\Email object with from(), to(), subject(), text() and html(). Where it goes is one DSN in the MAILER_DSN environment variable: smtp://user:pass@host:25, sendmail://default, or a provider such as postmark+api://KEY@default. Inject MailerInterface and call send().
Templated mail
TemplatedEmail, from the Twig bridge, adds htmlTemplate(), textTemplate() and context(). It is shorthand for rendering the template yourself and passing the string to html(): the same output, one step instead of two. Addresses work the same way, to('[email protected]') being the short form of to(new Address('[email protected]')).
Attachments
Attach a file with addPart(new DataPart(new File($path), 'invoice.pdf', 'application/pdf')). The name and content type are optional; without them the file's own are used.
Sending through a queue
Route Symfony\Component\Mailer\Messenger\SendEmailMessage to a transport and send() stops talking to the mail server during the request: it dispatches, and a worker does the sending. The whole email, context included, is serialised, so put ids and scalars in the context and never an entity.
In tests
Functional tests collect mail instead of sending it. assertEmailCount(), assertQueuedEmailCount() and getMailerMessage() let you assert what was built.
Example
// src/Mailer/InvoiceMailer.php
declare(strict_types=1);
namespace App\Mailer;
use App\Entity\Invoice;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\File;
final class InvoiceMailer
{
public function __construct(
private readonly MailerInterface $mailer,
// Bound in services.yaml, e.g. '%kernel.project_dir%/var/invoices'.
private readonly string $invoiceDirectory,
) {
}
public function send(Invoice $invoice): void
{
$email = (new TemplatedEmail())
->from(new Address('[email protected]', 'Example Billing'))
// A plain string here builds the same Address object.
->to($invoice->getCustomerEmail())
->subject(sprintf('Invoice %s', $invoice->getReference()))
->htmlTemplate('emails/invoice.html.twig')
->textTemplate('emails/invoice.txt.twig')
// Context is serialised with the message when mail is queued,
// so pass scalars rather than the Invoice entity itself.
->context([
'reference' => $invoice->getReference(),
'total_cents' => $invoice->getTotalInCents(),
])
->addPart(new DataPart(
new File($this->invoiceDirectory.'/'.$invoice->getReference().'.pdf'),
'invoice.pdf',
'application/pdf',
));
$this->mailer->send($email);
}
}When to use it
- Send a password reset link from a controller after a form submits.
- Email an invoice with the PDF attached.
- Keep signup fast by queueing the welcome email instead of waiting for SMTP.
- Assert in a functional test that registering queues exactly one email to the right address.
More examples
The longhand behind TemplatedEmail
Worth recognising, because plenty of code is written this way, and worth using when the body comes from somewhere other than a template. The Twig template is auto-escaped as usual: do not add |raw to a value a customer typed, or you have put their markup in your email.
// src/Mailer/PlainMailer.php
declare(strict_types=1);
namespace App\Mailer;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Twig\Environment;
final class PlainMailer
{
public function __construct(
private readonly MailerInterface $mailer,
private readonly Environment $twig,
) {
}
public function sendReminder(string $to, string $reference): void
{
// Render first, then set the body. TemplatedEmail's htmlTemplate()
// and context() do exactly this for you.
$html = $this->twig->render('emails/reminder.html.twig', [
'reference' => $reference,
]);
$this->mailer->send(
(new Email())
->from('[email protected]')
->to($to)
->subject('Payment reminder')
->html($html)
->text(sprintf('Invoice %s is still unpaid.', $reference))
);
}
}Queueing mail through Messenger
With this routing in place, MailerInterface::send() returns as soon as the message is queued and a worker on the async transport does the SMTP conversation. No calling code changes. Remember the worker needs MAILER_DSN too, and that a failed send now surfaces in the worker's log rather than in the request.
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
# Mailer wraps each email in this message when it is sent.
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'Asserting that mail was sent
These assertions come from MailerAssertionsTrait, which WebTestCase and KernelTestCase already use. They inspect the message object, so no mail server is involved and the test is as fast as any other. assertQueuedEmailCount() is the one that proves the send went through Messenger rather than straight out.
// tests/Controller/RegistrationControllerTest.php
declare(strict_types=1);
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class RegistrationControllerTest extends WebTestCase
{
public function testWelcomeMailIsQueued(): void
{
$client = static::createClient();
// Do not follow the redirect: that would reset the collected mail.
$client->request('POST', '/register', [
'registration_form' => [
'email' => '[email protected]',
'plainPassword' => 'correct horse battery staple',
],
]);
self::assertResponseRedirects('/login');
// One email was handed to the mailer, and queued rather than sent.
// assertEmailCount() counts only the emails that were really sent, so
// with the mail routed through Messenger it would see zero here.
self::assertQueuedEmailCount(1);
$email = self::getMailerMessage();
self::assertEmailAddressContains($email, 'To', '[email protected]');
self::assertEmailSubjectContains($email, 'Welcome');
self::assertEmailHtmlBodyContains($email, 'confirm your address');
}
}
Discussion