07 de jul. de 2025·7 min de leitura

Envios de e-mail duplicados: encontre gatilhos duplos e adicione chaves de deduplicação

Envios de e-mail duplicados em produção podem vir de gatilhos duplos, retries ou sobreposição de jobs. Aprenda a rastrear a causa e adicionar chaves de dedupe para enviar apenas um e-mail.

Envios de e-mail duplicados: encontre gatilhos duplos e adicione chaves de deduplicação

O que “e-mails duplicados” realmente significa em produção

Usuários não relatam “envios de e-mail duplicados.” Eles relatam a sensação: “Recebi dois e-mails de reset de senha”, “Meu recibo chegou duas vezes” ou “Seu app não para de me spammar.” Às vezes as cópias são idênticas. Outras vezes elas diferem por alguns segundos, pelo assunto ou por um pixel de rastreamento, o que dificulta provar o que aconteceu.

Duplicatas minam a confiança. Se um recibo aparece duas vezes, as pessoas se preocupam que foram cobradas duas vezes. Se um e-mail de login ou reset de senha é duplicado, as pessoas se preocupam que alguém esteja mexendo na conta. Internamente, duplicatas geram tickets de suporte, alertas barulhentos e métricas enganosas. Com o tempo também podem prejudicar a entregabilidade porque provedores notam rajadas e conteúdo repetido.

Duplicatas são complicadas porque “enviar um e-mail” raramente é um único passo. O mesmo evento de negócio pode se espalhar por vários sistemas: um webhook dispara, um job reexecuta, um worker reinicia, ou o usuário clica duas vezes e seu front envia duas requisições. Cada peça pode estar “comportando-se corretamente”, mas juntas podem acionar o mesmo envio mais de uma vez.

O objetivo é simples e testável: um evento de negócio = um e-mail.

Um evento de negócio é aquilo que importa para você, como “reset de senha solicitado para o usuário 123” ou “fatura 987 foi paga.” Depois de definir esse evento, proteja-o com uma identidade única para que cada camada possa dizer: “Isso já foi enviado.”

Uma forma prática de resumir:

  • Uma duplicata não é “duas chamadas SMTP.” É “o mesmo evento produziu duas mensagens.”
  • Corrigir não é só reduzir retries. É tornar cada gatilho seguro para rodar duas vezes.
  • O melhor resultado é entediante: retries, webhooks e reinícios acontecem, e os usuários ainda recebem um único e-mail.

Causas comuns: gatilhos duplicados, retries e sobreposição de jobs

A maioria das duplicatas não vem de “o serviço de e-mail enlouqueceu.” Acontecem porque seu app pediu o mesmo envio mais de uma vez, frequentemente de dois lugares que não sabem um do outro.

Um padrão comum começa na borda. O usuário clica duas vezes, um formulário é enviado duas vezes, ou o frontend reenvia porque não recebeu resposta. Se o backend tratar cada requisição como um novo evento de negócio, você criou dois envios.

Webhooks são outra fonte frequente. Muitos provedores entregam o mesmo webhook mais de uma vez de propósito, especialmente se seu endpoint está lento ou retorna status não-2xx. Se você processar cada entrega como única, pode disparar a mesma ação de “enviar e-mail” novamente.

Jobs em background trazem sua própria duplicação. Um job pode ser enfileirado duas vezes por corridas (dois servidores lidando com a mesma requisição), replays (a fila redriva uma mensagem) ou um worker retryando após timeout. O pior caso é quando o worker time out depois que o provedor aceitou o envio, e então reenvia novamente.

Ao traçar uma duplicata isolada, normalmente você encontra um destes:

  • O mesmo evento foi criado duas vezes (envio duplo, retry do cliente).
  • Um webhook foi reentregue e tratado como novo.
  • Um job rodou duas vezes (ou dois jobs rodaram em paralelo).
  • Um retry ocorreu depois que o e-mail já saiu do seu sistema.
  • Dois caminhos de código enviam o mesmo template (por exemplo, um no controller e outro num callback de model).

Esse último é comum em protótipos rápidos: a lógica de envio é copiada para vários handlers, e ambos continuam ativos.

Comece por um incidente e construa uma linha do tempo

Não comece vasculhando todo o código. Comece com um e-mail real que um usuário recebeu duas vezes. Escolha um template específico (como “Reset de senha” ou “Recibo”) e uma janela de tempo estreita (5 a 15 minutos) para não misturar eventos diferentes.

