Aller au contenu principal

Un transport Symfony Mailer sur mesure pour une API d'emails interne

Photo d'Emmanuel BALLERY, fondateur de x10
Emmanuel BALLERY
CTO freelance & Architecte logiciel
calendar_today 21/06/2026
schedule 12 min lecture
Un transport sur mesure relie Symfony Mailer à une API d'emails d'entreprise authentifiée et chiffrée

Dans une grande entreprise, on n'envoie pas un email comme on veut. Le SMTP sortant est fermé, et tout passe par une API maison : un point d'entrée unique, authentifié en OAuth2, qui sait signer et chiffrer les messages aux normes du groupe avant de les remettre. La contrainte n'est pas technique, elle est de gouvernance — traçabilité, conformité, un seul tuyau à auditer — et elle ne se négocie pas. La question pour le développeur est donc : comment faire pour que cinquante endroits du code qui envoient des emails continuent d'ignorer totalement cette plomberie ?

La réponse tient dans un composant Symfony qu'on étend rarement, mais qui est fait exactement pour ça : le transport du Mailer. J'ai écrit ce pont chez le grand opérateur télécom de cette série d'articles, et il vit dans la librairie interne partagée entre toutes les applications. Le résultat : aucune ligne de code métier ne sait que l'API existe.

Le mauvais réflexe : un service qui appelle l'API

La tentation immédiate est d'écrire un service qui prend les paramètres d'un email et appelle l'API. Ça marche, et c'est un piège :

// un service maison, appelé partout où l'on envoie un email
$this->mailApi->send(
    to: $user->email,
    subject: 'Votre commande',
    html: $renderedHtml,
);

Le problème n'est pas ce code, c'est tout ce qu'il abandonne. Symfony Mailer offre déjà un objet Email riche — pièces jointes, images inline, en-têtes, Reply-To —, l'intégration Twig (email.image(), le rendu de templates), la file d'attente via Messenger, les events, le spooling en test. Un service maison repart de zéro sur chacun de ces points, et surtout il couple cinquante endroits du code à une API spécifique : le jour où l'on veut tester en local avec Mailpit, ou router une application vers un autre fournisseur, il faut tout retoucher. Le bon point d'extension est plus bas, à l'endroit précis où Symfony décide comment un Email part : le transport.

Le bon point d'extension : un transport derrière un DSN

Symfony Mailer choisit son transport à partir d'une seule variable d'environnement, le DSN. smtp://…, ses+api://…, null://null en test : changer de canal d'envoi, c'est changer cette ligne, jamais le code. Il suffit donc d'enregistrer notre propre schéma — api:// — et tout le reste de l'application continue d'appeler $mailer->send($email) sans rien savoir :

# en production : l'API interne, avec ses identifiants OAuth2
MAILER_DSN=api://CLIENT_ID:SECRET@mail.interne.example

# en local : un Mailpit, sans toucher une ligne de code applicatif
MAILER_DSN=smtp://127.0.0.1:1025

Un schéma de DSN se déclare avec une TransportFactory. Le tag mailer.transport_factory suffit à ce que Symfony l'interroge : chaque fabrique répond si elle supporte le schéma, et celle qui dit oui construit le transport. C'est aussi ici qu'on configure le client HTTP — timeout franc, en-têtes par défaut — une bonne fois pour toutes :

#[AutoconfigureTag('mailer.transport_factory')]
final readonly class MailApiTransportFactory implements TransportFactoryInterface
{
    public function __construct(
        #[Autowire(service: 'mail.cache')]
        private CacheInterface $cache,
        private EventDispatcherInterface $dispatcher,
    ) {
    }

    public function supports(Dsn $dsn): bool
    {
        return 'api' === $dsn->getScheme();
    }

    public function create(Dsn $dsn): TransportInterface
    {
        $client = HttpClient::create([
            'timeout' => 30.0,
            'max_duration' => 30.0,
        ]);

        return new MailApiTransport($dsn, $this->cache, $client, $this->dispatcher);
    }
}

