Skip to content

Symfony Mailer — envoi d'emails de niveau production

TL;DRsymfony/mailer est l'abstraction officielle qui découple votre code du transport (SMTP, SES, Postmark, Mailgun, Resend…). Vous écrivez un Email (objet MIME), vous le passez au MailerInterface, 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

bash
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
bash
# .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
yaml
# 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
<?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 un Headers (pas le mail). En pratique, on écrit plutôt :

php
$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

twig
{# 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>
twig
{# templates/emails/welcome.txt.twig #}
Bienvenue {{ userName }}

Confirme ton adresse pour activer ton compte :
{{ confirmUrl }}

— L'équipe Learning Hub

Inline CSS automatique

Beaucoup de clients (Gmail, Outlook desktop) ignorent les <style>. On inline avec symfony/css-inliner-extra-twig :

bash
composer require twig/cssinliner-extra twig/inky-extra
twig
{# 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

php
$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)

yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
        routing:
            Symfony\Component\Mailer\Messenger\SendEmailMessage: async

Avec 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

yaml
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 min

Si 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
<?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) :

php
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 :

php
#[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
<?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
<?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() — sur SentMessage, getMessageId() retourne l'identifiant renvoyé par le transport (le MessageId SES, le X-Message-Id Postmark). 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 le SentMessageEvent : 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 :

  1. 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.com vs news.example.com), IP/pools séparés chez le provider.
  2. 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.
  3. 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_strategy ni la même queue.

Tradeoffs transports

TransportLatence p50DélivrabilitéCoût (1M)Quand le choisir
Postmark (postmark+api)~200 msExcellente (transac dédié)~$$$Transactionnel pur, faible volume, on paie pour la délivrabilité
Amazon SES (ses+https)~300 msBonne (à chauffer)~$ (le moins cher)Gros volume, infra déjà AWS, on accepte de gérer warm-up + SNS bounces
Mailgun (mailgun+https)~250 msBonne~$$Besoin de routing entrant + sortant, EU region dispo
Brevo (brevo+api)~300 msCorrecte~$$Mix transac + marketing, listes/segments natifs
SMTP brut (smtp://)variableMauvaise sans SPF/DKIM/DMARCJamais en prod. Dev local (Mailpit) ou relais interne uniquement

Règle staff : api/https > smtp pour les transports tiers. L'API renvoie un MessageId exploitable, gère mieux les erreurs structurées (429, codes de bounce), et n'ouvre pas de connexion SMTP longue par message. Le transport smtp+ d'un provider marche, mais l'+api/+https est 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 un From: 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=nonequarantinereject). C'est l'alignement entre le domaine du From: 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

VersionApport 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

  1. Oublier d'asynchroniser. En sync, une panne SES = 30 s de timeout HTTP côté user. Toujours router SendEmailMessage via Messenger en prod.
  2. Logs qui leakent les emails complets. Le TraceableTransport (dev) garde tout en RAM ; en prod, désactivez-le ou nettoyez.
  3. MAILER_DSN=smtp://localhost en prod. Vos emails finissent en spam (pas de SPF/DKIM/DMARC alignés). Utilisez un provider tiers, point.
  4. Pas de envelope.sender quand From: est différent du domaine vérifié → bounces. Le Return-Path doit être sur un domaine sous votre contrôle.
  5. Template Twig HTML sans version texte. Gmail Spam-classifie plus volontiers. Toujours fournir textTemplate() aussi.
  6. Pièces jointes énormes (>10 MB). SES rejette, Postmark coupe. Hébergez sur S3 et envoyez un lien signé.
  7. Boucles de bounces. Un user hard_bounced qui reste dans la liste = chaque digest échoue × N utilisateurs. Filtrez à la requête.
  8. Tester avec un vrai serveur SMTP en CI. Utilisez null://null ou MockTransport — sinon vous facturez des emails de tests, ou vous polluez un domaine.
  9. Ignorer le Reply-To. Si From: est no-reply@, ajoutez systématiquement replyTo() vers une boîte humaine.
  10. Confondre BCC et addBcc. 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

yaml
# config/packages/test/mailer.yaml
framework:
    mailer:
        dsn: 'null://null' # rien ne part vraiment

Assertions email avec WebTestCase

php
<?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
<?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
<?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)

yaml
# config/packages/test/messenger.yaml
framework:
    messenger:
        transports:
            async: 'sync://' # remplace Redis/RabbitMQ par exécution sync en test

Ainsi $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
<?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
<?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);
    }
}
yaml
# 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'
yaml
# 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
<?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/Solutionsymfony/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

Bibliothèque tech perso — Achref