07 juil. 2025·8 min de lecture

Envois d'emails en double : trouver les déclencheurs doublons et ajouter des clés de déduplication

Les envois d'emails en double en production peuvent venir de déclencheurs doublons, de reprises ou de chevauchements de tâches. Apprenez à tracer la cause et à ajouter des clés de déduplication pour n'envoyer qu'un seul email.

Envois d'emails en double : trouver les déclencheurs doublons et ajouter des clés de déduplication

Ce que signifient vraiment les « emails en double » en production

Les utilisateurs ne signalent pas « des envois d'emails en double ». Ils expriment une impression : « J'ai reçu deux emails de réinitialisation de mot de passe », « Mon reçu est arrivé deux fois », ou « Votre app m'envoie des spams ». Parfois les copies sont identiques. D'autres fois elles diffèrent de quelques secondes, d'un sujet, ou d'un pixel de tracking, ce qui complique la preuve.

Les duplications minent la confiance. Si un reçu arrive deux fois, les gens craignent d'avoir été débités deux fois. Si un email de connexion ou de réinitialisation est dupliqué, les utilisateurs s'inquiètent qu'on teste leur compte. En interne, les duplications créent des tickets support, des alertes bruyantes, et des métriques trompeuses. Sur la durée, elles peuvent aussi nuire à la délivrabilité parce que les fournisseurs d'inbox remarquent des pics et des contenus répétés.

Les duplications sont délicates parce que « envoyer un email » est rarement une seule étape. Le même événement métier peut se propager dans plusieurs systèmes : un webhook se déclenche, un job d'arrière-plan est relancé, un worker redémarre, ou un utilisateur clique deux fois et votre frontend soumet deux fois. Chaque pièce peut se comporter « correctement », mais ensemble elles peuvent déclencher le même envoi plusieurs fois.

L'objectif est simple et testable : un événement métier = un email.

Un événement métier est ce qui compte pour vous, par exemple « réinitialisation de mot de passe demandée pour l'utilisateur 123 » ou « facture 987 payée ». Une fois que vous définissez cet événement, protégez-le avec une identité unique afin que chaque couche puisse dire « Ceci a déjà été envoyé. »

Une façon pratique de l'encadrer :

  • Un duplicata n'est pas « deux appels SMTP ». C'est « le même événement a produit deux messages ».
  • Le corriger ne consiste pas seulement à réduire les retries. C'est rendre chaque trigger sûr à exécuter deux fois.
  • Le meilleur résultat est ennuyeux : retries, webhooks et redémarrages arrivent, et les utilisateurs reçoivent toujours un seul email.

Causes courantes : déclencheurs doubles, reprises et chevauchements de jobs

La plupart des duplications ne viennent pas d'un fournisseur d'email « fou ». Elles surviennent parce que votre appli demande le même envoi plusieurs fois, souvent depuis deux endroits qui n'en savent rien l'un de l'autre.

Un schéma fréquent commence à la périphérie. Un utilisateur double-clique, un formulaire se soumet deux fois, ou le frontend réessaie parce qu'il n'a pas reçu de réponse. Si le backend traite chaque requête comme un nouvel événement métier, vous venez de créer deux envois.

Les webhooks sont une autre source fréquente. Beaucoup de fournisseurs livrent volontairement le même webhook plusieurs fois, surtout si votre endpoint est lent ou renvoie un statut non 2xx. Si vous traitez chaque livraison comme unique, vous pouvez déclencher à nouveau la même action "envoyer un email".

Les jobs d'arrière-plan apportent leur propre type de duplication. Un job peut être mis en file deux fois à cause de races (deux serveurs traitent la même requête), de replays (la queue redrive un message), ou d'une reprise après timeout. Le pire cas est quand le worker timeout après que le fournisseur d'email a accepté l'envoi, puis réessaie et envoie de nouveau.