À partir de là, le contrat est posé : le métier manipule des Email, l'infrastructure choisit le tuyau par configuration. Tout le travail restant est encapsulé dans le transport — invisible du reste du code.

Traduire l'Email Symfony vers le payload de l'API

Symfony fournit une base taillée pour ce cas : AbstractApiTransport. Elle gère l'enveloppe, les events, la file d'attente ; il ne reste qu'une méthode à écrire, doSendApi(), dont tout le métier est de traduire l'objet Email vers le JSON attendu par l'API, puis de poster :

protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $envelope): ResponseInterface
{
    $payload = $this->createPayload($email);

    $response = $this->post($payload, $this->getToken());

    // le token en cache peut être périmé : on le purge et on retente une fois
    $status = $response->getStatusCode();
    if (401 === $status || 403 === $status) {
        $this->resetToken();
        $response = $this->post($payload, $this->getToken());
        $status = $response->getStatusCode();
    }

    if (400 <= $status) {
        $error = $response->toArray(false);

        throw new HttpTransportException(sprintf('Envoi via l\'API échoué (HTTP %d): %s', $status, $error['message'] ?? 'erreur inconnue'), $response);
    }

    return $response;
}

L'essentiel de la traduction est mécanique — sujet, corps texte et HTML, destinataires to/cc/bcc normalisés en {email, name}. Un seul point mérite l'attention : les images inline. Une image insérée dans un template via email.image() n'est pas une pièce jointe ordinaire — elle porte un Content-ID que le HTML référence en cid:…, et l'API l'attend dans un tableau distinct. Confondre les deux, c'est afficher une croix rouge à la place du logo :

// on sépare pièces jointes classiques et parts inline sur la disposition
foreach ($email->getAttachments() as $part) {
    $isInline = 'inline' === $part->getDisposition();

    $attachment = [
        'fileName' => $part->getFilename(),
        'contentType' => $part->getContentType(),
        'content' => base64_encode($part->getBody()),
    ];

    // une part inline porte le Content-ID référencé par cid:… dans le HTML
    if ($isInline) {
        $attachment['contentId'] = $part->getContentId();
    }

    // … rangé dans 'attachments' ou 'inlineAttachments' selon $isInline
}

L'authentification : un token mis en cache, pas négocié à chaque envoi

L'API exige un jeton OAuth2 obtenu en client credentials : on échange un identifiant et un secret contre un access_token à durée de vie limitée. Le négocier à chaque email serait absurde — un aller-retour réseau supplémentaire avant chaque envoi, pour un token valable une heure. Il se met donc en cache, et le détail qui compte est la marge d'expiration :

private function getToken(): string
{
    return $this->cache->get(self::TOKEN_CACHE_KEY, function (ItemInterface $item): string {
        $data = $this
            ->client
            ->request('POST', $this->tokenUrl(), [
                'auth_basic' => [$this->dsn->getUser(), $this->dsn->getPassword()],
                'body' => ['grant_type' => 'client_credentials'],
            ])
            ->toArray();

        // on expire 60 s avant l'heure : pas d'envoi avec un token déjà mort
        $item->expiresAfter($data['expires_in'] - 60);

        return $data['access_token'];
    });
}

Ces soixante secondes de marge évitent la course classique : un token lu du cache à la dernière seconde de sa validité, posté à l'API juste après son expiration, et l'email perdu. Mais une marge ne couvre pas tout — un token peut être révoqué côté serveur avant son terme, ou le cache être désynchronisé. C'est le rôle du retry sur 401/403 vu plus haut : plutôt que de faire confiance aveugle au cache, on traite un refus d'authentification comme un signal « ton token ne vaut plus rien », on le purge et on en redemande un. Le cache optimise le cas courant ; le retry rattrape le cas rare. Aucun des deux ne suffit seul.

Passer des options par message : signer, chiffrer

