Отправка почты из Node.js и Python по-взрослому: пул соединений, ретраи и корректное закрытие
На десятке писем наивная отправка незаметна, на тысяче — это 300–800 мс на письмо, случайные ECONNRESET и тихая потеря при 4xx. Разбираем, как из Nodemailer и aiosmtplib сделать отправку долгоживущим сетевым ресурсом: пул, три таймаута, ретраи с разделением 4xx/5xx и graceful shutdown.
EvilMail Team30 июля 2026 г.12 мин чтения
Почему `for (const to of list) sendMail(to)` — это медленно и опасно
Возьмём самый распространённый код рассылки: цикл по получателям, внутри — sendMail. Он работает, проходит ревью, а потом на продакшне на батче в тысячу писем начинает выдавать 400–800 мс на письмо и ронять случайные ECONNRESET. Причина не в коде отправки письма, а в том, что каждый вызов открывает новое соединение с нуля.
Разберём, что физически происходит на каждый наивный sendMail. Сначала DNS-резолв MX-записи. Потом TCP-хендшейк — один round-trip. Затем TLS: на TLS 1.2 это два RTT, на TLS 1.3 — один. Дальше EHLO, AUTH, и только после этого MAIL FROM / RCPT TO / DATA. И QUIT
в конце. До того, как в сокет уйдёт первый байт самого письма, вы уже потратили 4–7 полных round-trip'ов. При латентности до внешнего MX в 50–150 мс это стабильно 400+ мс оверхеда на каждое письмо — вхолостую, ещё до полезной нагрузки.
Вторая проблема менее очевидна. Почтовые провайдеры считают частоту новых подключений. Сотни свежих TCP-коннектов в минуту с одного IP — это паттерн, по которому включаются лимиты connections/hour и временные баны. Вы получаете 421 и думаете, что это сбой сети, а на деле вас притормозили за поведение.
Тезис простой: отправка почты — это работа с долгоживущим сетевым ресурсом, а не вызов функции. Соединение надо переиспользовать, а сбои — классифицировать, а не ретраить вслепую.
Пул соединений в Nodemailer: одно рукопожатие на сотню писем
В Nodemailer пул встроен, его надо только включить и правильно настроить.
javascript
const transporter = nodemailer.createTransport({
host: 'smtp.evilmail.pro',
port: 587, // submission + STARTTLS
secure: false, // true только для 465 (implicit TLS)
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
pool: true,
maxConnections: 5, // параллельные соединения
maxMessages: 80, // писем на соединение, затем reconnect
rateDelta: 1000, // окно троттлинга, мс
rateLimit: 20, // не больше 20 писем за это окно
connectionTimeout: 10000,
greetingTimeout: 20000,
socketTimeout: 45000,
});
// verify() обязателен на старте — падаем громко, а не на первом письме
await transporter.verify();
maxConnections: 5 — это параллелизм: пять живых соединений, между которыми размазывается очередь. maxMessages (дефолт 100) заставляет Nodemailer самому закрыть соединение после N писем и открыть новое. Это важнее, чем кажется: почти все MX рвут сессию после ~100 писем, и лучше опередить их своим чистым QUIT, чем ловить обрыв на середине. Держите maxMessages заведомо ниже лимита провайдера — 80 против 100 даёт запас.
rateDelta + rateLimit — троттлинг на стороне отправителя. Пара 1000 / 20 означает «не больше 20 писем в секунду», и это часто спасает от тех самых 421.
Отдельно про backpressure. Если сыпать в пул задачи быстрее, чем он их вывозит, они копятся в памяти процесса. Nodemailer даёт событие 'idle' и метод transporter.isIdle() — используйте их, чтобы подавать новую порцию только когда пул готов:
javascript
async function drain(recipients, buildMail) {
let i = 0;
return new Promise((resolve, reject) => {
const pump = () => {
while (transporter.isIdle() && i < recipients.length) {
const to = recipients[i++];
transporter.sendMail(buildMail(to)).catch(reject);
}
if (i >= recipients.length) resolve();
};
transporter.on('idle', pump);
pump();
});
}
Три таймаута, а не один
Деталь, которую пропускают почти все: у Nodemailer три разных таймаута, и все три по умолчанию рассчитаны на что угодно, только не на продакшн-отправку через внешний SMTP.
connectionTimeout — дефолт 120000 мс (2 минуты) на установку TCP. Для внешнего SMTP ставьте 10000–15000. Если за 15 секунд не подключились — это чёрная дыра, ждать бессмысленно.
greetingTimeout — дефолт 30000 мс. Сколько ждём баннер 220 после коннекта. Именно тут ловятся greylisting и подвисшие релеи. 20000 — разумно.
socketTimeout — дефолт 600000 мс, то есть десять минут простоя сокета. Это самый опасный дефолт: повисшее соединение держит слот в пуле все десять минут, и под нагрузкой пул деградирует до нуля рабочих слотов. Режьте до 30000–45000.
Ретраи по уму: 4xx лечим, 5xx хороним
Главная логическая ошибка в отправке — ретраить всё подряд либо не ретраить вовсе. Между ними лежит вся разница между рабочей рассылкой и генератором дубликатов.
Классификация по SMTP-коду:
2xx (250) — принято, готово.
4xx — временный отказ. 421 (service not available), 450/451 (mailbox busy / local error), 452 (over quota на сессии). Это надо ретраить — сервер прямо говорит «попробуй позже».
5xx — постоянный отказ. 550 (mailbox unavailable), 552 (over quota), 554 (transaction failed). Ретраить бессмысленно: адрес не станет валиднее от повтора. Такое сразу в dead-letter.
Отдельная история — greylisting. Первый 450/451 с формулировкой «try again later» — это норма, а не сбой. Сервер намеренно отбивает первую попытку, чтобы отсеять спамеров, которые не ретраят. Подождите 60–300 секунд и повторите — второй раз пройдёт.
Backoff обязательно с джиттером, иначе тысяча писем, упавших одновременно, ретраятся синхронно и создают thundering herd: sleep = min(base * 2^attempt, cap) + random_jitter. Типично base=1s, cap=300s, до 5 попыток.
Ретраить надо на уровне получателя, а не всего батча. Из ответа sendMail берите accepted[], rejected[] и rejectedErrors[] — повторяйте только по rejected. И задавайте свой Message-ID: тогда ретрай, доехавший после того как первая попытка всё-таки прошла, будет дедуплицирован провайдером, а не превратится в дубль.
smtplib.SMTP не пулит ничего. Каждый объект — это ровно одно соединение, и по типовым туториалам его открывают и закрывают на каждое письмо. Синхронный правильный паттерн — держать один объект живым и перед каждой отправкой проверять его через noop():
Обратите внимание на таймаут: в smtplib он задаётся только в конструкторе и действует на весь сокет целиком. Никакой гранулярности уровня Nodemailer (отдельно connection / greeting / socket) тут нет, поэтому ставьте его жёстче — 15 секунд, а не 60.
И запомните ehlo() после starttls(): до шифрования сервер анонсирует один набор возможностей, после — другой (в частности, AUTH часто появляется только внутри TLS). Пропустите повторный EHLO — и login() упадёт на ровном месте.
Для асинхронной отправки берём aiosmtplib и строим собственный пул на asyncio.Queue живых соединений плюс воркеры:
Тот же принцип, что и в Nodemailer: считаем письма на соединении, при достижении лимита закрываем его сами. Для порта 465 используйте use_tls=True (implicit TLS), для 587 — start_tls=True.
Корректное закрытие: QUIT против обрыва
Разница между QUIT и брошенным сокетом не косметическая. QUIT — это чистое завершение, сервер фиксирует нормальный конец сессии. Брошенный сокет провайдер видит как незакрытую, оборванную сессию, и накопление таких обрывов бьёт по репутации отправителя ровно так же, как всплеск новых коннектов.
В Node transporter.close() дренит пул — дожидается активных отправок и корректно закрывает соединения. Главное — вызвать его по сигналу, а не дать оркестратору убить процесс:
javascript
let draining = false;
async function shutdown() {
if (draining) return;
draining = true;
stopAcceptingJobs(); // новые задачи не берём
await Promise.race([
waitForInflight(), // ждём текущие отправки
new Promise(r => setTimeout(r, 25000)), // дедлайн под Docker grace
]);
transporter.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Дедлайн в 25 секунд подобран под стандартный 30-секундный grace period Docker (после которого прилетает SIGKILL) и под остановку PM2. Логика та же: перестать принимать новое, дождаться in-flight, закрыть пул. В Python — try/finally вокруг отправки и pool.close() / server.quit() на завершении.
Что будет, если процесс убьют посреди DATA? Письмо уйдёт частично, сервер его отбросит, но при следующем запуске задача может отправиться повторно — и вот тут вас спасает заранее заданный Message-ID: провайдер дедуплицирует, и дубль не дойдёт до ящика.
Чеклист продакшн-отправки
Пул включён (pool: true), verify() на старте зелёный.
Три таймаута урезаны: connectionTimeout 10–15 с, greetingTimeout 20 с, socketTimeout 30–45 с.
maxMessages ниже лимита провайдера (~80), соединение закрываем сами.
4xx и 5xx разведены: временное — ретрай, постоянное — dead-letter.
Backoff экспоненциальный, обязательно с джиттером; ретрай на уровне получателя, не батча.
Фиксированный Message-ID для идемпотентности.
Dead-letter очередь для писем, исчерпавших попытки.
Graceful shutdown по SIGTERM/SIGINT с дедлайном под grace period.
SPF, DKIM и DMARC настроены — без них пул и ретраи бесполезны, письма всё равно уйдут в спам.
Отдельный IP или сабдомен под транзакционную почту; мониторинг по rejected и responseCode.
Пул, ретраи и таймауты — это инфраструктура, которую в каждом сервисе приходится собирать заново и одинаково. На evilmail.pro эта часть уже закрыта на стороне релея: пулинг, классификация 4xx/5xx и повторы живут в SMTP-шлюзе, а приложение просто отдаёт письмо и получает предсказуемый ответ.