Quand vous traquez un duplicata unique, vous trouvez généralement l'un de ces cas :

  • Le même événement a été créé deux fois (soumission double, retry client).
  • Un webhook a été redélivré et traité comme nouveau.
  • Un job s'est exécuté deux fois (ou deux jobs en parallèle).
  • Un retry est arrivé après que l'email ait déjà quitté votre système.
  • Deux chemins de code envoient le même template (par exemple, depuis un controller et un callback de modèle).

Ce dernier est courant dans des prototypes évoluant vite : la logique d'envoi est copiée dans plusieurs handlers, et les deux restent actifs.

Commencez par un incident et construisez une timeline

Ne commencez pas par scanner tout le code. Prenez un email réel que l'utilisateur a reçu deux fois. Choisissez un template précis (par exemple « Réinitialisation de mot de passe » ou « Reçu ») et une fenêtre temporelle serrée (5 à 15 minutes) pour ne pas mélanger différents événements.

Collectez tous les identifiants possibles pour cet incident afin de pointer vers les tentatives d'envoi exactes, pas seulement vers l'utilisateur qui s'est plaint.

Pour chaque copie de l'email, récupérez :

  • Votre ID interne d'enregistrement d'email (ou l'ID de la ligne en base)
  • L'ID message/réponse du fournisseur d'email
  • Les horodatages (créé, mis en file, envoyé, accepté par le fournisseur)
  • Les IDs des entités métier (user_id, order_id, invoice_id, reset_token_id)
  • Tout request ID ou job ID lié à l'envoi

Puis rédigez une timeline en langage clair du trigger à l'acceptation par le fournisseur. Les logs aident, mais l'écrire force à clarifier.

Une timeline utile répond à quatre questions : quel événement est survenu, quel chemin de code l'a traité, quels jobs ont été mis en file, et combien de fois le fournisseur a accepté un message.

Exemple : un utilisateur clique « Reset password » à 10:03:12. Votre API crée reset_token_id=7781 et met en file un job à 10:03:13. À 10:03:14, le client réessaie (ou un webhook redelivre), créant un second token et un second job. Les deux jobs tournent et le fournisseur accepte deux messages à 10:03:20 et 10:03:22.

Instrumentez le chemin d'envoi pour voir les doublons

On ne peut pas corriger ce qu'on ne voit pas. Le premier objectif est simple : faites en sorte que chaque tentative d'envoi laisse une trace permettant de suivre du trigger au fournisseur.

Commencez par trouver chaque endroit où votre appli peut envoyer un email. Beaucoup d'équipes ont plus d'un chemin : un controller qui envoie directement, un handler de webhook qui envoie « au cas où », et un job d'arrière-plan qui envoie aussi. Ajoutez une ligne de log claire juste avant l'appel au fournisseur (le moment où vous demandez l'envoi), et rendez-la cohérente sur tous les points d'appel.

Que logger à chaque tentative d'envoi

Restez ennuyeux et cohérent. Un petit ensemble de champs est mieux qu'un long message que personne ne lit.

  • Un correlation ID qui suit la requête ou le job de bout en bout
  • Source du trigger (web_request, webhook, cron, background_job, manual_admin)
  • Événement métier (password_reset, receipt, invite, email_change)
  • Destinataire et nom du template (ou type de message)
  • La clé de déduplication prévue (même si vous ne l'appliquez pas encore)

Avec cela en place, quand un utilisateur dit « J'ai reçu deux emails », vous pouvez chercher dans les logs par destinataire et événement, puis grouper par correlation ID et clé de déduplication. Les duplications apparaissent souvent comme deux triggers différents en quelques secondes.

Webhooks : traitez les redeliveries comme normales

Harden your AI-generated app
We catch risky patterns around email flows, secrets, and injection-prone queries.

La plupart des systèmes de webhook réessaient par conception. Si votre handler n'est pas idempotent, les retries deviennent des envois d'emails en double même quand tout fonctionne « comme prévu ». La solution est de supposer que chaque webhook peut être livré plusieurs fois.

D'abord, assurez-vous de ne pas dupliquer les webhooks avant même que la requête n'atteigne votre code. Il est surprenant de trouver deux abonnements pointant vers le même endpoint (un ancien oublié, ou du staging pointant sur la production). Les payloads semblent valides ; le seul indice est le même événement apparaissant deux fois.

Ensuite, comprenez quand le fournisseur réessaie. Beaucoup redelivrent sur timeout et erreurs 5xx, et certains réessaient sur certains 4xx. Si votre handler fait du travail lent (envoi d'email, appels à d'autres services, requêtes lourdes) avant de répondre, vous augmentez les timeouts et les retries.

Un schéma plus sûr est : enregistrer d'abord, répondre ensuite, traiter après. Retournez un succès seulement après que les données importantes sont sauvegardées de façon durable (généralement en base), ainsi un retry verra l'événement déjà présent.

Checklist à haut signal :

  • Confirmez qu'il n'y a qu'un seul abonnement actif par type d'événement et par environnement.
  • Loggez l'ID d'événement du webhook (fourni par le provider) avec votre request ID.
  • Stockez l'ID d'événement avec une contrainte d'unicité et un statut processed/unprocessed.
  • Répondez 2xx après que l'événement est enregistré, pas après l'envoi de l'email.
  • Si l'enregistrement échoue, renvoyez une erreur pour que le retry soit utile, pas dangereux.

Jobs d'arrière-plan : empêcher les double enqueue et double run

Les jobs d'arrière-plan sont une source fréquente de duplications parce que la plupart des queues sont conçues pour une livraison au moins une fois. Un job peut s'exécuter deux fois et le système considère cela acceptable. Votre code doit être sûr si le même job réapparaît.

Un job peut s'exécuter deux fois pour des raisons ordinaires : un worker plante après l'envoi mais avant d'acquitter la queue, le job timeout, ou un visibility timeout expire et la queue redonne la même charge à un autre worker. Si l'envoi d'email est au milieu, l'utilisateur recevra deux messages.

D'abord, réduisez les double enqueue. Un bug classique est d'enqueueer à l'intérieur d'une transaction DB puis de faire un rollback, ou d'enqueueer à deux endroits (un handler API et un callback de modèle). Préférez l'enqueue après commit afin que le record « l'événement est arrivé » et le job « envoyer l'email » ne puissent pas diverger.

Ensuite, rendez le job sûr à exécuter deux fois. Le worker doit vérifier un garde « avons-nous déjà envoyé ceci ? » avant d'appeler le fournisseur d'email.

Gardes pratiques qui fonctionnent bien :

  • Utiliser une clé de job unique pour que la queue refuse les duplications pour le même événement métier.
  • Écrire une ligne « déjà enqueued » indexée par l'événement et n'enqueueer que si l'insertion réussit.
  • Dans le worker, réserver atomiquement l'envoi (ou acquérir un lock) avant d'envoyer.
  • Conserver des retries, mais les plafonner, et logger quand un retry survient après une acceptation par le fournisseur.

Si votre seule protection est « on retry en cas d'échec », vous continuerez à voir des duplications quand l'échec se produit après que l'email a réellement été envoyé.

Ajouter des clés de déduplication (idempotence) au niveau de l'événement métier

Pour arrêter les duplications définitivement, ne dédupliquez pas au niveau de l'appel API d'envoi. Dédupliquez au niveau de l'événement métier : ce qui s'est passé dans votre app et mérite exactement un message.

Commencez par définir ce que « le même email » signifie pour votre produit. Une définition pratique est souvent : même destinataire, même événement métier, et même template (ou type d'email). « Réinitialisation demandée » et « réinitialisation réussie » ne sont pas le même événement, même si dans la boîte de réception ils se ressemblent.

Une clé de déduplication doit être stable et prédictible pour que tous les chemins de code calculent la même valeur :

  • password_reset_requested:{user_id}:{reset_token_id}
  • order_receipt:{order_id}:{email_type}
  • invite_sent:{workspace_id}:{invitee_email}

Le détail le plus important : stockez la clé avant d'envoyer.

Créez un enregistrement email_deliveries (ou similaire) avec une contrainte d'unicité sur dedupe_key. Si l'insertion réussit, vous prenez la responsabilité de l'envoi. Si elle entre en conflit, quelqu'un d'autre l'a déjà géré.

En cas de conflit, choisissez le comportement qui convient :

  • Ignorer l'envoi et logguer « duplicate suppressed ».
  • Mettre à jour un champ last_attempt_at si vous voulez de la visibilité.
  • Retourner un succès à l'appelant en réutilisant l'enregistrement existant.

Décidez aussi de la fenêtre de déduplication. Certains emails doivent être uniques pour toujours (un reçu). D'autres peuvent autoriser des répétitions après un certain temps (un rappel quotidien). Pour les emails répétables, intégrez le temps dans la clé (par exemple reminder:{user_id}:2026-01-20) ou expirez les clés anciennes.

Un exemple réaliste : deux réinitialisations de mot de passe, un seul utilisateur

Clean up scattered send code
We find copy-pasted send logic and consolidate it into one reliable path.

Les envois d'emails en double paraissent souvent inoffensifs en test, puis apparaissent en production quand les utilisateurs cliquent vite et que les réseaux sont instables.

Sara oublie son mot de passe. Elle ouvre la page de reset et clique « Envoyer le lien de réinitialisation ». La page paraît lente, elle clique de nouveau.

Une timeline réaliste menant à deux emails :

  • 10:02:11 La première requête crée un token de reset et enfile SendPasswordResetEmail.
  • 10:02:12 Sara clique encore. Une seconde requête enfile le même job (ou déclenche un autre chemin qui l'enfile).
  • 10:02:20 Le runner de jobs prend le premier job et appelle le fournisseur d'email.
  • 10:02:22 L'appel fournisseur timeout et votre job retry.
  • 10:02:23 Le second job s'exécute aussi. Vous avez maintenant chevauchement plus reprise.

Dans les logs, cela peut ressembler à « nous n'avons envoyé qu'une fois » côté app, tandis que le fournisseur montre deux acceptations, ou une acceptation plus un retry qui a aussi réussi.

La correction est de dédupliquer au niveau de l'événement métier, pas au niveau de l'ID du job. Pour la réinitialisation, une clé solide est user_id + reset_token (ou reset_token seul s'il est unique).

Quand le code d'envoi s'exécute, il vérifie d'abord « avons-nous déjà envoyé pour cette clé ? » Si oui, il saute l'appel au fournisseur et enregistre un log clair comme « ignored duplicate attempt », incluant la clé de déduplication et la source du trigger.

Cela transforme le second clic et le retry en no-ops sûrs, tout en gardant une trace d'audit pour l'incident suivant.

Erreurs fréquentes qui font revenir les duplications

Les duplications survivent souvent au premier correctif parce que le patch traite le symptôme, pas le trigger. Tout a l'air correct en test, puis le prochain pic de trafic ou la prochaine reprise du fournisseur produit deux (ou cinq) messages.

Un piège est de compter sur les outils de suppression du fournisseur d'email et considérer le problème réglé. La suppression peut cacher ce que voient les utilisateurs, mais votre app enverra toujours plusieurs requêtes d'envoi. Ça complique aussi le débogage car vous verrez des entrées « send attempted » répétées.

Les clés de déduplication posent aussi problème. Si la clé est trop large (comme user_id + template), vous pouvez bloquer des messages légitimes (deux reçus différents). Si la clé est trop fine (comme un UUID aléatoire par requête), elle ne correspond jamais aux duplications, donc les retries renvoient encore.

Les conditions de course sont l'assassin silencieux. Si vous écrivez l'enregistrement de déduplication après l'envoi, deux workers peuvent tous deux passer le contrôle « pas encore envoyé », envoyer chacun un message, puis écrire tous deux le succès. Réservez la clé d'abord (insertion atomique), puis envoyez.

Problèmes qui tendent à réintroduire des duplications :

  • Un webhook acquitte le succès avant que l'état de l'événement ne soit persisté.
  • La redelivery du webhook est traitée comme une erreur plutôt que comme comportement normal.
  • Le même job peut être enfilé deux fois sans garde d'unicité.
  • Un seul trigger est corrigé, mais un second chemin (action admin, cron, import) envoie encore.

Vérifications rapides avant le déploiement

Diagnose one real incident
Get a clear timeline from user action to provider accept, with the real root cause.

Avant de déployer, choisissez un type d'email qui a été dupliqué (réinitialisation, reçu, invitation) et confirmez que vous pouvez le suivre de bout en bout. Si vous ne pouvez pas tracer un message unique depuis le premier trigger jusqu'à l'appel au fournisseur, vous faites encore des suppositions.

Une règle pratique : chaque email doit avoir une identité d'événement métier unique, et chaque système qui le manipule doit traiter les répétitions comme normales.

Checklist pré-déploiement (rapide, à fort signal)

En staging, avec des retries similaires à la production :

  • Les logs montrent une chaîne claire : trigger reçu, handler accepté, décision de déduplication, job enfile (si besoin), tentative d'envoi, réponse du fournisseur enregistrée.
  • Les handlers de webhook stockent l'ID d'événement du provider (ou le vôtre) et ignorent les redeliveries sans générer d'erreur.
  • Les jobs d'arrière-plan peuvent être retriés sans effets de bord : si le même job s'exécute deux fois, le handler sort tôt au lieu d'envoyer deux fois.
  • Une clé de déduplication unique est écrite dans un stockage durable avant l'appel d'envoi, pas après.
  • Vous pouvez voir rapidement les pics (même un graphique basique) pour « emails envoyés par minute » et « déduplications appliquées ».

Un test rapide « cassez-le volontairement »

Déclenchez deux fois le même événement (ou rejouez le même payload de webhook). Puis forcez une erreur : tuez le worker au milieu du job, ou simulez un timeout du fournisseur d'email.

Le résultat attendu est ennuyeux : au plus un email livré, et des logs qui expliquent clairement pourquoi les duplications ont été bloquées.

Étapes suivantes : rendez-le ennuyeux, puis maintenez-le ainsi

Quand les clés de déduplication stoppent les duplications dans vos logs, déployez le changement comme toute mise en production. Si vous êtes nerveux, mettez la vérification de déduplication derrière un feature flag et activez-la progressivement. Commencez par un type d'email (les réinitialisations sont une bonne cible), puis étendez une fois que les métriques se stabilisent.

Puis nettoyez les dégâts que les duplications ont déjà créés. Si vous stockez des enregistrements « email envoyé », vous voudrez peut-être marquer les extras comme duplicata afin que les vues support et les rapports cessent d'être trompeurs. L'historique parfait importe moins que la cohérence future entre ce que vos métriques disent et ce que les utilisateurs ont réellement vécu.

Ajoutez un petit test automatisé qui prouve que le handler est idempotent : appelez deux fois le même événement avec la même clé de déduplication et assert que seul un envoi est enregistré. Ce test unique empêche souvent qu'une refactorisation ultérieure supprime la garde.

Quelques habitudes pour garder la situation ennuyeuse :

  • Loggez la clé de déduplication à chaque tentative d'envoi et à chaque skip.
  • Alarmez sur des pics soudains de « skipped as duplicate » (cela peut signaler une boucle de triggers).
  • Passez en revue les nouveaux handlers de webhook et jobs d'arrière-plan pour l'idempotence avant de merger.
  • Gardez le stockage des clés de déduplication suffisamment durable pour survivre aux redémarrages et retries.

Si vous avez hérité d'une base générée par IA où les envois d'email sont éparpillés dans des handlers copiés-collés et des retries, un audit ciblé peut économiser des jours de conjectures. FixMyMess (fixmymess.ai) se spécialise dans le diagnostic et la réparation d'apps générées par IA, y compris l'ajout d'idempotence au niveau de l'événement métier afin que webhooks et retries cessent de produire des emails en double.

Questions Fréquentes

What do you mean by “duplicate emails” in production?

Considérez-le comme un même événement métier ayant produit deux messages, pas seulement « deux appels SMTP ». Commencez par nommer l'événement (par exemple password_reset_requested ou receipt_paid) puis faites en sorte que toutes les couches traitent les répétitions comme normales et sûres.

What are the most common reasons users get the same email twice?

Le plus souvent, c’est votre application qui demande l’envoi plusieurs fois : double-clics ou reprises côté client, redeliveries de webhooks, reprises de jobs en arrière-plan, ou deux chemins de code différents qui envoient le même template. Les fournisseurs d’email envoient généralement ce que vous leur demandez.

How do I debug one duplicate without getting lost in the whole codebase?

Choisissez un incident réel et construisez une timeline. Récupérez votre ID interne d'envoi d'email, l'ID du message fourni par le fournisseur, les horodatages, les IDs des entités métier (comme order_id ou reset_token_id), et les IDs de requête/job, puis décrivez précisément le chemin ayant mené à chaque acceptation par le fournisseur.

What should I log so duplicates are easy to spot later?

Ajoutez une ligne de log cohérente juste avant chaque appel au fournisseur, avec un correlation ID, la source du trigger, le nom de l'événement métier, le destinataire, le template/type, et la clé de déduplication (même si vous ne l'appliquez pas encore). Ainsi, il devient évident quand deux triggers différents se sont déclenchés en quelques secondes.

How do I stop webhook redeliveries from causing duplicate emails?

Supposez que chaque webhook peut arriver plusieurs fois. Enregistrez l'ID d'événement du webhook dans un stockage durable avec une contrainte d'unicité, répondez 2xx après l'avoir sauvegardé, et traitez ensuite le travail. Ainsi, une redelivery devient une no-op sans danger au lieu d'un nouvel envoi.

How do I prevent background jobs from sending the same email twice?

La plupart des queues garantissent "at-least-once", donc un job peut s'exécuter deux fois après un timeout, un crash ou l'expiration d'une visibilité. Rendez le job idempotent : réservez l'envoi en écrivant un enregistrement de déduplication unique avant d'appeler le fournisseur, et quittez tôt si la réservation existe déjà.

What’s a good dedupe (idempotency) key for email sends?

Créez une clé stable basée sur l'événement métier, par exemple order_receipt:{order_id}:{email_type} ou password_reset_requested:{user_id}:{reset_token_id}. Stockez-la avant l'envoi avec une contrainte d'unicité ; si l'insertion entre en conflit, ignorez l'appel au fournisseur et loggez « duplicate suppressed ».

Why is “check if sent, then send” still producing duplicates?

Si vous écrivez l'enregistrement « envoyé » après l'appel au fournisseur, deux workers peuvent tous deux passer le contrôle « pas encore envoyé » et envoyer chacun un message. La correction standard est d'abord réserver atomiquement (insert unique ou lock), puis envoyer, puis marquer comme envoyé.

How can I test the fix before deploying to production?

Un test simple « cassez-le volontairement » fonctionne bien : déclenchez deux fois le même événement, rejouez le même payload de webhook, et forcez une erreur (tuez le worker, simulez un timeout fournisseur). Résultat attendu : au maximum un email livré, et des logs clairs expliquant pourquoi la seconde tentative a été bloquée par la déduplication.

Can FixMyMess help if this is happening in an AI-generated app?

Si la logique d'envoi est éparpillée dans des handlers copiés-collés, des webhooks et des jobs, les duplications reviendront après chaque correctif. FixMyMess aide à diagnostiquer les codebases générées par IA, consolider les chemins d'envoi, ajouter des clés de déduplication au niveau de l'événement métier, et durcir les reprises pour que les utilisateurs reçoivent toujours un seul message.