Symfony Mailer — envoi d'emails de niveau production
TL;DR —
symfony/mailerest l'abstraction officielle qui découple votre code du transport (SMTP, SES, Postmark, Mailgun, Resend…). Vous écrivez unMailerInterface, et un DSN décide où ça part. Combiné àsymfony/messenger, l'envoi devient asynchrone, retryable et observable. Pour aller en prod : transports tiers (pas de SMTP "maison"), templates Twig avec inline CSS, bounces gérés via webhooks, DKIM via le transport, et tests assertant l'email sans le poster réellement.
🧠 Mental model — ASCII + analogie
Pensez au Mailer comme à La Poste moderne. Vous (l'application) rédigez une lettre (Email), vous la déposez dans la boîte (MailerInterface::send()), et un transporteur s'occupe du reste. Le DSN est votre contrat avec le transporteur : ses+https://... = "DHL aérien", smtp://... = "Le facteur du coin". Et avec Messenger devant, vous mettez la lettre dans une file d'attente — elle partira quand un worker sera dispo, et si le destinataire est absent, on retentera.
┌─────────────────────────────────────────────────────────────────┐
│ Votre application │
│ │
│ $mailer->send($email); │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌────────────────────────┐
│ MailerInterface │ ← abstraction
│ (Symfony\Mailer) │
└────────┬───────────────┘
│
┌───────────────┴────────────────┐
│ │
▼ (sync) ▼ (async via Messenger)
┌──────────────┐ ┌─────────────────────┐
│ Transport │ │ SendEmailMessage │
│ (DSN) │ │ → file (Redis/SQS) │
└──────┬───────┘ └──────────┬──────────┘
│ │
▼ ▼ worker
┌───────┐ ┌───────┐ ┌──────────┐ ┌──────────┐
│ SES │ │Postmark│ │ Mailgun │ │ Resend │
└───┬───┘ └───┬────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
╔═══════════════════════════════════════════╗
║ Boîte de réception ║
╚═══════════════════════════════════════════╝L'Email est un objet MIME : From, To, Subject, text, html, pièces jointes, en-têtes personnalisés, et même embeds (logos inline). Vous ne manipulez jamais directement le protocole SMTP — c'est la beauté de l'abstraction.
🛠️ Code minimal (PHP 8.2+)
Installation et configuration
composer require symfony/mailer
# transports tiers (un seul à la fois en général)
composer require symfony/amazon-mailer
composer require symfony/postmark-mailer
composer require symfony/mailgun-mailer
composer require resend/resend-php # transport Resend communautaire# .env / .env.local
MAILER_DSN=ses+https://ACCESS_KEY:SECRET_KEY@default?region=eu-west-3
# Alternatives :
# MAILER_DSN=postmark+api://POSTMARK_TOKEN@default
# MAILER_DSN=mailgun+https://API_KEY:DOMAIN@default?region=eu
# MAILER_DSN=smtp://user:[email protected]:587?encryption=tls
# MAILER_DSN=null://null # désactive en dev/CI# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
envelope:
sender: '[email protected]'
recipients: ['%env(MAILER_CATCH_ALL)%'] # uniquement en staging
headers:
X-Mailer-App: 'learning-hub'Service métier qui envoie un email transactionnel
<?php
declare(strict_types=1);
namespace App\Email;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
final readonly class WelcomeEmailSender
{
public function __construct(
private MailerInterface $mailer,
private string $fromAddress,
private string $fromName,
) {}
public function send(string $to, string $userName, string $confirmUrl): void
{
$email = (new TemplatedEmail())
->from(new Address($this->fromAddress, $this->fromName))
->to(new Address($to, $userName))
->subject('Bienvenue sur Learning Hub')
->htmlTemplate('emails/welcome.html.twig')
->textTemplate('emails/welcome.txt.twig')
->context([
'userName' => $userName,
'confirmUrl' => $confirmUrl,
])
// tag/metadata propagé vers le provider (Postmark/SES/Mailgun)
->getHeaders()
->addTextHeader('X-PM-Tag', 'welcome')
->getBody() // chaînage cassé ici volontairement — voir note ci-dessous
;
$this->mailer->send($email);
}
}Note importante sur le chaînage :
TemplatedEmail::getHeaders()retourne unHeaders(pas le mail). En pratique, on écrit plutôt :
$email = (new TemplatedEmail())
->from(new Address($this->fromAddress, $this->fromName))
->to(new Address($to, $userName))
->subject('Bienvenue sur Learning Hub')
->htmlTemplate('emails/welcome.html.twig')
->textTemplate('emails/welcome.txt.twig')
->context(['userName' => $userName, 'confirmUrl' => $confirmUrl])
;
$email->getHeaders()->addTextHeader('X-PM-Tag', 'welcome');
$this->mailer->send($email);Template Twig — HTML + texte
{# templates/emails/welcome.html.twig #}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Bienvenue</title>
<style>
/* Dark mode aware — supporté par Apple Mail, iOS, certains Outlook récents */
@media (prefers-color-scheme: dark) {
.container { background: #1a1a1a !important; color: #f5f5f5 !important; }
.btn { background: #4f8df7 !important; }
}
</style>
</head>
<body style="margin:0;padding:0;font-family:-apple-system,Segoe UI,Roboto,sans-serif;">
<table class="container" width="100%" style="background:#ffffff;color:#111;">
<tr>
<td align="center" style="padding:32px 16px;">
<h1 style="font-size:24px;margin:0 0 16px;">Bienvenue {{ userName }}</h1>
<p style="font-size:16px;line-height:1.5;max-width:480px;">
Confirme ton adresse pour activer ton compte.
</p>
<a href="{{ confirmUrl }}" class="btn"
style="display:inline-block;padding:12px 24px;background:#2a6df4;
color:#fff;border-radius:6px;text-decoration:none;font-weight:600;">
Confirmer mon email
</a>
</td>
</tr>
</table>
</body>
</html>{# templates/emails/welcome.txt.twig #}
Bienvenue {{ userName }}
Confirme ton adresse pour activer ton compte :
{{ confirmUrl }}
— L'équipe Learning HubInline CSS automatique
Beaucoup de clients (Gmail, Outlook desktop) ignorent les <style>. On inline avec symfony/css-inliner-extra-twig :
composer require twig/cssinliner-extra twig/inky-extra{# Le filtre `inline_css` est désormais disponible #}
{% apply inline_css(source('emails/styles.css')) %}
<!DOCTYPE html>
<html>...</html>
{% endapply %}Pièce jointe et image embarquée
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Votre facture')
->html('<p>Voici votre facture <img src="cid:logo"></p>')
->attachFromPath('/var/invoices/F-2026-0042.pdf', 'facture.pdf', 'application/pdf')
->embedFromPath('/assets/logo.png', 'logo', 'image/png')
;🎯 Patterns courants — 6
1. Envoi asynchrone via Messenger (par défaut en prod)
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: asyncAvec cette config, chaque $mailer->send() part dans la file async. Le contrôleur HTTP répond en <50 ms, et un worker (php bin/console messenger:consume async) traite l'envoi.
2. Retry avec backoff exponentiel
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 5
delay: 1000 # 1s
multiplier: 3 # 1s, 3s, 9s, 27s, 81s
max_delay: 300000 # cap à 5 minSi SES tombe en 503, le worker met le message en failed après 5 tentatives. Récupérez-les via messenger:failed:retry ou un dashboard.
3. Digest emails planifiés (Scheduler)
symfony/scheduler (stable depuis 6.4) permet de planifier des envois récurrents sans cron externe :
<?php
namespace App\Scheduler;
use App\Message\SendDailyDigest;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('default')]
final class DigestSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())
->add(
RecurringMessage::cron('0 8 * * *', new SendDailyDigest()) // 8h chaque jour
)
->stateful(); // évite doublons en cas de redémarrage
}
}Le handler de SendDailyDigest itère les utilisateurs et envoie un TemplatedEmail à chacun (qui est lui-même asyncifié par Messenger — deux files distinctes).
4. DKIM via transport (signature des emails)
Les providers (SES, Mailgun, Postmark, Resend) signent en DKIM côté serveur dès lors que le domaine est vérifié dans leur console. Vous n'avez rien à faire dans Symfony. Si vous tenez vraiment à signer en local (SMTP brut) :
use Symfony\Component\Mime\Crypto\DkimSigner;
$signer = new DkimSigner('file:///var/keys/dkim.private.key', 'example.com', 'default');
$signed = $signer->sign($email);
$mailer->send($signed);Mais en prod, déléguer au provider est toujours préférable (rotation des clés, alignement DMARC, etc.).
5. Gestion des bounces — webhooks providers
Aucun provider ne renvoie le bounce via SMTP en temps réel. Vous configurez un webhook côté provider, qui pointe vers une route Symfony :
#[Route('/webhooks/mailgun', name: 'mailgun_webhook', methods: ['POST'])]
public function mailgunWebhook(Request $request, BounceProcessor $processor): JsonResponse
{
// 1. Vérifier la signature HMAC (Mailgun fournit timestamp + token + signature)
if (!$processor->verifyMailgunSignature($request)) {
throw new AccessDeniedHttpException('Invalid signature');
}
$payload = $request->toArray();
$processor->handle($payload); // marque user.email_status = bounced, hard/soft
return new JsonResponse(['ok' => true]);
}Règle d'or : ne jamais ré-envoyer à une adresse
hard_bounced. Les providers vous banniront (et votre réputation s'effondre).
6. Traçabilité et observabilité — corrélation message → user
Pour debugger "pourquoi Alice n'a-t-elle pas reçu son email de confirmation ?", il faut joindre les bouts : log applicatif → message Messenger → API du provider → boîte d'Alice. Pattern recommandé :
<?php
namespace App\Email;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Event\SentMessageEvent;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Uid\Ulid;
final readonly class TrackedEmailSender
{
public function __construct(
private MailerInterface $mailer,
private LoggerInterface $logger,
) {}
public function send(TemplatedEmail $email, string $userId, string $intent): void
{
$correlationId = (string) new Ulid();
$email->getHeaders()
->addTextHeader('X-Correlation-Id', $correlationId)
->addTextHeader('X-User-Id', $userId)
->addTextHeader('X-Intent', $intent)
;
$this->logger->info('email.send', [
'correlation_id' => $correlationId,
'user_id' => $userId,
'intent' => $intent,
'to' => $email->getTo()[0]->getAddress(),
'subject' => $email->getSubject(),
]);
$this->mailer->send($email);
}
}Et un EventSubscriber qui écoute MessageEvent / SentMessageEvent pour logger après envoi (l'API SES retourne un MessageId qu'on relie au correlation_id) :
<?php
namespace App\EventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\Event\SentMessageEvent;
final readonly class EmailTracingSubscriber implements EventSubscriberInterface
{
public function __construct(private LoggerInterface $logger) {}
public function onSentMessage(SentMessageEvent $event): void
{
$sent = $event->getMessage();
$providerMessageId = $event->getMessage()->getMessageId(); // ID renvoyé par le provider
$this->logger->info('email.sent', [
'provider_message_id' => $providerMessageId,
// l'en-tête X-Correlation-Id reste dans l'original
]);
}
public static function getSubscribedEvents(): array
{
return [SentMessageEvent::class => 'onSentMessage'];
}
}À partir de là, dans Datadog/Loki/Kibana, vous filtrez par correlation_id et vous voyez tout : enqueue → handler → envoi → réception webhook bounce/open/click.
Subtilité
getMessageId()— surSentMessage,getMessageId()retourne l'identifiant renvoyé par le transport (leMessageIdSES, leX-Message-IdPostmark). Tous les transports ne le remplissent pas : SMTP brut ne renvoie rien d'exploitable. Côté SES/Postmark/Mailgun, c'est cet ID que vous corrélerez avec les events webhook (delivered,bounced,opened). Stockez le couple(correlation_id, provider_message_id)dès leSentMessageEvent: c'est la clé de jointure de toute votre observabilité email.
🧭 Comment un staff engineer raisonne — l'arbre de décision
Avant d'écrire la moindre ligne, un senior tranche trois questions dans cet ordre :
- Transactionnel ou marketing ? Transactionnel (1 destinataire, déclenché par une action : reset password, reçu, OTP) → Mailer + Postmark/SES, délivrabilité maximale, jamais de batch. Marketing (N destinataires, segmentation, désinscription, A/B) → ESP dédié (Brevo, Customer.io, Loops), Mailer ne fait que déclencher un
template_id. Mélanger les deux sur le même domaine d'envoi tue la réputation du transactionnel : un taux de plainte marketing élevé fait spammer vos OTP. → sous-domaines séparés (mail.example.comvsnews.example.com), IP/pools séparés chez le provider. - Sync ou async ? En prod, toujours async via Messenger. Le seul cas sync légitime : un script CLI one-shot où la latence n'a aucune importance et où l'on veut l'erreur immédiate.
- Quel niveau de garantie de livraison ? Un OTP qui n'arrive pas en 10 s est inutile (latence > fiabilité : peu de retries, alerte rapide). Un reçu comptable doit toujours finir par partir (fiabilité > latence : retries longs, DLQ, archivage). Ces deux profils n'ont pas la même
retry_strategyni la même queue.
Tradeoffs transports
| Transport | Latence p50 | Délivrabilité | Coût (1M) | Quand le choisir |
|---|---|---|---|---|
Postmark (postmark+api) | ~200 ms | Excellente (transac dédié) | ~$$$ | Transactionnel pur, faible volume, on paie pour la délivrabilité |
Amazon SES (ses+https) | ~300 ms | Bonne (à chauffer) | ~$ (le moins cher) | Gros volume, infra déjà AWS, on accepte de gérer warm-up + SNS bounces |
Mailgun (mailgun+https) | ~250 ms | Bonne | ~$$ | Besoin de routing entrant + sortant, EU region dispo |
Brevo (brevo+api) | ~300 ms | Correcte | ~$$ | Mix transac + marketing, listes/segments natifs |
SMTP brut (smtp://) | variable | Mauvaise sans SPF/DKIM/DMARC | — | Jamais en prod. Dev local (Mailpit) ou relais interne uniquement |
Règle staff :
api/https>smtppour les transports tiers. L'API renvoie unMessageIdexploitable, gère mieux les erreurs structurées (429, codes de bounce), et n'ouvre pas de connexion SMTP longue par message. Le transportsmtp+d'un provider marche, mais l'+api/+httpsest presque toujours supérieur en observabilité.
Le modèle mental de la délivrabilité (ce qui distingue un senior)
Envoyer un email techniquement est trivial. Le faire arriver en inbox est le vrai métier. Trois piliers d'authentification, alignés sur le From: :
- SPF : le domaine du
Return-Path(=envelope.sender) autorise-t-il l'IP émettrice ? Échoue si vous mettez unFrom:d'un domaine dont le provider n'est pas autorisé. - DKIM : signature cryptographique des en-têtes + corps. Délégué au provider (domaine vérifié dans sa console) — vous ne touchez à rien.
- DMARC : politique qui dit "que faire si SPF ou DKIM échoue et que l'alignement avec le
From:casse" (p=none→quarantine→reject). C'est l'alignement entre le domaine duFrom:et celui validé par SPF/DKIM qui compte, pas juste leur succès isolé.
Conséquence concrète sur votre code : si From: [email protected] mais que vous envoyez via SES sur votre domaine, DMARC casse (Gmail n'autorise pas SES à signer pour gmail.com). D'où la règle : le From: est toujours sur un domaine que vous contrôlez et avez vérifié chez le provider. Pour répondre à l'utilisateur, on utilise replyTo().
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
| Version | Apport principal |
|---|---|
| 5.4 (LTS) | API stable de Mailer + Mime. Tous les transports tiers disponibles via symfony/<provider>-mailer. Pas de Scheduler — utilisez symfony/lock + cron. |
| 6.4 (LTS) | Stabilisation de symfony/scheduler. Composant Notifier mature. Support officiel Resend (symfony/resend-mailer). Améliorations DSN (options verify_peer, local_domain). |
| 7.0+ | Suppression des deprecations 6.x. TemplatedEmail peut désormais inclure des attributs PHP 8 pour le routing (préférences nominatives). |
| 7.1+ | SmimeSigner et DkimSigner exposent davantage d'options (algos signature). |
| 7.2+ | Améliorations sur MockTransport et assertions PHPUnit (assertEmailHeaderSame plus tolérant). |
Côté libs :
twig/cssinliner-extra≥ 3.x requiert Twig 3.twig/inky-extra(templates type Foundation Emails) pratique pour HTML emails compliqués.
⚠️ Pitfalls — 8
- Oublier d'asynchroniser. En sync, une panne SES = 30 s de timeout HTTP côté user. Toujours router
SendEmailMessagevia Messenger en prod. - Logs qui leakent les emails complets. Le
TraceableTransport(dev) garde tout en RAM ; en prod, désactivez-le ou nettoyez. MAILER_DSN=smtp://localhosten prod. Vos emails finissent en spam (pas de SPF/DKIM/DMARC alignés). Utilisez un provider tiers, point.- Pas de
envelope.senderquandFrom:est différent du domaine vérifié → bounces. LeReturn-Pathdoit être sur un domaine sous votre contrôle. - Template Twig HTML sans version texte. Gmail Spam-classifie plus volontiers. Toujours fournir
textTemplate()aussi. - Pièces jointes énormes (>10 MB). SES rejette, Postmark coupe. Hébergez sur S3 et envoyez un lien signé.
- Boucles de bounces. Un user
hard_bouncedqui reste dans la liste = chaque digest échoue × N utilisateurs. Filtrez à la requête. - Tester avec un vrai serveur SMTP en CI. Utilisez
null://nullouMockTransport— sinon vous facturez des emails de tests, ou vous polluez un domaine. - Ignorer le
Reply-To. SiFrom:estno-reply@, ajoutez systématiquementreplyTo()vers une boîte humaine. - Confondre
BCCetaddBcc. Le BCC est invisible des destinataires mais visible dans les logs du provider. Pour des envois en masse, utilisez plutôt des envois individuels (avec Messenger en batch).
🧪 Testing — phpunit + KernelTestCase
Configuration de test
# config/packages/test/mailer.yaml
framework:
mailer:
dsn: 'null://null' # rien ne part vraimentAssertions email avec WebTestCase
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Mime\Email;
final class SignupControllerTest extends WebTestCase
{
public function testSignupSendsWelcomeEmail(): void
{
$client = static::createClient();
$client->request('POST', '/signup', [
'email' => '[email protected]',
'password' => 'S3cret!Pass',
]);
self::assertResponseRedirects('/welcome');
$this->assertEmailCount(1);
$email = $this->getMailerMessage(0);
self::assertInstanceOf(Email::class, $email);
self::assertEmailHeaderSame($email, 'To', '[email protected]');
self::assertEmailHeaderSame($email, 'Subject', 'Bienvenue sur Learning Hub');
self::assertEmailHtmlBodyContains($email, 'Confirmer mon email');
self::assertEmailTextBodyContains($email, 'Confirme ton adresse');
}
}Test unitaire avec MockTransport
<?php
namespace App\Tests\Email;
use App\Email\WelcomeEmailSender;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Test\Constraint\EmailAddressContains;
use Symfony\Component\Mailer\Transport\NullTransport;
use Symfony\Component\Mailer\Transport\TransportInterface;
final class WelcomeEmailSenderTest extends TestCase
{
public function testItSendsAnHtmlAndTextEmail(): void
{
$transport = new class implements TransportInterface {
public array $sent = [];
public function send($message, ?\Symfony\Component\Mailer\Envelope\Envelope $envelope = null): ?\Symfony\Component\Mailer\SentMessage
{
$this->sent[] = $message;
return null;
}
public function __toString(): string { return 'mock'; }
};
$mailer = new Mailer($transport);
// … injection d'un Twig stub si TemplatedEmail
$sender = new WelcomeEmailSender($mailer, '[email protected]', 'Hub');
$sender->send('[email protected]', 'Alice', 'https://hub.test/confirm/abc');
self::assertCount(1, $transport->sent);
$email = $transport->sent[0];
self::assertSame('[email protected]', $email->getTo()[0]->getAddress());
}
}Profiler en dev — /_profiler/email
En APP_ENV=dev, le profiler Symfony intercepte chaque email via TraceableTransport et l'affiche dans le panneau "Emails". On peut prévisualiser le HTML rendu et copier le source MIME. Indispensable pour itérer sur les templates.
Test de rendu HTML avec snapshot
<?php
namespace App\Tests\Email;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bridge\Twig\Mime\BodyRenderer;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
final class WelcomeEmailRenderTest extends KernelTestCase
{
public function testRenderedHtmlContainsConfirmLink(): void
{
self::bootKernel();
$renderer = static::getContainer()->get(BodyRenderer::class);
$email = (new TemplatedEmail())
->from('[email protected]')
->to('[email protected]')
->htmlTemplate('emails/welcome.html.twig')
->textTemplate('emails/welcome.txt.twig')
->context([
'userName' => 'Alice',
'confirmUrl' => 'https://hub.test/confirm/xyz',
]);
$renderer->render($email);
self::assertStringContainsString('Alice', $email->getHtmlBody());
self::assertStringContainsString('https://hub.test/confirm/xyz', $email->getHtmlBody());
self::assertStringContainsString('Confirme ton adresse', $email->getTextBody());
}
}Test de bout-en-bout via Messenger (sync transport)
# config/packages/test/messenger.yaml
framework:
messenger:
transports:
async: 'sync://' # remplace Redis/RabbitMQ par exécution sync en testAinsi $mailer->send() exécute le handler immédiatement, et assertEmailCount() voit le mail. Utile pour tester toute la chaîne sans worker.
🎬 Cas d'usage concrets
Scénario 1 — Mails transactionnels banque (Boursorama, Hello Bank, Fortuneo)
Une néobanque envoie 2,5M de mails transactionnels/mois : confirmation d'ouverture de compte, virement reçu, alerte solde négatif, MFA OTP, relevés mensuels. Le stack Symfony Mailer pointe vers Amazon SES (zone eu-west-3 Paris) via DSN ses+smtp://.... Les templates Twig sont en templates/email/ avec versioning Git, héritage email/base.html.twig (header BNP logo + footer mentions légales DOC 6 LCB-FT). Les variables sont sanitisées (PII : nom client, montant ; jamais l'IBAN complet) via un EmailDataNormalizer. Tous les emails passent par Messenger transport async (queue Redis), avec retry multiplier: 2 jusqu'à 5 tentatives, puis DLQ pour les rejets ESP. Le DKIM et SPF sont signés via SES, mais l'application signe aussi en DKIM SymfonyMime pour la double validation (exigence BSP). Les MessageEvent permettent d'injecter un X-BPCE-Channel: transactional header pour le tri ESP. Les bounces hard et plaintes sont remontées via SNS → un webhook Symfony qui marque l'email suspended (plus jamais d'envoi). Compliance ACPR : tout email contractuel est archivé 10 ans sur S3 en .eml chiffré (GPG).
Scénario 2 — Mails cabinet juridique relance (Jarvis Legal, Septeo Avocats)
Un cabinet d'avocats utilise Mailer pour envoyer : relance facture en retard, convocation audience, notification d'ajout de pièce dans le DMS, alerte client. Le DSN pointe vers Mailjet (FR, certifié ISO 27001). Les templates incorporent la signature manuscrite scannée de l'avocat + son numéro de barreau (variable injectée). Les emails de relance utilisent embeded image pour le logo via embedFromPath. Les pièces jointes (factures PDF, conclusions Word) sont attachées via attachFromPath avec inspection antivirus préalable (ClamAV). Les Address typées garantissent que les noms d'avocats sont bien encodés (UTF-8 + RFC 6532 pour caractères français). Mailer écrit dans le DMS un EmailSentEntry (audit RGPD : qui a envoyé quoi à qui, quand) via un MessageListener. Si l'envoi échoue (bounce avocat externe sur ancien domaine), un Slack notifie le secrétariat. Volume modeste : 12 000 emails/mois, mais chaque email a une valeur juridique (preuve de notification).
Scénario 3 — Newsletter e-commerce (Sézane, Asphalte, Veepee)
Une marque DTC envoie sa newsletter hebdo (180 000 abonnés) + transactionnels (commandes, livraisons). Le stack split : transactional via Postmark (postmark+api://) avec excellente délivrabilité, marketing via Brevo (brevo+api://) avec gestion de listes/segmentation. Les templates marketing sont créés sur Brevo (drag&drop) et Symfony envoie juste template_id + params via la Bridge Brevo. Les transactionnels sont gérés côté Symfony avec BodyRendererInterface + Twig + MJML compilation (spatie/laravel-mailcoach-mjml adapté). Les UTM tracking codes sont injectés dans les liens via un Twig extension. Le test A/B (objet "Promo -20%" vs "🎉 Soldes"... non, sans emoji) est piloté côté Brevo. Sentry capture les exceptions TransportExceptionInterface (timeout API Brevo). Sur les soldes 2026, l'équipe a envoyé 800 000 mails en 4h via Messenger transport batch (50 workers parallèles), avec rate limiting Brevo respecté (40 mails/sec) via RateLimiterFactory.
🛠️ Exemple end-to-end
Cas : envoi d'un mail transactionnel banque (confirmation virement) async + retry + signature DKIM + archivage légal.
<?php
// src/Email/TransferConfirmationEmail.php
declare(strict_types=1);
namespace App\Email;
use App\Entity\Transfer;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;
final class TransferConfirmationEmail extends TemplatedEmail
{
public static function forTransfer(Transfer $transfer, Address $recipient): self
{
$email = new self();
$email
->from(new Address('[email protected]', 'Bank — Service Virements'))
->to($recipient)
->subject(sprintf('Virement de %s € confirmé', $transfer->amount))
->htmlTemplate('email/transfer-confirmation.html.twig')
->textTemplate('email/transfer-confirmation.txt.twig')
->context([
'transfer_id' => $transfer->id,
'amount' => $transfer->amount,
'currency' => $transfer->currency,
'beneficiary_masked' => self::maskIban($transfer->creditorIban),
'executed_at' => $transfer->executedAt,
])
->getHeaders()
->addTextHeader('X-Bank-Channel', 'transactional')
->addTextHeader('X-Bank-Transfer-Id', $transfer->id);
return $email;
}
private static function maskIban(string $iban): string
{
return substr($iban, 0, 4) . str_repeat('*', max(0, strlen($iban) - 8)) . substr($iban, -4);
}
}<?php
// src/MessageHandler/SendTransferConfirmationHandler.php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Email\TransferConfirmationEmail;
use App\Message\SendTransferConfirmation;
use App\Repository\TransferRepository;
use App\Service\EmailArchive;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Address;
#[AsMessageHandler]
final readonly class SendTransferConfirmationHandler
{
public function __construct(
private MailerInterface $mailer,
private TransferRepository $transfers,
private EmailArchive $archive,
) {}
public function __invoke(SendTransferConfirmation $message): void
{
$transfer = $this->transfers->get($message->transferId);
$email = TransferConfirmationEmail::forTransfer(
$transfer,
new Address($transfer->debtorEmail, $transfer->debtorName),
);
$this->mailer->send($email);
$this->archive->store($email, retentionYears: 10);
}
}# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
headers:
From: 'Bank <[email protected]>'
Reply-To: '[email protected]'
envelope:
sender: '[email protected]'
when@prod:
framework:
mailer:
transports:
main: '%env(MAILER_DSN)%'
bulk: '%env(MAILER_BULK_DSN)%'
dkim:
key_path: '%env(DKIM_KEY_PATH)%'
domain: 'bank.example.fr'
selector: 'symfony2026'# config/packages/messenger.yaml
framework:
messenger:
transports:
email_async:
dsn: '%env(MESSENGER_REDIS_DSN)%'
retry_strategy:
max_retries: 5
multiplier: 2
delay: 2000
max_delay: 600000
failure_transport: email_failed
email_failed: 'doctrine://default?queue_name=failed_email'
routing:
App\Message\SendTransferConfirmation: email_async
Symfony\Component\Mailer\Messenger\SendEmailMessage: email_async<?php
// tests/Email/TransferConfirmationEmailTest.php
declare(strict_types=1);
namespace App\Tests\Email;
use App\Email\TransferConfirmationEmail;
use App\Entity\Transfer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mime\Address;
final class TransferConfirmationEmailTest extends TestCase
{
public function testIbanIsMaskedInBody(): void
{
$transfer = new Transfer();
$transfer->id = 't-001';
$transfer->creditorIban = 'FR7630001007941234567890185';
$transfer->amount = '150.00';
$transfer->currency = 'EUR';
$transfer->debtorEmail = '[email protected]';
$transfer->debtorName = 'Léa Martin';
$transfer->executedAt = new \DateTimeImmutable();
$email = TransferConfirmationEmail::forTransfer(
$transfer,
new Address($transfer->debtorEmail, $transfer->debtorName),
);
$context = $email->getContext();
self::assertSame('FR76*******************0185', $context['beneficiary_masked']);
}
}Couverture : value object email + handler async + retry + signature DKIM + archive RGPD + test masquage IBAN. Le mail confirmant un virement est tracé de bout en bout.
🏋️ Exercices
Progression : on implémente, puis on durcit pour la prod, puis on casse pour réparer. Faites-les dans l'ordre.
1. Le sender transactionnel testable (implémentation)
Objectif — écrire un PasswordResetEmailSender qui envoie un TemplatedEmail (HTML + texte), avec replyTo() vers une boîte humaine, un header X-Intent: password_reset, et un test KernelTestCase qui assert le rendu sans rien envoyer.
Indice/Solution — DSN null://null en test ; injecter MailerInterface + fromAddress ; assertions assertEmailHtmlBodyContains / assertEmailHeaderSame. Le piège : oublier textTemplate() → Gmail spam-classifie. Vérifier que getMailerMessage() retourne bien un Email avec les deux corps.
2. Asynchronisation + retry profilé (production-grade)
Objectif — router SendEmailMessage via Messenger sur deux queues distinctes : email_otp (retry agressif court : max_retries: 2, delay: 500, alerte si échec) et email_receipt (retry long + DLQ Doctrine + archivage). Justifier chaque paramètre par le profil latence-vs-fiabilité.
Indice/Solution — deux transports dans messenger.yaml, deux clés de routing. Pour router selon l'intent, créer deux Message distincts (SendOtpEmail, SendReceiptEmail) plutôt que de router le SendEmailMessage générique (qui ne porte pas l'intent). Le failure_transport: email_failed sur doctrine:// pour la DLQ. Tester en sync:// que la chaîne complète passe.
3. Bounce processor idempotent et sécurisé (production-grade)
Objectif — implémenter le webhook bounce d'un provider (Postmark ou Mailgun) : vérifier la signature HMAC, distinguer hard_bounce (suppression définitive) de soft_bounce (transitoire), et garantir l'idempotence (le provider rejoue le webhook en cas de timeout).
Indice/Solution — clé d'idempotence = provider_message_id + type d'event, stockée en base avec contrainte unique → un INSERT … ON CONFLICT DO NOTHING ou un try/catch UniqueConstraintViolation. Vérifier la signature avant de parser le corps (sinon DoS). Répondre 200 même sur event déjà vu (sinon le provider retente à l'infini). Marquer email_status = suspended sur hard bounce → la requête d'envoi du digest exclut ces users.
4. Casser puis réparer la délivrabilité (break-then-fix)
Objectif — partir d'une config qui envoie en spam : From: [email protected], pas d'envelope.sender, pas de version texte, envoi SMTP localhost. Diagnostiquer pourquoi via les headers reçus, puis réparer pour passer SPF/DKIM/DMARC.
Indice/Solution — symptômes : dmarc=fail, spf=softfail dans Authentication-Results. Réparations : From: sur domaine vérifié chez le provider, envelope.sender (= Return-Path) aligné, ajouter textTemplate(), passer à ses+https/postmark+api. Vérifier avec un outil type mail-tester.com ou en lisant les en-têtes Authentication-Results reçus chez Gmail.
5. Rate limiting d'un batch (break-then-fix)
Objectif — un digest envoie à 50 000 users ; en l'état, 20 workers saturent l'API du provider (429 Too Many Requests) et la moitié des mails partent en DLQ. Réparer en respectant le quota provider (ex. 40 mails/s) sans sérialiser à 1 worker.
Indice/Solution — symfony/rate-limiter avec une politique token_bucket partagée (storage Redis pour qu'elle soit globale entre workers) ; le handler ->consume(1) puis reserve()->wait() si épuisé. Alternative : transport Messenger avec rate_limiter configuré (Symfony 6.4+). Mesurer : avant/après, le taux de 429 doit tomber à 0 et le débit se stabiliser au plafond.
6. Corrélation end-to-end observable (architect)
Objectif — relier un clic utilisateur jusqu'à l'event delivered du provider via un seul correlation_id, traversant : log applicatif → message Messenger → SentMessageEvent (capture du provider_message_id) → webhook delivered. Exposer une métrique email_delivery_seconds (temps enqueue → delivered).
Indice/Solution — propager le correlation_id dans un header X-Correlation-Id et dans le Stamp Messenger (pour qu'il survive à la sérialisation). Au SentMessageEvent, persister (correlation_id → provider_message_id). Au webhook, retrouver le correlation_id par jointure et émettre la métrique. Le piège : le header MIME et le contexte Messenger sont deux mondes ; il faut ponter les deux.
🎤 En entretien
Q : Pourquoi router les emails via Messenger plutôt que d'appeler send() directement dans le contrôleur ? R : Pour découpler la latence HTTP de la latence du provider. Un appel SES synchrone peut prendre 300 ms à 30 s (timeout) ; en async, le contrôleur répond en <50 ms et un worker absorbe la lenteur, les pics et les pannes (retry/backoff/DLQ). C'est aussi ce qui rend l'envoi retryable et observable.
Q : Un email "part" (pas d'exception) mais l'utilisateur ne le reçoit pas. Comment vous debuggez ? R : send() sans exception garantit seulement que le transport a accepté le message, pas la livraison en inbox. Je remonte la chaîne via le correlation_id/provider_message_id : logs applicatifs → SentMessageEvent → dashboard du provider (accepted/delivered/bounced/spam) → webhooks. 90 % du temps c'est de la délivrabilité : DMARC/SPF/DKIM non alignés, hard bounce, ou classement spam — pas un bug de code.
Q : From: vs Sender: vs Return-Path (envelope sender) — quelle différence et pourquoi ça compte ? R : From: est l'auteur affiché (header visible). Le Return-Path/envelope sender est l'adresse SMTP réelle où reviennent les bounces et c'est elle qui est validée par SPF. Si les deux domaines ne sont pas alignés, DMARC casse et vous tombez en spam. D'où la règle : From: toujours sur un domaine vérifié chez le provider, envelope.sender aligné, et replyTo() pour les réponses humaines.
Q : Comment garantir qu'un webhook de bounce ne corrompt pas vos données s'il est rejoué deux fois ? R : Idempotence. Clé = provider_message_id + type d'event avec contrainte d'unicité en base ; un INSERT en conflit est un no-op. On vérifie la signature HMAC avant de parser (anti-DoS) et on répond 200 même pour un event déjà traité, sinon le provider retente indéfiniment. L'effet de bord (marquer suspended) doit être idempotent par construction (un UPDATE vers un état stable, pas un incrément).
🔁 Quand utiliser / éviter
Utilisez symfony/mailer quand :
- Vous envoyez moins de ~100 000 emails / jour (au-delà, considérez du marketing dédié type Sendgrid / Customer.io avec API directe).
- Vous voulez le découplage transport (changer Postmark → SES sans refactor).
- Vous mixez transactionnel (Mailer) et notifications (Notifier).
- Vous voulez prévisualiser en dev et tester en CI sans config exotique.
Évitez (ou complétez) si :
- Newsletters / campaign marketing : utilisez Customer.io, Mailchimp, Loops. Mailer n'a pas de gestion segmentation/A-B/unsubscribe natif.
- Volumes industriels (>1M/jour) : il faut un batch sender côté provider et de la concurrence côté worker fine-tunée.
- Drag-and-drop pour les non-devs : Mailer ne donne pas d'éditeur visuel. Couplez avec un MJML compilé en build ou un outil externe.
🔗 Liens
- Doc officielle Mailer : https://symfony.com/doc/current/mailer.html
- Composant Mime : https://symfony.com/doc/current/components/mime.html
- Resend transport : https://github.com/resend/resend-php
- DMARC explainer : https://dmarc.org/overview/
- Email on Acid (test rendering cross-clients) : https://www.emailonacid.com/
- Twig CSS Inliner : https://symfony.com/doc/current/mailer.html#inlining-css-styles