Почему `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 пул встроен, его надо только включить и правильно настроить.
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() — используйте их, чтобы подавать новую порцию только когда пул готов:
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: тогда ретрай, доехавший после того как первая попытка всё-таки прошла, будет дедуплицирован провайдером, а не превратится в дубль.
function classify(err) {
const code = err.responseCode; // SMTP-код
if (code >= 500) return 'permanent'; // 5xx → dead-letter
if (code >= 400) return 'transient'; // 4xx → retry
if (['ECONNRESET','ETIMEDOUT','ESOCKET','EAI_AGAIN'].includes(err.code))
return 'transient'; // сеть/DNS → retry
return 'permanent';
}
async function sendWithRetry(mail, max = 5) {
for (let attempt = 0; attempt < max; attempt++) {
try { return await transporter.sendMail(mail); }
catch (err) {
if (classify(err) === 'permanent' || attempt === max - 1) {
await deadLetter(mail, err);
throw err;
}
const wait = Math.min(1000 * 2 ** attempt, 300000) + Math.random() * 1000;
await new Promise(r => setTimeout(r, wait));
}
}
}Python: пула нет, строим сами
smtplib.SMTP не пулит ничего. Каждый объект — это ровно одно соединение, и по типовым туториалам его открывают и закрывают на каждое письмо. Синхронный правильный паттерн — держать один объект живым и перед каждой отправкой проверять его через noop():
import smtplib, ssl
class SmtpSession:
def __init__(self, host, port=587, timeout=15):
self.host, self.port, self.timeout = host, port, timeout
self.srv = None
def _connect(self):
self.srv = smtplib.SMTP(self.host, self.port, timeout=self.timeout)
self.srv.ehlo()
self.srv.starttls(context=ssl.create_default_context())
self.srv.ehlo() # обязательный повторный EHLO после STARTTLS
self.srv.login(USER, PASS)
def healthy(self):
try:
return self.srv and self.srv.noop()[0] == 250
except smtplib.SMTPException:
return False
def send(self, msg):
if not self.healthy():
self._connect()
self.srv.send_message(msg)Обратите внимание на таймаут: в smtplib он задаётся только в конструкторе и действует на весь сокет целиком. Никакой гранулярности уровня Nodemailer (отдельно connection / greeting / socket) тут нет, поэтому ставьте его жёстче — 15 секунд, а не 60.
И запомните ehlo() после starttls(): до шифрования сервер анонсирует один набор возможностей, после — другой (в частности, AUTH часто появляется только внутри TLS). Пропустите повторный EHLO — и login() упадёт на ровном месте.
Для асинхронной отправки берём aiosmtplib и строим собственный пул на asyncio.Queue живых соединений плюс воркеры:
import asyncio, aiosmtplib
from email.message import EmailMessage
class AioSmtpPool:
def __init__(self, host, port=587, size=5, max_msgs=80):
self.host, self.port, self.size, self.max_msgs = host, port, size, max_msgs
self.q = asyncio.Queue()
async def start(self):
for _ in range(self.size):
await self.q.put(await self._open())
async def _open(self):
c = aiosmtplib.SMTP(hostname=self.host, port=self.port,
start_tls=True, timeout=15)
await c.connect()
await c.login(USER, PASS)
return {"conn": c, "sent": 0}
async def send(self, msg: EmailMessage):
slot = await self.q.get()
try:
if slot["sent"] >= self.max_msgs or not slot["conn"].is_connected:
await slot["conn"].quit()
slot = await self._open()
await slot["conn"].send_message(msg)
slot["sent"] += 1
finally:
await self.q.put(slot)
async def close(self):
while not self.q.empty():
slot = await self.q.get()
try: await slot["conn"].quit()
except Exception: passТот же принцип, что и в Nodemailer: считаем письма на соединении, при достижении лимита закрываем его сами. Для порта 465 используйте use_tls=True (implicit TLS), для 587 — start_tls=True.
Корректное закрытие: QUIT против обрыва
Разница между QUIT и брошенным сокетом не косметическая. QUIT — это чистое завершение, сервер фиксирует нормальный конец сессии. Брошенный сокет провайдер видит как незакрытую, оборванную сессию, и накопление таких обрывов бьёт по репутации отправителя ровно так же, как всплеск новых коннектов.
В Node transporter.close() дренит пул — дожидается активных отправок и корректно закрывает соединения. Главное — вызвать его по сигналу, а не дать оркестратору убить процесс:
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()на старте зелёный. - Три таймаута урезаны:
connectionTimeout10–15 с,greetingTimeout20 с,socketTimeout30–45 с. maxMessagesниже лимита провайдера (~80), соединение закрываем сами.- 4xx и 5xx разведены: временное — ретрай, постоянное — dead-letter.
- Backoff экспоненциальный, обязательно с джиттером; ретрай на уровне получателя, не батча.
- Фиксированный
Message-IDдля идемпотентности. - Dead-letter очередь для писем, исчерпавших попытки.
- Graceful shutdown по
SIGTERM/SIGINT
Пул, ретраи и таймауты — это инфраструктура, которую в каждом сервисе приходится собирать заново и одинаково. На evilmail.pro эта часть уже закрыта на стороне релея: пулинг, классификация 4xx/5xx и повторы живут в SMTP-шлюзе, а приложение просто отдаёт письмо и получает предсказуемый ответ.