Colete todo identificador que puder para esse incidente, para que você aponte as tentativas de envio exatas, não só o usuário que reclamou.

Para cada cópia do e-mail, pegue:

  • Seu ID interno de registro de e-mail (ou o ID da linha no banco)
  • O message ID / response ID do provedor de e-mail
  • Timestamps (criado, enfileirado, enviado, aceito pelo provedor)
  • IDs das entidades de negócio (user_id, order_id, invoice_id, reset_token_id)
  • Qualquer request ID ou job ID ligado ao envio

Então escreva uma linha do tempo em linguagem simples do gatilho até a aceitação pelo provedor. Logs ajudam, mas escrever força clareza.

Uma linha do tempo útil responde a quatro perguntas: que evento aconteceu, qual caminho de código o tratou, que jobs foram enfileirados e quantas vezes o provedor aceitou uma mensagem.

Exemplo: um usuário clica em “Reset password” às 10:03:12. Sua API cria reset_token_id=7781 e enfileira um job às 10:03:13. Às 10:03:14, o cliente reenvia (ou um webhook é reentregue), criando um segundo token e um segundo job. Ambos os jobs rodam e o provedor aceita duas mensagens às 10:03:20 e 10:03:22.

Instrumente o caminho de envio para ver duplicatas

Você não consegue corrigir o que não vê. O primeiro objetivo é simples: faça cada tentativa de envio deixar um rastro que permita seguir desde o gatilho até o provedor.

Comece encontrando todos os lugares onde seu app pode enviar e-mail. Muitas equipes têm mais de um caminho: um controller que envia direto, um handler de webhook que envia “por garantia”, e um job em background que também envia. Adicione uma linha clara de log bem antes da chamada ao provedor (o momento em que você pede para enviar), e torne-a consistente em todos os pontos de chamada.

O que logar em cada tentativa de envio

Mantenha chato e consistente. Um pequeno conjunto de campos vence uma mensagem longa que ninguém lê.

  • Um correlation ID que acompanha a request ou job de ponta a ponta
  • Origem do gatilho (web_request, webhook, cron, background_job, manual_admin)
  • Evento de negócio (password_reset, receipt, invite, email_change)
  • Destinatário e nome do template (ou tipo de mensagem)
  • A chave de dedupe que você planeja usar (mesmo que ainda não a esteja aplicando)

Com isso em prática, quando um usuário diz “Recebi dois e-mails”, você pode buscar os logs pelo destinatário e evento, então agrupar por correlation ID e dedupe key. Duplicatas frequentemente aparecem como dois gatilhos diferentes acionando em segundos.

Webhooks: trate redeliveries como normais

Corrigir envios por execução dupla de jobs
Endurecemos jobs em background para que timeouts e retries não spammem usuários.

A maioria dos sistemas de webhook faz retries por design. Se seu handler não for idempotente, retries viram envios duplicados mesmo quando tudo está “funcionando como planejado.” A correção é assumir que todo webhook pode ser entregue mais de uma vez.

Primeiro, verifique se você não está duplicando webhooks antes mesmo da requisição chegar ao seu código. É surpreendentemente comum ter duas subscriptions apontando para o mesmo endpoint (uma antiga esquecida, ou staging apontando para produção). Os payloads parecem válidos; a única pista é o mesmo evento aparecendo duas vezes.

Depois, entenda quando o provedor reenvia. Muitos reenviam em timeouts e erros 5xx, e alguns até reenviam em certos 4xx. Se seu handler fizer trabalho lento (enviar o e-mail, chamar outros serviços, consultas pesadas) antes de responder, você aumenta timeouts e retries.

Um padrão mais seguro é: grave primeiro, responda depois, processe por último. Retorne sucesso somente depois que os dados importantes estiverem salvos de forma durável (geralmente no banco), assim um retry pode ver que o evento já existe.

Uma checklist de alto sinal:

  • Confirme que há apenas uma subscription ativa por tipo de evento e ambiente.
  • Log o event ID do webhook (do provedor) junto ao seu request ID.
  • Armazene o event ID com uma constraint única e um status processed/unprocessed.
  • Responda 2xx depois que o evento estiver registrado, não depois que o e-mail for enviado.
  • Se o registro falhar, retorne erro para que o retry seja útil, não prejudicial.

