Транзакционные письма, которые доходят: очередь, ретраи и идемпотентность против SMTP-релея
Пользователь жмёт «сбросить пароль» и получает три одинаковых письма за 40 секунд, а его сосед по таблице — ни одного. Оба бага растут из одного корня: `await sendMail()` прямо в HTTP-хендлере. Разбираем, как построить durable-очередь на Postgres с идемпотентностью, бэкоффом и dead-letter, где SMTP-релей — это ненадёжный сетевой ресурс, а не «отправитель».
EvilMail Team30 июля 2026 г.13 мин чтения
Пользователь жмёт «сбросить пароль» и получает три одинаковых письма за 40 секунд. Его сосед по таблице не получает ни одного. Оба бага растут из одного корня — вот такого хендлера:
Здесь два независимых требования склеены в один синхронный вызов, и оба нарушены. Первое: письмо должно быть отправлено хотя бы один раз — если процесс перезапустят между createResetToken и подтверждением от релея, письмо испаряется вместе с воркером. Второе: письмо должно дойти не больше одного раза с точки зрения получателя — а ретрай HTTP-клиента, двойной клик или таймаут балансировщика легко превращают один запрос в три. Совместить durability и идемпотентность внутри request-хендлера нельзя в принципе. Ниже — схема, которая их разводит.
Почему нельзя слать письмо прямо из HTTP-хендлера
Надёжная отправка транзакционных писем через SMTP-релей: очередь, ретраи, идемпотентность — EvilMail Blog
SMTP — это разговорный протокол с состоянием. Один sendMail() разворачивается в цепочку EHLO → STARTTLS → EHLO → AUTH → MAIL FROM → RCPT TO → DATA → QUIT — это 4–8 полных RTT, то есть 200–800 мс до релея даже когда всё хорошо. Когда плохо — релей отвечает 421 и держит сокет, greylisting подвешивает RCPT на секунды, TLS-хендшейк спотыкается о таймаут. Всё это время пользовательский запрос заблокирован, а p99 ручки логина живёт по законам чужого MTA.
Хуже: письмо в этот момент существует только в оперативной памяти воркера. Rolling-деплой, OOM-killer, SIGTERM от Kubernetes с 30-секундным grace period — и намерение отправить письмо потеряно без следа, потому что вы уже вернули клиенту 200 OK (или ещё не вернули, и он ретраит).
Правило простое: HTTP-хендлер обязан только записать намерение в durable-хранилище и вернуть `202 Accepted`. Всю грязную работу с сетью делает отдельный воркер, который можно рестартовать, масштабировать и чинить независимо от фронта.
Модель отказов SMTP: 4xx против 5xx
Вся архитектура очереди строится вокруг одной границы — временный отказ против постоянного. Релей отвечает трёхзначным кодом, и первая цифра решает судьбу письма.
2xx — 250 2.0.0 Ok: queued — релей принял сообщение к обработке. Внимание: это не «доставлено в инбокс», а только «я взял ответственность». Финальный вердикт придёт позже, асинхронно, отдельным письмом-отбивкой (DSN).
4xx — временный отказ, ретраить. 421 service not available (релей перегружен или закрывается), 450 mailbox busy, 451 local error in processing, 452 too many recipients. Сюда же — классический greylisting, который прикидывается 450/451 с текстом «try again later».
5xx — постоянный отказ, ретраить категорически нельзя. 550 no such user, 552 message size exceeds limit, 554 rejected / spam. Повторная долбёжка в 550 — это прямой сигнал провайдеру «я шлю на несуществующие адреса», то есть добровольная порча репутации домена.
Отдельная категория — ошибки уровня соединения, у которых вообще нет SMTP-кода: ECONNREFUSED, ETIMEDOUT, EAI_AGAIN (DNS не отвечает), ECONNRESET. Все они по смыслу временные — трактуем как 4xx и ретраим.
Очередь на Postgres: SKIP LOCKED вместо брокера
Не тащите RabbitMQ или Kafka ради 500 писем в день. Пока у вас единицы воркеров, обычная таблица в Postgres с SELECT ... FOR UPDATE SKIP LOCKED закрывает 95% кейсов, переживает рестарт и, в отличие от Redis-очереди, полностью видима обычным SQL — вы можете глазами посмотреть, что застряло и почему.
sql
CREATE TABLE email_outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key text UNIQUE NOT NULL,
recipient text NOT NULL,
template text NOT NULL,
payload jsonb NOT NULL,
message_id text NOT NULL,
status text NOT NULL DEFAULT 'pending', -- pending|sending|sent|failed
attempts int NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
locked_by text,
last_error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON email_outbox (status, next_attempt_at);
Сердце системы — атомарный claim-запрос. Он забирает пачку готовых записей, помечает их sending и инкрементит счётчик попыток в одной транзакции. SKIP LOCKED означает, что параллельный воркер просто перескочит уже захваченные строки, а не встанет в блокировку:
sql
WITH claimed AS (
SELECT id FROM email_outbox
WHERE status = 'pending' AND next_attempt_at <= now()
ORDER BY next_attempt_at
FOR UPDATE SKIP LOCKED
LIMIT 20
)
UPDATE email_outbox o
SET status = 'sending', locked_by = $1, attempts = attempts + 1
FROM claimed c
WHERE o.id = c.id
RETURNING o.*;
Воркер в цикле дёргает этот запрос, отправляет пачку в релей и по результату переводит каждую запись в sent, failed или обратно в pending с новым next_attempt_at. Если воркер умрёт посреди отправки, записи зависнут в sending — прикройте это фоновым sweeper'ом, который возвращает в pending всё, что висит в sending дольше, скажем, 5 минут (locked_by подскажет, кто их держал).
Мигрировать на BullMQ/Redis или RabbitMQ стоит, когда воркеров больше 5–10 и polling сам по себе начинает греть базу, либо когда нужны приоритеты и отложенные задачи «из коробки». Не раньше.
Идемпотентность: ключ, а не дедупликация постфактум
Дедуплицировать письма «по факту» — сравнивать тело, время, адрес — это гонка и боль. Единственный надёжный источник истины — UNIQUE-констрейнт на ключе, который генерирует вызывающая сторона из бизнес-события, а не из времени отправки.
Для сброса пароля это password_reset:{user_id}:{token_jti} — при повторном клике token_jti тот же, значит и ключ тот же. Вставка отбивает дубль на уровне БД:
sql
INSERT INTO email_outbox
(idempotency_key, recipient, template, payload, message_id, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
Если запрос вернул строку — письмо поставлено в очередь. Если вернул пусто — оно уже там, второй HTTP-запрос не создал ничего. Хендлер в обоих случаях отдаёт 202.
Но это только половина. UNIQUE-ключ гасит дубли до очереди. Внутри же одной записи воркер может ретраить отправку много раз — и если на каждой попытке генерировать новый Message-ID, то при сценарии «релей принял письмо, но ответ 250 потерялся по дороге» получатель увидит два письма с разными идентификаторами. Поэтому `Message-ID` должен быть детерминирован от ключа идемпотентности и записан один раз при вставке:
Один и тот же Message-ID на всех попытках — и релей, и почтовый клиент получателя дедуплицируют письмо сами. Новый Message-ID на ретрае — самая частая причина дублей, которую потом невозможно объяснить продакту.
Ретраи: экспоненциальный бэкофф с джиттером
Наивный «ретрай каждые 30 секунд» устраивает провайдеру DDoS в момент, когда упавший релей поднимается: 10 000 писем, застрявших на 421, бьют одновременно. Нужен экспоненциальный рост интервала плюс случайный разброс:
Рабочие числа: base = 30с, cap = 6ч, max_attempts = 8, полный джиттер (случайное значение от нуля до вычисленного интервала). Джиттер здесь не украшение — он размазывает волну ретраев по времени и убивает thundering herd. Для greylisting (450/451) выставляйте первый ретрай не раньше чем через 60–300 секунд: релей специально хочет, чтобы вы «подумали».
Терминальные состояния: любой 5xx переводит запись в failed немедленно, без ретраев. Исчерпание max_attempts на 4xx — туда же, в dead-letter. failed — это не «забыть», а «поднять алерт и показать человеку». Обновление attempts, next_attempt_at и last_error делается в той же транзакции, что и фиксация результата отправки, иначе счётчик разъедется с реальностью.
250 — это не финал: обработка bounce
Релей ответил 250 и умыл руки. Через минуту или через час может прилететь DSN — отбивка на ваш return-path. Чтобы связать её с конкретной записью, используйте VERP: адрес отправителя в envelope делайте вида bounces+{outbox_id}@evilmail.pro. Отбивка вернётся именно на этот адрес, и outbox_id вытащится из To: без парсинга тела.
Разбор по DSN-статусам (RFC 3463):
Hard bounce — 5.1.1 unknown user, 5.7.1 blocked by policy. Адрес мёртв или вас заблокировали — немедленно в suppression-лист, больше не слать никогда.
Soft bounce — 4.2.2 mailbox full, временные лимиты. Считаем; после N подряд (обычно 5–7) — тоже suppression.
FBL / жалоба на спам — приходит в ARF-формате через feedback loop провайдера. Один сигнал — мгновенный suppress.
Ключевой момент: suppression-лист проверяется ПЕРЕД постановкой в очередь, а не при отправке. Отправлять на адрес, который уже дал hard bounce, — значит своими руками гнать bounce rate вверх. А bounce rate выше 2% Gmail и Microsoft воспринимают как сигнал компрометации и начинают дросселировать весь домен.
Аутентификация домена: без SPF/DKIM/DMARC очередь бессмысленна
Письмо, уехавшее в спам, для вашей системы неотличимо от 250 accepted — но для бизнеса оно потеряно так же, как если бы не отправлялось. Поэтому доменная аутентификация — часть надёжности, а не отдельная тема. Минимум:
Поверх — MTA-STS (_mta-sts.evilmail.pro TXT "v=STSv1; id=20260704" плюс policy-файл на https://mta-sts.evilmail.pro/.well-known/mta-sts.txt) заставляет отправляющие MTA использовать TLS и не даёт даунгрейдить соединение, а TLS-RPT присылает отчёты о неудачных handshake. На evilmail.pro транзакционка едет именно по этой связке — без неё очередь просто аккуратно складывает письма в спам-папки.
Не забудьте про таймауты клиента, иначе один медленный релей выжрет весь пул воркеров. Для Nodemailer:
Отдельный пул воркеров с per-domain rate limit, чтобы не долбить один MX.
За чем следить в первую очередь — две метрики. Возраст самого старого неотправленного письма: oldest_unsent_age = now() - min(created_at) WHERE status = 'pending' с алертом на 5 минут — он ловит и упавший воркер, и лёгший релей быстрее любого дашборда. И bounce rate за последние 24 часа: пока он под 2%, а доля жалоб на спам под 0.1%, ваша репутация в зелёной зоне, и очередь делает ровно то, ради чего строилась — доставляет письма по одному разу и до конца.