Dernier obstacle, et le plus intéressant de conception. L'API sait signer et chiffrer un email en S/MIME, mais par message : un mail de notification anodin part en clair, un courrier contractuel doit être signé. Or l'abstraction qu'on a choisie est précisément faite pour ignorer le transport — la couche métier manipule un Email standard, sans aucune notion de signature. Comment, alors, lui faire passer une consigne destinée au transport, sans réintroduire le couplage qu'on vient d'éliminer ?

La réponse passe par le seul canal que Email transporte de bout en bout sans le comprendre : ses en-têtes. Un en-tête X- applicatif voyage avec le message jusqu'au transport, qui le lit, le traduit en option d'API, puis le retire avant l'envoi — pour qu'il n'atteigne jamais le destinataire :

abstract class MailHeaders
{
    public const string SIGN = 'X-Mail-Sign';
    public const string ENCRYPT = 'X-Mail-Encrypt';

    public static function sign(Email $email): void
    {
        $email->getHeaders()->addTextHeader(self::SIGN, '1');
    }

    public static function encrypt(Email $email): void
    {
        $email->getHeaders()->addTextHeader(self::ENCRYPT, '1');
    }

    // raccourci : signer ET chiffrer en un appel
    public static function secure(Email $email): void
    {
        self::sign($email);
        self::encrypt($email);
    }
}

Côté métier, demander une signature reste donc déclaratif et ignore tout de l'API : MailHeaders::sign($email) (ou ::secure() pour signer et chiffrer), puis l'envoi habituel. Côté transport, on lit les drapeaux et on nettoie dans la foulée :

$headers = $email->getHeaders();

$flags = [
    'sign' => $headers->has(MailHeaders::SIGN),
    'encrypt' => $headers->has(MailHeaders::ENCRYPT),
];

// on consomme les en-têtes : ils ne doivent pas fuiter chez le destinataire
$headers->remove(MailHeaders::SIGN);
$headers->remove(MailHeaders::ENCRYPT);

Ce motif — un en-tête X- comme canal d'options entre l'émetteur et le transport, consommé en chemin — n'est pas une astuce maison : c'est ainsi que Symfony lui-même fait passer ses TagHeader et MetadataHeader aux transports SES ou Mailgun. On reste dans l'idiome du framework, et le couplage est nul : le code métier dépend d'une classe utilitaire à trois méthodes, pas de l'API.

Ce que ce découpage apporte

Le bénéfice se mesure à ce qui ne change pas. Toutes les applications du parc envoient leurs emails avec le Mailer standard, leurs templates Twig, leurs pièces jointes et leurs images inline — exactement comme dans n'importe quel projet Symfony. La contrainte corporate la plus rigide — passer par une API maison authentifiée et chiffrée — tient dans une centaine de lignes, isolée dans la librairie partagée, et se réduit à une variable d'environnement côté consommateur.

Le jour où l'API change d'URL, de format d'authentification ou de payload, un seul fichier bouge, dans un seul dépôt, et toutes les applications en héritent par une montée de version. Le jour où un développeur veut tester ses emails en local, il bascule sur un smtp:// (un Mailpit, cf. plus bas) et oublie l'API. C'est la définition même d'une bonne abstraction : pousser la complexité incompressible dans un coin unique, et rendre tout le reste ignorant de son existence.

Bonus : Mailpit en dev et en test

Puisque le canal d'envoi tient dans le MAILER_DSN, le transport custom ne tourne qu'en production : en local et en CI, on bascule sur un outil qui capte tout sans rien envoyer pour de vrai. Mailpit — le successeur moderne de MailHog — est parfait pour ça : un binaire unique, qui expose à la fois un serveur SMTP, une interface web pour lire les messages capturés, et une API REST. Côté configuration, c'est une ligne :

MAILER_DSN=smtp://127.0.0.1:1025

En développement, l'interface web (port 8025 par défaut) affiche chaque email avec son rendu HTML, ses en-têtes et ses pièces jointes — on vérifie d'un coup d'œil qu'un template s'affiche bien, sans polluer une vraie boîte. Mais le vrai gain est en test automatisé : plutôt que de moquer le Mailer, on laisse l'application envoyer réellement vers Mailpit, puis on interroge son API pour asserter ce qui est parti :

