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.