Jobs em background: prevenir dupla enfileiramento e dupla execução

Jobs em background são fonte comum de duplicatas porque a maioria das filas é construída para entrega pelo menos-uma-vez. Um job pode rodar duas vezes e o sistema ainda considerar isso aceitável. Seu código precisa ser seguro quando o mesmo job reaparece.

Um job pode rodar duas vezes por razões comuns: um worker trava depois de enviar mas antes de reconhecer a fila, o job time outa, ou o tempo de visibilidade expira e a fila entrega o mesmo payload a outro worker. Se o envio de e-mail estiver no meio disso, o usuário recebe duas mensagens.

Primeiro, reduza duplo enfileiramento. Um bug clássico é enfileirar dentro de uma transação de banco e depois dar rollback, ou enfileirar em dois lugares (um handler de API e um callback de model). Prefira enfileirar após o commit para que o registro “o evento aconteceu” e o job “envia o e-mail” não divergam.

Depois torne o job seguro para rodar duas vezes. O worker deve checar um guard de “já enviamos isto?” antes de chamar o provedor.

Guardiões práticos que funcionam bem:

  • Use uma chave única de job para que a fila recuse duplicatas para o mesmo evento de negócio.
  • Grave uma linha “já enfileirado” chaveada pelo evento e enfileire só se a inserção succeed.
  • No worker, reserve atômica mente o envio (ou adquira um lock) antes de enviar.
  • Mantenha retries, mas limite-os, e log quando um retry acontece depois de um accept do provedor.

Se sua única proteção for “repetimos em caso de falha”, você continuará vendo duplicatas quando a falha acontecer depois que o e-mail já foi enviado.

Adicione chaves de dedupe (idempotência) no nível do evento de negócio

Para acabar com duplicatas de vez, não dedupe no nível da chamada à API de envio. Faça dedupe no nível do evento de negócio: o que aconteceu na sua aplicação que merece exatamente uma mensagem.

Comece definindo o que “o mesmo e-mail” significa para seu produto. Uma definição prática costuma ser: mesmo destinatário, mesmo evento de negócio e mesmo template (ou tipo de e-mail). “Reset de senha solicitado” e “reset de senha concluído” não são o mesmo evento, mesmo que pareçam parecidos na caixa de entrada.

Uma chave de dedupe deve ser estável e previsível para que todo caminho de código calcule o mesmo valor:

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

O detalhe mais importante: armazene a chave antes de enviar.

Crie um registro email_deliveries (ou similar) com uma constraint única em dedupe_key. Se o insert succeed, você é o dono do envio. Se conflitar, outra pessoa já lidou com isso.

Em caso de conflito, escolha o comportamento que faz sentido:

  • Pule o envio e registre “duplicate suppressed.”
  • Atualize um campo last_attempt_at se quiser visibilidade.
  • Retorne sucesso ao caller usando o registro existente.

Também decida a janela de dedupe. Alguns e-mails devem ser únicos para sempre (um recibo). Outros devem permitir repetições após um tempo (um lembrete diário). Para e-mails repetíveis, incorpore tempo na chave (por exemplo, reminder:{user_id}:2026-01-20) ou expire chaves antigas.

Um exemplo realista: dois resets de senha, uma usuária

Torne webhooks idempotentes
Corrija redeliveries de webhook e torne os handlers seguros para rodar duas vezes.

Envios duplicados muitas vezes parecem inofensivos em testes, depois aparecem em produção quando usuários clicam rápido e redes ficam instáveis.

Sara esquece a senha. Abre a página de reset e clica “Enviar link de reset.” A página parece lenta, então ela clica de novo.

Uma linha do tempo realista que leva a dois e-mails:

  • 10:02:11 A primeira requisição cria um token de reset e enfileira SendPasswordResetEmail.
  • 10:02:12 Sara clica de novo. Uma segunda requisição enfileira o mesmo job (ou aciona outro caminho que o enfileira).
  • 10:02:20 O runner pega o primeiro job e chama o provedor de e-mail.
  • 10:02:22 A chamada ao provedor time outa e seu job reexecuta.
  • 10:02:23 O segundo job roda também. Agora há sobreposição mais um retry.

Nos logs, isso pode parecer “só enviamos uma vez” do lado do app, enquanto o provedor mostra duas accepts, ou um accept mais um retry que também deu certo.