// l'application a envoyé pour de vrai vers Mailpit ; on lit son API REST
$messages = $this->client
    ->request('GET', 'http://127.0.0.1:8025/api/v1/messages')
    ->toArray();

self::assertSame(1, $messages['total']);
self::assertSame('Votre commande', $messages['messages'][0]['Subject']);
self::assertSame('client@example.com', $messages['messages'][0]['To'][0]['Address']);

On teste ainsi le sujet, les destinataires, le corps, jusqu'aux pièces jointes — sur le vrai Email produit par le code, et non sur un mock qui finirait par diverger. C'est le prolongement naturel du choix d'architecture : avoir branché l'envoi au niveau du transport rend toute la chaîne d'emails testable de bout en bout, sans qu'aucun test ne connaisse l'existence de l'API de production.

Conclusion

Brancher Symfony Mailer sur une API d'entreprise ne demande pas de contourner le framework, mais d'en utiliser le point d'extension prévu : un transport derrière un DSN, qui traduit l'Email en appel d'API, met son token OAuth2 en cache avec une marge et un retry, et reçoit ses options par message via des en-têtes consommés en chemin. Une centaine de lignes écrites une fois, et la contrainte la plus pénible d'un environnement corporate devient invisible pour le code qui envoie les emails.

La leçon dépasse l'email. Chaque fois qu'une infrastructure impose son tuyau — un bus de messages maison, un stockage de fichiers propriétaire, un fournisseur d'identité interne —, la bonne question n'est pas « quel service écrire ? » mais « quel point d'extension le framework offre-t-il déjà ? ». La réponse y est presque toujours, et elle préserve ce qui a le plus de valeur : du code métier qui ignore tout de la plomberie.

Photo d'Emmanuel BALLERY, fondateur de x10

À propos de l'auteur

Emmanuel BALLERY est le fondateur de x10 solutions. Expert en architecture logicielle et passionné par la qualité du code (Software Craftsmanship), il aide les entreprises à transformer leur dette technique en actifs durables.

Voir plus arrow_forward

Questions fréquentes

Pourquoi écrire un transport Mailer plutôt qu'un service qui appelle l'API ? expand_more
Parce que le transport est le point d'extension prévu par Symfony : tout le code applicatif garde l'objet Email standard, les templates Twig, les pièces jointes et la file d'attente Messenger. Un service maison repart de zéro sur tout ça et couple chaque endroit d'envoi à l'API. Avec un transport, on ne change qu'une variable d'environnement, le MAILER_DSN.
Comment passer des options par message — signer ou chiffrer un email précis ? expand_more
Via des en-têtes MIME applicatifs (X-Mail-Sign, X-Mail-Encrypt). Le code métier les pose de façon déclarative, le transport les lit, les traduit en option d'API puis les retire avant l'envoi pour qu'ils n'atteignent pas le destinataire. C'est l'idiome utilisé par Symfony lui-même pour ses TagHeader et MetadataHeader vers SES ou Mailgun.
Comment gérer le token OAuth2 sans le négocier à chaque envoi ? expand_more
On le met en cache avec une expiration calée 60 secondes avant son terme réel, pour ne jamais poster avec un token déjà mort. Et comme un token peut être révoqué avant l'heure, un refus 401/403 de l'API déclenche une purge du cache et un unique retry. Le cache optimise le cas courant, le retry rattrape le cas rare.
Un transport custom fonctionne-t-il avec l'envoi asynchrone et les templates Twig ? expand_more
Oui, c'est tout l'intérêt. En héritant d'AbstractApiTransport, on récupère gratuitement l'enveloppe, les events, le routage Messenger et l'intégration TwigBridge (rendu de templates, images inline en cid:). La seule méthode à écrire est la traduction de l'Email vers le payload JSON de l'API.