Tous les articles

Tous les articles

L'e-mail transactionnel comme sous-système

L'e-mail construit une fois, comme un sous-système : une ligne d'outbox dans la transaction de l'appelant, du MJML sans Node et une vue d'administration.

Jusqu'à la fin juin, la plateforme ne savait pas envoyer un seul e-mail, et toute une file de fonctionnalités l'attendait : réinitialisation du mot de passe, invitations, codes de connexion, notifications, factures. J'ai construit l'e-mail une seule fois, comme un sous-système, plutôt que dans la première fonctionnalité qui en avait besoin. La capacité et une vue d'administration pour la surveiller sont arrivées le même jour, avec un modèle de référence qui prouvait la chaîne de bout en bout et aucun e-mail destiné aux utilisateurs pour l'instant.

Une outbox plutôt qu'une file#

La solution évidente met un job d'envoi en file au moment où l'action a lieu. Elle perd du courrier dans les deux sens. Le job part en file puis la transaction est annulée, et un e-mail annonce quelque chose qui n'a jamais eu lieu. Ou la transaction est validée pendant que la file est brièvement injoignable, et un e-mail qui devait partir ne part jamais.

La plateforme écrit plutôt une ligne sortante, dans la transaction de l'appelant, et ne met qu'ensuite en file le job qui la livre. Une action annulée ne laisse aucune ligne ; une action validée en laisse une que quelque chose livrera. Un balayeur cron reprend toute ligne restée deux minutes au-delà de son heure prévue, ce qui couvre la mise en file perdue et le worker mort en plein envoi. Le job verrouille sa ligne et le balayeur saute les lignes déjà verrouillées, si bien que les deux ne livrent jamais la même.

Chaque ligne suit queued → sending → sent. Une erreur SMTP passagère la fait passer en retrying, avec un délai qui commence à une minute et double jusqu'à une heure. Une erreur définitive, ou le cinquième échec, la termine en failed, et les lignes en échec forment une file de lettres mortes que je peux interroger et rejouer. Comme chaque tentative vit sur une ligne, chaque e-mail que la plateforme a envoyé ou échoué à envoyer laisse une trace d'audit.

Des modèles qui survivent à Outlook#

Chaque e-mail tient en trois fichiers : un objet, un corps MJML et une version texte. MJML parce que du HTML responsive qu'Outlook affiche correctement est un métier à part entière, et que MJML le produit. Le compilateur MJML de référence est un programme Node alors que l'API est en Python ; j'ai donc pris mrml, une implémentation en Rust avec des bindings Python : rendre un e-mail ne demande aucun runtime Node à côté de l'API.

Les modèles sont rendus dans un environnement Jinja2 sandboxé, avec l'échappement automatique sur les parties HTML : un utilisateur dont le nom contient du balisage, ou quelque chose qui ressemble à une expression de modèle, le voit affiché comme du texte. Les textes passent par les mêmes catalogues de traduction que le reste de l'API, en anglais et en français.

La sécurité à la frontière#

Chaque contrôle a lieu quand un message est construit, pas quand il est envoyé. Un retour chariot ou un saut de ligne dans un en-tête est refusé, ce qui ferme l'injection d'en-têtes. Les adresses sont validées, et les pièces jointes comme le message entier ont une taille maximale. Hors production, les destinataires peuvent être limités à une liste ou redirigés, pour qu'un développeur branché sur un vrai relais ne puisse pas écrire à de vrais utilisateurs. Les logs masquent chaque adresse. En production, la configuration refuse de démarrer avec une connexion SMTP en clair ou dont le certificat n'est pas vérifié.

Les limites de débit par destinataire et les clés d'idempotence s'activent appel par appel. Une réinitialisation de mot de passe utilise les deux : un double clic envoie un seul e-mail et une boucle scriptée est freinée.

Une vue d'administration, le même jour#

Une outbox n'est une trace d'audit que si quelqu'un peut la lire. Les lignes sont devenues une ressource du framework CRUD de la plateforme, derrière un nouveau droit d'accès donné aux administrateurs de la plateforme : une liste, une recherche et un formulaire en lecture seule, avec deux actions, renvoyer et annuler, qui refusent une ligne dont l'état ne s'y prête pas.

Les règles de sécurité m'ont appris quelque chose sur mon propre moteur de contrôle d'accès. Je voulais que les opérateurs lisent toutes les colonnes et n'en modifient qu'une poignée, et j'ai écrit une seule règle pour les deux. Dans ce moteur, une restriction par champ s'applique aussi à la lecture, et cette règle unique masquait la plus grande partie de la ligne. Elle est devenue deux règles, l'une pour lire et l'autre pour modifier les champs de planification. Les corps rendus et le contexte des modèles restent secrets ; la dernière erreur ne l'est pas, parce qu'un opérateur doit savoir pourquoi un envoi a échoué.

Le développement local a reçu Mailpit, un capteur de courrier : un e-mail envoyé depuis un portable arrive dans une boîte que je peux ouvrir plutôt que dans une ligne de log.

Ce qu'il a fallu pour livrer quoi que ce soit#

Aussi propre qu'il était, rien ne partait. Dix jours plus tard, je me suis rendu compte que rien, ni en développement local, ni en CI, ni dans la suite de bout en bout, ne démarrait le worker qui vide la file. Le sous-système était entièrement testé contre un backend en mémoire et personne ne le consommait.

Brancher le worker a révélé un bug plus subtil. Playwright démarre chaque serveur dont un run de tests a besoin et attend qu'il soit prêt ; le worker n'écoute sur aucun port, alors j'avais pointé son contrôle de disponibilité sur celui du capteur de courrier. Playwright prend un port déjà ouvert pour un serveur déjà lancé et n'exécute jamais la commande : en CI, le worker ne démarrait silencieusement jamais. Le contrôle attend désormais la ligne que le worker écrit au démarrage, et un test de bout en bout change un mot de passe puis relit dans Mailpit l'e-mail qui en résulte, objet et corps.

Deux jours plus tard, c'est la conception elle-même qu'il a fallu corriger. Le job d'envoi gardait le verrou de sa ligne, et avec lui une connexion du pool de la base de données, pendant tout l'aller-retour SMTP, et le balayeur gardait une connexion le temps de livrer jusqu'à cent lignes dans une seule transaction. Sous charge, le worker pouvait épuiser le pool, et l'API se mettait à répondre 503. La livraison est devenue réserver puis envoyer : passer la ligne en sending et valider, envoyer sans aucune connexion tenue, puis enregistrer le résultat dans une deuxième transaction courte. Le balayeur ne livre plus rien et se contente de remettre en file. La livraison devient ainsi au moins une fois, puisqu'un crash entre l'envoi et sa validation peut livrer un e-mail deux fois, et un doublon est la panne que je préfère à un message perdu.

Ce que ça a coûté#

Le premier commit a ajouté environ 2 400 lignes, dont 61 tests et un guide d'exploitation. C'était la bonne taille. La réinitialisation du mot de passe s'est appuyée dessus onze jours plus tard, et chaque e-mail depuis a la même forme : trois fichiers de modèle et un appel de fonction, avec les nouvelles tentatives, la trace d'audit et la vue d'administration déjà en place.