A correção é dedupe no nível do evento de negócio, não no ID do job. Para reset de senha, uma chave sólida é user_id + reset_token (ou reset_token sozinho se for único).

Quando o código de envio roda, ele primeiro verifica “já enviamos para essa chave?” Se sim, pula a chamada ao provedor e registra algo claro como “ignored duplicate attempt”, incluindo a dedupe key e a origem do gatilho.

Isso transforma o segundo clique e o retry em no-ops seguros, mantendo um rastro de auditoria para o próximo incidente.

Erros comuns que fazem duplicatas voltarem

Duplicatas frequentemente sobrevivem à primeira correção porque o patch trata o sintoma, não o gatilho. Tudo parece OK em testes, então o próximo pico de tráfego ou retry do provedor produz duas (ou cinco) mensagens.

Uma armadilha é confiar em ferramentas de supressão do provedor e considerar o problema resolvido. A supressão pode esconder o que os usuários veem, mas seu app ainda dispara múltiplas requisições de envio. Isso também dificulta o debug porque você continuará vendo entradas “send attempted”.

Chaves de dedupe mal feitas são outro problema comum. Se a chave for ampla demais (como user_id + template), você pode bloquear mensagens legítimas (dois recibos diferentes). Se for estreita demais (como um UUID aleatório por requisição), ela nunca matcha duplicatas, então retries continuam enviando.

Condições de corrida são o assassino silencioso. Se você grava o registro de dedupe depois de enviar, dois workers podem passar na checagem “não enviado ainda”, ambos enviar e então ambos escreverem sucesso. Reserve a chave primeiro (insert atômico), depois envie.

Problemas que tendem a reintroduzir duplicatas mais tarde:

  • Um webhook reconhece sucesso antes do estado do evento ser persistido.
  • Redelivery de webhook é tratado como erro em vez de comportamento normal.
  • O mesmo job é enfileirado duas vezes sem guard de unicidade.
  • Só um gatilho foi corrigido, mas um segundo caminho (ação de admin, cron, import) ainda envia.

Verificações rápidas antes de fazer deploy da correção

Remediação rápida para founders
A maioria das correções é entregue em 48–72 horas após uma auditoria gratuita do seu código.

Antes de aplicar, escolha um tipo de e-mail que teve duplicatas (reset de senha, recibo, convite) e confirme que você consegue seguir ele de ponta a ponta. Se não conseguir traçar uma mensagem do gatilho até a chamada ao provedor, você ainda está adivinhando.

Uma regra prática: cada e-mail deve ter uma identidade de evento de negócio única, e todo sistema que a toca deve tratar repetições como normais.

Checklist pré-deploy (rápido, alto sinal)

Em staging, com retries similares aos de produção ligados:

  • Logs mostram uma cadeia clara: gatilho recebido, handler aceitou, decisão de dedupe, job enfileirado (se houver), tentativa de envio, resposta do provedor registrada.
  • Handlers de webhook armazenam o event ID do provedor (ou o seu) e ignoram redeliveries sem lançar erros.
  • Jobs em background podem ser reexecutados sem efeitos colaterais: se o mesmo job rodar duas vezes, o handler sai cedo em vez de enviar duas vezes.
  • Uma chave de dedupe única é escrita em armazenamento durável antes da chamada de envio, não depois.
  • Você consegue ver picos rapidamente (até um gráfico básico) de “e-mails enviados por minuto” e “hits de dedupe”.

Um teste rápido “quebre de propósito”

Dispare o mesmo evento duas vezes (ou replique o mesmo payload de webhook). Então force uma falha: mate o worker no meio do job, ou simule um timeout do provedor de e-mail.

O resultado esperado é entediante: no máximo um e-mail entregue, e logs que expliquem claramente por que duplicatas foram bloqueadas.

Próximos passos: torne entediante, e mantenha assim

Depois que as chaves de dedupe pararem duplicatas nos seus logs, lance a mudança como qualquer atualização de produção. Se estiver apreensivo, coloque a checagem de dedupe atrás de uma feature flag e ative gradualmente. Comece com um tipo de e-mail (resets de senha são um bom alvo inicial) e expanda quando as métricas se estabilizarem.

