Envíos duplicados de correo: encontrar disparadores dobles y añadir claves de deduplicación
Los envíos duplicados de correo en producción suelen venir de disparadores dobles, reintentos o solapamiento de jobs. Aprende a rastrear la causa y añadir claves de deduplicación para enviar un solo correo.

Qué significa realmente “correos duplicados” en producción
Los usuarios no informan “envíos duplicados de correo”. Informan la sensación: “Recibí dos correos para restablecer la contraseña”, “Mi recibo llegó dos veces”, o “Tu app no para de enviarme correos”. A veces las copias son idénticas. Otras veces difieren por segundos, por la línea de asunto o por un píxel de seguimiento, lo que complica demostrar qué pasó.
Los duplicados dañan la confianza. Si un recibo aparece dos veces, la gente teme haber sido cobrada dos veces. Si un correo de inicio de sesión o restablecimiento de contraseña se duplica, la gente teme que alguien esté manipulando su cuenta. Internamente, los duplicados crean tickets de soporte, alertas ruidosas y métricas engañosas. Con el tiempo, también pueden perjudicar la entregabilidad porque los proveedores de bandeja notan ráfagas y contenido repetido.
Los duplicados son delicados porque “enviar un correo” rara vez es un único paso. El mismo evento de negocio puede ramificarse por varios sistemas: un webhook se dispara, un job en background reintenta, un worker de cola se reinicia, o un usuario hace clic dos veces y tu frontend envía dos solicitudes. Cada pieza puede estar “funcionando correctamente”, pero en conjunto pueden provocar el mismo envío más de una vez.
El objetivo es simple y comprobable: un evento de negocio = un correo.
Un evento de negocio es lo que te importa, como “se solicitó restablecimiento de contraseña para el usuario 123” o “la factura 987 fue pagada”. Una vez defines ese evento, protégelo con una identidad única para que cada capa pueda decir: “Esto ya se envió”.
Una forma práctica de plantearlo:
- Un duplicado no es “dos llamadas SMTP”. Es “el mismo evento produjo dos mensajes”.
- Arreglarlo no es solo reducir reintentos. Es hacer que cada trigger sea seguro de ejecutar dos veces.
- El mejor resultado es aburrido: los reintentos, webhooks y reinicios ocurren, y los usuarios siguen recibiendo un único correo.
Causas comunes: disparadores dobles, reintentos y solapamiento de jobs
La mayoría de los duplicados no se deben a “el servicio de correo se volvió loco”. Ocurren porque tu app pide el mismo envío más de una vez, a menudo desde dos lugares que no saben uno del otro.
Un patrón común comienza en el borde. Un usuario hace doble clic, un formulario se envía dos veces, o el frontend reintenta porque no obtuvo respuesta. Si el backend trata cada petición como un nuevo evento de negocio, has creado dos envíos.
Los webhooks son otra fuente frecuente. Muchos proveedores entregan el mismo webhook más de una vez a propósito, especialmente si tu endpoint es lento o devuelve un estado no 2xx. Si procesas cada entrega como única, puedes volver a activar la misma acción de “enviar correo”.
Los jobs en background añaden su propio tipo de duplicación. Un job puede encolarse dos veces por carreras (dos servidores manejando la misma petición), replays (la cola reinyecta un mensaje), o porque un worker reintenta tras un timeout. El peor caso es cuando el worker hace timeout después de que el proveedor aceptó el envío, y luego reintenta y envía de nuevo.
Cuando rastreas un único duplicado, normalmente encuentras uno de estos:
- El mismo evento se creó dos veces (doble envío, reintento del cliente).
- Un webhook se volvió a entregar y se trató como nuevo.
- Un job corrió dos veces (o dos jobs corrieron en paralelo).
- Un reintento ocurrió después de que el correo ya salió de tu sistema.
- Dos rutas de código envían la misma plantilla (por ejemplo, una en un controlador y otra en un callback del modelo).
Ese último caso es común en prototipos rápidos: la lógica de envío se copia en varios handlers y ambos siguen activos.
Empieza con un incidente y construye una línea de tiempo
No empieces por escanear todo el código. Empieza con un correo real que un usuario recibió dos veces. Escoge una plantilla concreta (como “Restablecer contraseña” o “Recibo”) y una ventana de tiempo estrecha (5 a 15 minutos) para no mezclar eventos distintos.
Recoge todos los identificadores que puedas para ese incidente, de modo que puedas señalar los intentos de envío exactos, no solo al usuario que se quejó.
Para cada copia del correo, recopila:
- Tu ID interno del registro de correo (o el ID de fila en la base de datos)
- El ID del mensaje / response ID del proveedor de correo
- Timestamps (creado, encolado, enviado, proveedor aceptó)
- Los IDs de entidad del negocio (user_id, order_id, invoice_id, reset_token_id)
- Cualquier request ID o job ID ligado al envío
Luego escribe una línea de tiempo en lenguaje claro desde el trigger hasta la aceptación por parte del proveedor. Los logs ayudan, pero escribirlo obliga a claridad.
Una línea de tiempo útil responde cuatro preguntas: qué evento ocurrió, qué ruta de código lo manejó, qué jobs fueron encolados y cuántas veces el proveedor aceptó un mensaje.
Ejemplo: un usuario hace clic en “Reset password” a las 10:03:12. Tu API crea reset_token_id=7781 y encola un job a las 10:03:13. A las 10:03:14, el cliente reintenta (o un webhook se vuelve a entregar), creando un segundo token y un segundo job. Ambos jobs corren y el proveedor acepta dos mensajes a las 10:03:20 y 10:03:22.
Instrumenta la ruta de envío para poder ver duplicados
No puedes arreglar lo que no ves. El primer objetivo es simple: haz que cada intento de envío deje una traza que puedas seguir desde el trigger hasta el proveedor.
Empieza por localizar cada lugar donde tu app puede enviar correo. Muchos equipos tienen más de una ruta: un controlador que envía directamente, un handler de webhook que envía “por si acaso”, y un job en background que también envía. Añade una línea de log clara justo antes de la llamada al proveedor (el momento en que pides enviar un correo) y hazla consistente en todos los puntos de llamada.
Qué registrar en cada intento de envío
Manténlo simple y consistente. Un pequeño conjunto de campos vence a un mensaje largo que nadie lee.
- Un correlation ID que siga la petición o el job de extremo a extremo
- Fuente del trigger (web_request, webhook, cron, background_job, manual_admin)
- Evento de negocio (password_reset, receipt, invite, email_change)
- Destinatario y nombre de plantilla (o tipo de mensaje)
- La clave de dedupe que planeas usar (incluso si aún no la aplicas)
Con esto en su lugar, cuando un usuario dice “Recibí dos correos”, puedes buscar en los logs por el destinatario y el evento, luego agrupar por correlation ID y clave de dedupe. Los duplicados suelen aparecer como dos triggers distintos activándose en segundos.
Webhooks: trata las redeliveries como normales
La mayoría de los sistemas de webhook reintentan por diseño. Si tu handler no es idempotente, los reintentos se convierten en envíos duplicados incluso cuando todo está “funcionando según lo diseñado”. La solución es asumir que cualquier webhook puede entregarse más de una vez.
Primero, asegúrate de no estar duplicando webhooks antes de que la petición llegue a tu código. Es sorprendentemente común tener dos suscripciones apuntando al mismo endpoint (una vieja que alguien olvidó, o staging apuntando a producción). Los payloads parecen válidos; la única pista es el mismo evento apareciendo dos veces.
Luego, entiende cuándo reintenta el proveedor. Muchos reintentan por timeouts y errores 5xx, y algunos incluso reintentan por ciertos 4xx. Si tu handler hace trabajo lento (enviar el correo, llamar a otros servicios, consultas pesadas) antes de responder, aumentas timeouts y reintentos.
Un patrón más seguro es: grabar primero, responder segundo, procesar tercero. Devuelve éxito solo después de que los datos importantes se hayan guardado de forma duradera (normalmente en tu base de datos), de modo que un reintento pueda ver que el evento ya existe.
Una lista de control de alta señal:
- Confirma que solo hay una suscripción activa por tipo de evento y por entorno.
- Registra el event ID del webhook (proporcionado por el proveedor) junto con tu request ID.
- Almacena el event ID con una restricción única y un estado processed/unprocessed.
- Devuelve 2xx después de que el evento esté registrado, no después de enviar el correo.
- Si el registro falla, devuelve un error para que el reintento sea útil, no dañino.
Jobs en background: evita doble encolado y doble ejecución
Los jobs en background son una fuente común de duplicados porque la mayoría de las colas están diseñadas para entrega "al menos una vez". Un job puede ejecutarse dos veces y el sistema aún lo considera aceptable. Tu código debe ser seguro si el mismo job aparece otra vez.
Un job puede ejecutarse dos veces por razones normales: un worker choca después de enviar pero antes de reconocer la cola, el job hace timeout, o el timeout de visibilidad expira y la cola entrega la misma carga a otro worker. Si el envío del correo queda en el medio, el usuario recibe dos mensajes.
Primero, reduce el doble encolado. Un bug clásico es encolar dentro de una transacción de base de datos y luego hacer rollback, o encolar en dos lugares (un handler API y un callback del modelo). Prefiere encolar después del commit para que el registro de “el evento ocurrió” y el job de “enviar el correo” no se desincronicen.
Luego haz que el job sea seguro para ejecutarse dos veces. El worker debe comprobar un guard “¿ya enviamos esto?” antes de llamar al proveedor de correo.
Guardas prácticas que funcionan bien:
- Usa una clave única para el job para que la cola rechace duplicados del mismo evento de negocio.
- Escribe una fila “ya encolado” indexada por el evento y encola solo si la inserción tiene éxito.
- En el worker, reserva el envío de forma atómica (o adquiere un lock) antes de enviar.
- Mantén reintentos, pero pon un tope y registra cuando un reintento ocurre después de que el proveedor ya aceptó.
Si tu única protección es “reintentamos en caso de fallo”, seguirás viendo duplicados cuando el fallo ocurra después de que el correo ya fue enviado.
Añade claves de dedupe (idempotencia) a nivel de evento de negocio
Para detener los duplicados de forma definitiva, no hagas dedupe al nivel de la "llamada al API de envío". Hazlo al nivel del evento de negocio: lo que ocurrió en tu app y que merece exactamente un mensaje.
Empieza por definir qué significa “el mismo correo” para tu producto. Una definición práctica suele ser: mismo destinatario, mismo evento de negocio y misma plantilla (o tipo de correo). “Solicitud de restablecimiento de contraseña” y “restablecimiento de contraseña completado” no son el mismo evento, aunque puedan parecer similares en la bandeja.
Una clave de dedupe debe ser estable y predecible para que cada ruta de código calcule el mismo valor:
password_reset_requested:{user_id}:{reset_token_id}order_receipt:{order_id}:{email_type}invite_sent:{workspace_id}:{invitee_email}
El detalle más importante: almacena la clave antes de enviar.
Crea un registro email_deliveries (o similar) con una restricción única sobre dedupe_key. Si la inserción tiene éxito, te adjudicas el envío. Si hay conflicto, otro ya lo manejó.
En caso de conflicto, elige el comportamiento que encaje:
- Omitir el envío y registrar “duplicate suppressed”.
- Actualizar un campo
last_attempt_atsi quieres visibilidad. - Devolver éxito al llamador usando el registro existente.
También decide la ventana de dedupe. Algunos correos deben enviarse una vez por evento para siempre (un recibo). Otros permiten repeticiones tras un tiempo (un recordatorio diario). Para correos repetibles, incorpora tiempo en la clave (por ejemplo reminder:{user_id}:2026-01-20) o expira claves antiguas.
Un ejemplo realista: dos restablecimientos de contraseña, un usuario
Los envíos duplicados suelen parecer inofensivos en pruebas y luego aparecen en producción cuando los usuarios hacen clic rápido y las redes fallan.
Sara olvida su contraseña. Abre la página de restablecimiento y hace clic en “Enviar enlace”. La página se siente lenta, así que hace clic otra vez.
Una línea de tiempo realista que conduce a dos correos:
- 10:02:11 La primera petición crea un token de restablecimiento y encola
SendPasswordResetEmail. - 10:02:12 Sara hace clic de nuevo. Una segunda petición encola el mismo job (o activa otra ruta que lo encola).
- 10:02:20 El runner de jobs procesa el primer job y llama al proveedor de correo.
- 10:02:22 La llamada al proveedor hace timeout y tu job reintenta.
- 10:02:23 El segundo job corre también. Ahora hay solapamiento más un reintento.
En los logs, esto puede parecer “solo enviamos una vez” desde el lado de la app, mientras que el proveedor muestra dos aceptaciones, o una aceptación más un reintento que también tuvo éxito.
La solución es dedupear a nivel de evento de negocio, no a nivel de ID de job. Para restablecimiento, una clave sólida es user_id + reset_token (o reset_token solo si es único).
Cuando el código de envío corre, primero comprueba “¿ya enviamos para esta clave?” Si sí, omite la llamada al proveedor y registra una entrada clara como “ignored duplicate attempt”, incluyendo la clave de dedupe y la fuente del trigger.
Eso convierte el segundo clic y el reintento en no-ops seguros, mientras mantiene una pista de auditoría para el siguiente incidente.
Errores comunes que hacen que los duplicados vuelvan
Los duplicados suelen sobrevivir al primer arreglo porque el parche trata el síntoma, no el trigger. Todo parece bien en pruebas, pero el siguiente pico de tráfico o reintento del proveedor produce dos (o cinco) mensajes.
Una trampa es confiar en las herramientas de supresión del proveedor de correo y darlo por resuelto. La supresión puede ocultar lo que ven los usuarios, pero tu app sigue disparando múltiples solicitudes de envío. Eso también complica la depuración porque seguirás viendo entradas “send attempted” repetidas.
Las claves de dedupe son otro problema frecuente. Si la clave es demasiado amplia (como user_id + template), puedes bloquear mensajes legítimos (dos recibos distintos). Si la clave es demasiado estrecha (como un UUID aleatorio por petición), nunca coincide con duplicados, así que los reintentos siguen enviando otra vez.
Las condiciones de carrera son el asesino silencioso. Si escribes el registro de dedupe después de enviar, dos workers pueden pasar la comprobación de “no enviado aún”, ambos enviar y luego ambos escribir éxito. Reserva la clave primero (una inserción atómica), luego envía.
Problemas que tienden a reintroducir duplicados más tarde:
- Un webhook reconoce éxito antes de que el estado del evento esté persistido.
- El reenvío de webhooks se trata como un error en lugar de comportamiento normal.
- El mismo job puede encolarse dos veces sin guardia de unicidad.
- Solo se arregla un trigger, pero otra ruta (acción de admin, cron, importación) sigue enviando.
Comprobaciones rápidas antes de desplegar la solución
Antes de desplegar, escoge un tipo de correo que haya estado duplicándose (restablecimientos, recibos, invitaciones) y confirma que puedes seguirlo de extremo a extremo. Si no puedes trazar un solo mensaje desde el primer trigger hasta la llamada al proveedor, aún estás adivinando.
Una regla práctica: cada correo debería tener una identidad de evento de negocio única, y cada sistema que lo toque debería tratar repeticiones como normales.
Checklist pre-despliegue (rápido, de alta señal)
En staging, con reintentos tipo producción activados:
- Los logs muestran una cadena clara: trigger recibido, handler aceptó, decisión de dedupe, job encolado (si lo hay), intento de envío, respuesta del proveedor registrada.
- Los handlers de webhook almacenan el ID del evento del proveedor (o el tuyo) e ignoran redeliveries sin lanzar errores.
- Los jobs en background pueden reintentarse sin efectos secundarios: si el mismo job corre dos veces, el handler sale temprano en lugar de enviar dos veces.
- Una clave de dedupe única se escribe en almacenamiento duradero antes de la llamada de envío, no después.
- Puedes ver picos rápidamente (incluso con un gráfico básico) de “correos enviados por minuto” y “dedupe hits”.
Una prueba rápida de “romperlo a propósito”
Dispara el mismo evento dos veces (o reenvía el mismo payload de webhook). Luego fuerza un fallo: mata el worker a mitad de job o simula un timeout del proveedor.
El resultado esperado es aburrido: como mucho un correo entregado, y logs que expliquen claramente por qué se bloquearon duplicados.
Siguientes pasos: haz que sea aburrido y manténlo así
Después de que las claves de dedupe detengan los duplicados en tus logs, despliega el cambio como cualquier actualización de producción. Si tienes dudas, coloca la comprobación de dedupe detrás de un feature flag y actívala gradualmente. Empieza con un tipo de correo (los restablecimientos de contraseña son un buen primer objetivo) y expande una vez que las métricas se estabilicen.
Luego limpia el desorden que los duplicados ya crearon. Si guardas registros de “correo enviado”, quizá quieras marcar los extras como duplicados para que las vistas de soporte y los informes dejen de ser incorrectos. Un historial perfecto importa menos que que los recuentos futuros coincidan con lo que los usuarios realmente vivieron.
Añade una prueba automática pequeña que demuestre que el handler es idempotente: llama al mismo evento dos veces con la misma clave de dedupe y afirma que solo se registra un envío. Esa prueba suele evitar que un refactor posterior elimine la protección.
Unos hábitos mantienen las cosas aburridas con el tiempo:
- Registra la clave de dedupe en cada intento de envío y en cada omisión.
- Alerta por picos repentinos en “skipped as duplicate” (puede señalar un bucle de triggers).
- Revisa los nuevos handlers de webhook y jobs en background por idempotencia antes de mergear.
- Mantén la tienda de dedupe lo bastante duradera para sobrevivir reinicios y reintentos.
Si heredaste una base de código generada por IA donde los envíos de correo están repartidos entre handlers copiados y reintentos, una auditoría enfocada puede ahorrar días de conjeturas. FixMyMess (fixmymess.ai) se especializa en diagnosticar y reparar apps generadas por IA, incluyendo añadir idempotencia por evento de negocio para que webhooks y reintentos dejen de producir correos duplicados.
Preguntas Frecuentes
What do you mean by “duplicate emails” in production?
Trátalo como un evento de negocio produjo dos mensajes, no solo “dos llamadas SMTP”. Empieza por nombrar el evento (como password_reset_requested o receipt_paid) y luego haz que cada capa trate las repeticiones como normales y seguras.
What are the most common reasons users get the same email twice?
Casi siempre ocurre porque tu app solicita el mismo envío dos veces: doble clics o reintentos del cliente, redeliveries de webhooks, reintentos de jobs en background, o dos rutas de código distintas enviando la misma plantilla. Los proveedores de correo normalmente solo envían lo que les pides enviar.
How do I debug one duplicate without getting lost in the whole codebase?
Elige un incidente real y crea una línea de tiempo. Recoge tu ID interno del registro de correo, el ID del mensaje del proveedor, timestamps, IDs de entidades del negocio (como order_id o reset_token_id) y los IDs de petición/job, luego escribe exactamente el camino que llevó a cada aceptación por parte del proveedor.
What should I log so duplicates are easy to spot later?
Registra una línea consistente justo antes de cada llamada al proveedor con un correlation ID, la fuente del trigger, el nombre del evento de negocio, destinatario, plantilla/tipo y la clave de dedupe (incluso si aún no la aplicas). Así se hace evidente cuando dos triggers distintos se activaron en segundos.
How do I stop webhook redeliveries from causing duplicate emails?
Asume que cualquier webhook puede llegar más de una vez. Guarda el ID del evento del webhook en almacenamiento duradero con una restricción única, devuelve 2xx después de que esté guardado y procesa el trabajo después. Así una redeliveria será un no-op en lugar de otro envío.
How do I prevent background jobs from sending the same email twice?
Como la mayoría de colas garantizan entrega "al menos una vez", un job puede ejecutarse dos veces por timeouts, crashes o expiración de visibilidad. Haz el job idempotente: reserva el envío usando un registro único de dedupe antes de llamar al proveedor, y sal temprano si ya está reservado o enviado.
What’s a good dedupe (idempotency) key for email sends?
Crea una clave estable basada en el evento de negocio, por ejemplo order_receipt:{order_id}:{email_type} o password_reset_requested:{user_id}:{reset_token_id}. Guárdala antes de enviar con una restricción única; si la inserción genera conflicto, omite la llamada al proveedor y registra “duplicate suppressed”.
Why is “check if sent, then send” still producing duplicates?
Si escribes el registro de “enviado” después de llamar al proveedor, dos workers pueden pasar la comprobación de “no enviado aún” y ambos enviar. La solución es reserva atómica primero (insert único o lock), luego enviar y finalmente marcar como enviado.
How can I test the fix before deploying to production?
Una prueba sencilla es forzar la rotura: dispara el mismo evento dos veces, reenvía el mismo payload de webhook y provoca un fallo como crash del worker o timeout del proveedor. El resultado esperado es aburrido: como máximo un correo entregado y logs que expliquen por qué se bloquearon duplicados.
Can FixMyMess help if this is happening in an AI-generated app?
Si la lógica de envío está repartida entre handlers copiados y jobs, los duplicados volverán tras cada parche. FixMyMess ayuda a diagnosticar codebases generados por IA, consolidar rutas de envío, añadir claves de dedupe por evento de negocio y reforzar reintentos para que los usuarios reciban un solo mensaje.