Дублирующиеся отправки писем: найти двойные триггеры и добавить dedupe-ключи
Дублирующиеся отправки писем в продакшене вызваны двойными триггерами, повторами или перекрытием задач. Научитесь находить причину и добавлять dedupe-ключи, чтобы отправлять только одно письмо.

Что на самом деле значит «дублирующиеся письма» в продакшене
Пользователи не говорят «мне пришло два SMTP-вызова». Они описывают ощущение: «Мне пришло два письма для сброса пароля», «Мой чек пришёл дважды» или «Ваше приложение меня заспамило». Иногда копии идентичны. Иногда они отличаются на несколько секунд, строкой темы или пикселем отслеживания — и тогда сложнее доказать, что произошло.
Дубликаты подрывают доверие. Если чек приходит дважды, люди боятся, что с них сняли деньги дважды. Если письмо для входа или сброса пароля дублируется, пользователи переживают, что кто-то пытается получить доступ к их аккаунту. Внутри компании дубликаты создают тикеты в поддержку, шумные алерты и искажают метрики. Со временем это может также ухудшить доставляемость: почтовые провайдеры замечают всплески и повторяющийся контент.
Проблема в том, что «отправка письма» редко — это один шаг. Одно и то же бизнес-событие может распространиться по системам: webhook сработал, фоновая задача повторилась, воркер рестартанулся или пользователь кликнул дважды и фронтенд отправил запрос дважды. Каждый компонент может вести себя «правильно», но в совокупности они вызовут одну и ту же отправку несколько раз.
Цель проста и проверяема: одно бизнес-событие = одно письмо.
Бизнес-событие — это то, что вам важно, например «запрошен сброс пароля для user 123» или «счёт 987 оплачен». Как только вы определили событие, защищайте его одной идентичностью, чтобы каждый слой мог сказать: «Это уже отправлялось.»
Практичный способ смотреть на это:
- Дубликат — это не «два SMTP-вызова», а «одно и то же событие породило два сообщения».
- Исправлять нужно не только уменьшением повторов, а тем, чтобы любой триггер был безопасен для повторного запуска.
- Лучший результат — скучный: повторы, webhooks и рестарты происходят, а пользователи всё равно получают одно письмо.
Распространённые причины: двойные триггеры, повторы и перекрытие задач
Большинство дубликатов — это не «почтовый сервис сошёл с ума». Они происходят потому, что приложение запросило одну и ту же отправку более одного раза, часто из двух мест, не знающих друг о друге.
Типичная схема начинается на краю: пользователь кликает дважды, форма отправляется дважды или фронтенд повторяет запрос, потому что не получил ответ. Если бэкенд считает каждый запрос новым бизнес-событием, вы получили две отправки.
Webhooks — ещё частый источник. Многие провайдеры намеренно доставляют один и тот же webhook несколько раз, особенно если ваш endpoint медленный или возвращает не-2xx статус. Если вы обрабатываете каждую доставку как новую, вы можете снова вызвать действие «отправить письмо».
Фоновые задачи добавляют свой тип дублирования. Задача может попасть в очередь дважды из-за гонок (два сервера обрабатывают один и тот же запрос), реплеев (очередь повторно прогоняет сообщение) или повтора воркера после тайм-аута. Худший случай — воркер тайм-аутит после того, как провайдер принял отправку, затем повторяет и отправляет снова.
При трассировке конкретного дубликата вы обычно находите одно из следующего:
- Тот же ивент был создан дважды (двойная отправка формы, повтор со стороны клиента).
- Webhook был повторно доставлен и обработан как новый.
- Задача выполнилась дважды (или два воркера запустились параллельно).
- Повтор произошёл после того, как письмо уже покинуло вашу систему.
- Два разных участка кода отправляют один и тот же шаблон (например, в контроллере и в callback модели).
Последнее часто встречается в быстрых прототипах: логика отправки копируется в несколько обработчиков, и оба остаются активными.
Начните с одного инцидента и составьте таймлайн
Не пытайтесь сразу просканировать весь код. Начните с одного реального письма, которое получило пользователь дважды. Выберите один шаблон (например, «Сброс пароля» или «Чек») и узкое окно времени (5–15 минут), чтобы не смешивать разные события.
Соберите как можно больше идентификаторов по этому инциденту, чтобы вы могли ссылаться на конкретные попытки отправки, а не только на пользователя, который пожаловался.
Для каждой копии письма зафиксируйте:
- Внутренний ID записи письма (или ID строки в БД)
- message ID / response ID от почтового провайдера
- Временные метки (создано, в очереди, отправлено, принято провайдером)
- Бизнес-идентификаторы (user_id, order_id, invoice_id, reset_token_id)
- Любой request ID или job ID, связанный с отправкой
Затем пропишите таймлайн простым языком от триггера до принятия провайдером. Логи помогают, но письменный вариант заставляет мыслить яснее.
Полезный таймлайн отвечает на четыре вопроса: какое событие произошло, какой путь кода его обработал, какие задачи были поставлены в очередь и сколько раз провайдер принял сообщение.
Пример: пользователь кликает «Сброс пароля» в 10:03:12. Ваш API создаёт reset_token_id=7781 и ставит задачу в очередь в 10:03:13. В 10:03:14 клиент повторяет запрос (или webhook редоставлен), создаётся второй токен и вторая задача. Обе задачи выполняются, и провайдер принимает два сообщения в 10:03:20 и 10:03:22.
Инструментируйте путь отправки, чтобы видеть дубликаты
Вы не сможете исправить то, чего не видите. Первая цель проста: пусть каждая попытка отправки оставляет след, по которому можно проследить от триггера до провайдера.
Начните с поиска всех мест, откуда ваше приложение может отправлять почту. У многих команд таких путей больше одного: контроллер, отправляющий напрямую, обработчик webhook «на всякий случай» и фоновая задача, которая тоже может отправлять. Добавьте одну понятную строчку лога прямо перед вызовом провайдера (момент, когда вы просите отправить письмо) и сделайте её одинаковой во всех местах.
Что логировать при каждой попытке отправки
Делайте это скучно и единообразно. Набор небольших полей лучше длинного сообщения, которое никто не читает.
- correlation ID, который проходит через весь запрос или задачу
- источник триггера (web_request, webhook, cron, background_job, manual_admin)
- бизнес-событие (password_reset, receipt, invite, email_change)
- получатель и имя шаблона (или тип сообщения)
- dedupe-ключ, который вы планируете использовать (даже если пока не применяете)
С этим, когда пользователь скажет «мне пришли два письма», вы сможете искать в логах по получателю и событию, а затем группировать по correlation ID и dedupe-ключу. Дубликаты часто выглядят как два разных триггера, сработавшие в пределах нескольких секунд.
Webhooks: относитесь к повторным доставкам как к норме
Большинство систем вебхуков повторяют доставку по дизайну. Если ваш обработчик не идемпотентен, повторы становятся дублирующими отправками даже при том, что «всё работает как задумано». Исправление — считать, что любой webhook может быть доставлен больше одного раза.
Сначала убедитесь, что вы не дублируете webhooks до того, как запрос попадёт в ваш код. Удивительно часто бывают две подписки на один и тот же endpoint (забытая старая подписка или staging, указывающий на продакшен). Пейлоады выглядят валидно; единственный намёк — одно и то же событие появляется дважды.
Далее разберитесь, когда провайдер делает повторные отправки. Многие повторяют при тайм-аутах и 5xx ошибках, а некоторые даже при определённых 4xx. Если ваш обработчик делает тяжёлую работу (отправляет письмо, вызывает другие сервисы, выполняет тяжёлые запросы) до ответа, вы увеличиваете шанс тайм-аута и повторной доставки.
Более безопасный паттерн: сперва записать, затем ответить, потом обработать. Возвращайте успех только после того, как важные данные надёжно сохранены (обычно в БД), тогда повторная доставка увидит уже существующее событие.
Контрольный список с высоким сигналом:
- Подтвердите, что активна только одна подписка на тип события в каждом окружении.
- Логируйте ID события webhook от провайдера вместе с вашим request ID.
- Сохраняйте ID события с уникальным ограничением и статусом processed/unprocessed.
- Возвращайте 2xx только после того, как событие сохранено, а не после отправки письма.
- Если запись не удалась, возвращайте ошибку, чтобы повтор был полезным, а не вредным.
Фоновые задачи: предотвратите двойную постановку и двойное выполнение
Фоновые задачи — частый источник дубликатов, потому что большинство очередей гарантируют хотя бы однократную доставку. Задача может выполниться дважды, и система всё ещё посчитает это нормой. Ваш код должен быть безопасен, если та же задача попадёт повторно.
Задача может выполниться дважды по обычным причинам: воркер упал после отправки, но до подтверждения очереди, задача тайм-аутит или истёк visibility timeout, и очередь отдаёт ту же полезную нагрузку другому воркеру. Если вызов отправки письма находится в середине этого процесса, пользователь получит два сообщения.
Сначала уменьшите риск двойной постановки. Классическая ошибка — ставить задачу в очередь внутри транзакции и затем откатывать транзакцию, или ставить в двух местах (API-обработчик и callback модели). Предпочитайте ставить задачу после коммита, чтобы запись «событие произошло» и задача «отправить письмо» не рассинхронизировались.
Затем сделайте задачу безопасной при повторном запуске. Воркер должен проверять «отправляли ли мы это уже?» перед вызовом провайдера.
Практичные средства защиты, которые хорошо работают:
- Используйте уникальный ключ задачи, чтобы очередь отказала в дубликате для одного и того же бизнес-события.
- Записывайте строку «уже поставлено в очередь», ключёную по событию, и ставьте в очередь только если вставка прошла.
- В воркере атомарно резервируйте отправку (или берите лок) перед отправкой.
- Оставляйте повторы, но ограничивайте их и логируйте случаи, когда повтор произошёл после того, как провайдер подтвердил приём.
Если ваша единственная защита — «мы делаем повторы при ошибках», вы будете продолжать видеть дубликаты, когда ошибка произойдёт после того, как письмо уже было отправлено.
Добавьте dedupe-ключи (идемпотентность) на уровне бизнес-события
Чтобы окончательно остановить дубликаты, не делайте дедупликацию на уровне «вызов API отправки». Делайте её на уровне бизнес-события: что произошло в вашем приложении, что заслуживает ровно одного сообщения.
Начните с определения, что значит «одно и то же письмо» для вашего продукта. Практичное определение обычно: тот же получатель, то же бизнес-событие и тот же шаблон (или тип письма). «Запрошен сброс пароля» и «сброс пароля выполнен» — разные события, даже если во входящих они похожи.
Dedupe-ключ должен быть стабильным и предсказуемым, чтобы каждый путь кода вычислял одно и то же значение:
password_reset_requested:{user_id}:{reset_token_id}order_receipt:{order_id}:{email_type}invite_sent:{workspace_id}:{invitee_email}
Самый важный момент: сохраните ключ до отправки.
Создайте запись email_deliveries (или похожую) с уникальным ограничением по dedupe_key. Если вставка прошла — вы владеете отправкой. Если конфликт, значит кто-то уже это сделал.
При конфликте выберите поведение, которое подходит:
- Пропустить отправку и залогировать «duplicate suppressed».
- Обновить поле
last_attempt_at, если нужен мониторинг. - Вернуть успех вызывающему, используя существующую запись.
Также определите окно дедупликации. Некоторые письма должны отправляться один раз навсегда (чек). Другие допускают повторы через время (ежедневное напоминание). Для повторяющихся писем включайте время в ключ (например, reminder:{user_id}:2026-01-20) или истекайте старые ключи.
Реалистичный пример: два сброса пароля от одного пользователя
Дубли часто выглядят безобидно в тесте, но проявляются в продакшене, когда пользователи кликают быстро, а сети фланят.
Сара забыла пароль. Она открыла страницу сброса и нажала «Отправить ссылку». Страница показалась медленной, и она нажала снова.
Реалистичный таймлайн, ведущий к двум письмам:
- 10:02:11 Первый запрос создаёт reset token и ставит
SendPasswordResetEmailв очередь. - 10:02:12 Сара нажимает ещё раз. Второй запрос ставит такую же задачу (или вызывает другой путь, ставящий задачу).
- 10:02:20 Раннер задач подхватывает первую задачу и вызывает провайдера.
- 10:02:22 Вызов провайдера тайм-аутит, и задача повторяется.
- 10:02:23 Запускается вторая задача. Теперь у нас есть перекрытие и повтор.
В логах это может выглядеть как «мы отправили только один раз» со стороны приложения, в то время как провайдер показывает два принятых запроса, или одно принятое и один повтор, который также удался.
Фикс — дедупликация на уровне бизнес-события, а не ID задачи. Для сброса пароля надёжный ключ — user_id + reset_token (или просто reset_token, если он уникален).
Когда код отправки выполняется, он сначала проверяет «мы уже отправляли по этому ключу?» Если да — пропускает вызов провайдера и пишет понятный лог ignored duplicate attempt с dedupe-ключом и источником триггера.
Это превращает второй клик и повтор в безопасные no-op'ы, сохраняя при этом аудит для разбора инцидента.
Распространённые ошибки, из-за которых дубликаты возвращаются
Дубликаты часто переживают первое исправление, потому что патч лечит симптом, а не источник. Всё выглядит нормально в тестах, но следующий всплеск трафика или повтор от провайдера снова породит два (или пять) сообщений.
Одна ловушка — полагаться на инструменты подавления со стороны почтового провайдера и считать задачу выполненной. Подавление может скрыть то, что видит пользователь, но ваше приложение всё ещё отправляет несколько запросов. Это также усложняет отладку, потому что вы будете видеть повторяющиеся записи «send attempted».
Ещё частая проблема — некорректные dedupe-ключи. Если ключ слишком широкий (например, user_id + template), вы можете блокировать легитимные письма (два разных чека). Если ключ слишком узкий (например, случайный UUID для каждого запроса), он никогда не совпадёт, и повторы всё равно будут отправлять.
Гонка — тихий убийца. Если вы записываете запись dedupe после отправки, два воркера могут оба пройти проверку «ещё не отправлено», оба отправить, а затем оба записать успех. Сначала резервируйте ключ (атомарная вставка), потом отправляйте.
Проблемы, которые склонны возвращать дубликаты:
- Webhook подтверждает успех до того, как состояние события сохранено.
- Повторная доставка webhook трактуется как ошибка, а не как нормальное поведение.
- Одна и та же задача ставится в очередь дважды без уникальной защиты.
- Исправлен только один триггер, а второй путь (действие администратора, cron, импорт) всё ещё отправляет.
Быстрая проверка перед релизом
Перед развёртыванием выберите один тип письма, по которому были дубликаты (сброс пароля, чек, приглашение) и убедитесь, что вы можете проследить его end-to-end. Если вы не можете проследить одно сообщение от первого триггера до вызова провайдера, вы всё ещё в сфере догадок.
Практичное правило: у каждого письма должна быть единая бизнес-идентичность, и каждая система, которая с ним работает, должна нормально относиться к повторным событиям.
Чек-лист перед деплоем (быстро и с высоким сигналом)
В staging с включёнными продакшен-подобными повторами:
- Логи показывают одну чёткую цепочку: триггер получен, обработчик принял, принято решение по дедупу, задача поставлена в очередь (если есть), попытка отправки, ответ провайдера записан.
- Обработчики webhook сохраняют ID события провайдера (или ваш собственный) и игнорируют повторные доставки, не падая с ошибкой.
- Фоновые задачи можно повторно запускать без побочных эффектов: при втором запуске обработчик выходит раньше, вместо повторной отправки.
- Уникальный dedupe-ключ записан в надёжное хранилище до вызова провайдера, а не после.
- Вы можете быстро увидеть всплески (хотя бы базовый график) для «писем в минуту» и «попаданий в дедуп».
Быстрый тест «сломай это намеренно»
Сгенерируйте одно и то же событие дважды (или воспроизведите payload webhook). Затем форсируйте один сбой: убейте воркер в середине задачи или симулируйте тайм-аут от провайдера.
Ожидаемый результат — скучный: максимум одно доставленное письмо и логи, объясняющие, почему дубликаты были заблокированы.
Следующие шаги: сделайте это скучным и поддерживайте так
После того как dedupe-ключи перестанут давать дубликаты в логах, выкатывайте изменения как обычный релиз. Если боитесь, поставьте проверку дедупа за feature-flag и включайте поэтапно. Начните с одного типа письма (сброс пароля — хороший кандидат), затем расширяйте, когда метрики устаканятся.
Затем приведите в порядок уже созданный мусор. Если вы храните записи «письмо отправлено», возможно, стоит пометить лишние как дубликаты, чтобы представления для поддержки и отчёты перестали выглядеть неправильно. Идеальная история важнее не всегда: важнее, чтобы будущие счёты совпадали с тем, что увидели пользователи.
Добавьте один небольшой автоматизированный тест, который доказывает идемпотентность обработчика: вызовите одно и то же событие дважды с тем же dedupe-ключом и убедитесь, что записана только одна отправка. Один такой тест часто предотвращает удаление защиты при следующем рефакторинге.
Несколько привычек, которые сохранят скучность со временем:
- Логируйте dedupe-ключ при каждой попытке отправки и при каждом пропуске.
- Ставьте алерты на резкие всплески «skipped as duplicate» (это может сигналить о цикле триггеров).
- Ревьюйте новые обработчики webhook и фоновые задачи на предмет идемпотентности до мерджа.
- Держите хранилище дедуп-ключей достаточно надёжным, чтобы оно пережило рестарты и повторы.
Если вы унаследовали AI-сгенерированную кодовую базу, где отправки писем разбросаны по копированным обработчикам и повторам, фокусированный аудит может сэкономить дни на субъективных догадках. FixMyMess (fixmymess.ai) специализируется на диагностике и ремонте AI-сгенерированных приложений, включая добавление идемпотентности на уровне бизнес-событий, чтобы webhooks и повторы задач перестали порождать дубликаты писем.
Часто задаваемые вопросы
Что вы имеете в виду под «дублирующимися письмами» в продакшене?
Речь о том, что одно бизнес-событие породило два сообщения, а не просто «два SMTP-вызова». Сначала назовите событие (например, password_reset_requested или receipt_paid), затем сделайте так, чтобы каждый слой системы уверенно и безопасно обрабатывал повторные события.
Каковы самые частые причины, по которым пользователю приходит одно и то же письмо дважды?
В большинстве случаев приложение само вызывает одну и ту же отправку дважды: двойные клики или повторы со стороны клиента, повторные доставки webhook, повторы фоновых задач или два разных участка кода, отправляющих один и тот же шаблон. Почтовые провайдеры обычно отправляют ровно то, что вы им запросили.
Как отладить один дубликат, не погружаясь в весь код?
Выберите один реальный инцидент и постройте хронологию. Соберите внутренний ID записи письма, message ID провайдера, временные метки, бизнес-идентификаторы (например, order_id или reset_token_id) и ID запроса/задачи, затем опишите точный путь, который привёл к каждому подтверждению приёма провайдером.
Что стоит логировать, чтобы позже было легко заметить дубликаты?
Записывайте одну согласованную строку лога прямо перед каждым вызовом провайдера: correlation ID, источник триггера, имя бизнес-события, получатель, шаблон/тип и dedupe-ключ (даже если вы его пока не применяете). Тогда очевидно, когда два разных триггера сработали в пределах нескольких секунд.
Как не допустить, чтобы повторные доставки webhook вызывали дубликаты писем?
Предположите, что любой webhook может прийти больше одного раза. Сохраните ID события от провайдера в надёжном хранилище с уникальным ограничением, возвращайте 2xx только после записи, а обработку выполняйте после сохранения. Тогда повторная доставка станет безопасным no-op, а не дополнительной отправкой.
Как предотвратить, чтобы фоновые задачи отправляли одно и то же письмо дважды?
Очевидно — очереди по сути обеспечивают «по крайней мере один раз», поэтому задача может выполниться дважды при тайм-аутах, крашах или истечении видимости. Сделайте задачу идемпотентной: забронируйте отправку через уникальную запись dedupe до вызова провайдера и завершайте ранo, если запись уже существует или помечена как отправленная.
Какой хороший ключ дедупликации (идемпотентности) для отправок писем?
Хороший ключ — стабильный ключ, основанный на бизнес-событии, например order_receipt:{order_id}:{email_type} или password_reset_requested:{user_id}:{reset_token_id}. Сохраните его до отправки с уникальным ограничением; при конфликте пропускайте вызов провайдера и логируйте «duplicate suppressed».
Почему схема «проверить, отправить, потом отметить как отправленное» всё ещё даёт дубликаты?
Если вы записываете статус «отправлено» после вызова провайдера, два воркера могут одновременно пройти проверку «ещё не отправлено» и оба отправят. Стандартный фикс — сначала атомарно зарезервировать ключ (уникальная вставка или лок), потом отправить, затем пометить как отправленное.
Как протестировать фикс перед развёртыванием в продакшн?
Простой тест «сломай это намеренно»: вызовите одно и то же событие дважды или воспроизведите полезную нагрузку webhook, затем форсируйте отказ (убейте воркер в середине задачи или симулируйте тайм-аут провайдера). Ожидаемый результат — максимум одно доставленное письмо и понятные логи о том, почему второй попытке было отказано.
Может ли FixMyMess помочь, если это происходит в AI-сгенерированном приложении?
Если логика отправки разбросана по копипастным обработчикам, webhooks и задачам, дубликаты будут возвращаться после каждого патча. FixMyMess помогает диагностировать AI-сгенерированные кодовые базы, консолидировать пути отправки, добавить дедупликацию на уровне бизнес-событий и укрепить повторы, чтобы пользователи получали ровно одно сообщение.