Depois limpe a bagunça que duplicatas já criaram. Se você armazena registros de “e-mail enviado”, talvez queira marcar extras como duplicatas para que a visão de suporte e relatórios não fique errada. Histórico perfeito importa menos que os contadores futuros baterem com o que os usuários realmente experimentaram.

Adicione um pequeno teste automatizado que prove que o handler é idempotente: chame o mesmo evento duas vezes com a mesma dedupe key e asserte que só um envio foi registrado. Esse único teste frequentemente evita que um refactor futuro remova a proteção.

Alguns hábitos que mantêm tudo entediante ao longo do tempo:

  • Logue a dedupe key em toda tentativa de envio e em todo skip.
  • Alarme para picos súbitos de “skipped as duplicate” (pode sinalizar um loop de gatilhos).
  • Revise novos handlers de webhook e jobs em background por idempotência antes de dar merge.
  • Mantenha o armazenamento de dedupe durável o bastante para sobreviver reinícios e retries.

Se você herdou uma codebase gerada por IA onde envios de e-mail estão espalhados por handlers copiados e retries, uma auditoria focada pode economizar dias de adivinhação. FixMyMess (fixmymess.ai) é especialista em diagnosticar e reparar apps gerados por IA, incluindo adicionar idempotência por evento de negócio para que webhooks e retries parem de produzir e-mails duplicados.

Perguntas Frequentes

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

Trate como um evento de negócio produziu duas mensagens, não apenas “duas chamadas SMTP”. Comece nomeando o evento (por exemplo, password_reset_requested ou receipt_paid) e faça com que todas as camadas tratem repetições como normais e seguras.

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

Na maioria das vezes seu app está pedindo o mesmo envio duas vezes: cliques duplos ou retries do cliente, redeliveries de webhook, retries de jobs em background, ou dois caminhos de código diferentes enviando o mesmo template. Os provedores de e-mail geralmente enviam exatamente o que você lhes pediu.

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

Escolha um incidente real e construa uma linha do tempo. Colete seu ID interno do registro de e-mail, o message ID do provedor, timestamps, IDs das entidades de negócio (como order_id ou reset_token_id) e os IDs de request/job; então escreva o caminho exato que levou a cada aceitação pelo provedor.

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

Registre uma linha consistente logo antes de cada chamada ao provedor com um correlation ID, origem do gatilho, nome do evento de negócio, destinatário, template/tipo e a chave de dedupe (mesmo que ainda não esteja aplicando). Isso torna óbvio quando dois gatilhos diferentes dispararam em segundos.

How do I stop webhook redeliveries from causing duplicate emails?

Presuma que todo webhook pode chegar mais de uma vez. Grave o event ID do webhook em armazenamento durável com uma constraint única, retorne 2xx depois que ele estiver salvo e processe o trabalho depois. Assim, um redelivery vira um no-op em vez de outro envio.

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

Como a maioria das filas é de at-least-once, um job pode rodar duas vezes por timeouts, crashes ou expirações de visibilidade. Torne o job idempotente: reserve o envio usando um registro de dedupe único antes de chamar o provedor e saia cedo se já estiver reservado ou enviado.

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

Crie uma chave estável baseada no evento de negócio, por exemplo order_receipt:{order_id}:{email_type} ou password_reset_requested:{user_id}:{reset_token_id}. Armazene-a antes de enviar com uma constraint única; se a inserção conflitar, pule a chamada ao provedor e registre “duplicate suppressed”.

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

Se você escreve o registro de “enviado” depois da chamada ao provedor, dois workers podem passar no check de “ainda não enviado” e ambos enviar. A correção padrão é reservar de forma atômica primeiro (insert único ou lock), depois enviar e então marcar como enviado.

How can I test the fix before deploying to production?

Um teste simples “quebre de propósito” funciona: dispare o mesmo evento duas vezes, replique o mesmo payload de webhook e force uma falha como crash do worker ou timeout do provedor. Você deve ver, no máximo, um e-mail entregue, e logs que expliquem claramente por que a segunda tentativa foi bloqueada pela dedupe.

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

Se a lógica de envio estiver espalhada por handlers copiados, webhooks e jobs, os duplicates reaparecem após cada patch. FixMyMess ajuda a diagnosticar codebases geradas por IA, consolidar caminhos de envio, adicionar chaves de dedupe por evento e endurecer retries para que os usuários recebam uma única mensagem de forma confiável.