В Node нет встроенной работы с почтой, поэтому отправка письма — это разговор по SMTP, а на практике — Nodemailer, на котором построены почтовые интеграции всех Node-фреймворков. Ниже — настройка на fmailer, HTML-письма, вложения, рассылка через пул соединений и повторы при сбоях.
| Параметр | Значение |
|---|---|
| SMTP-хост | smtp.fmailer.ru |
| Порт | 587 |
| Шифрование | STARTTLS (соединение переводится в TLS до авторизации) |
| Логин | Логин SMTP-токена вашего домена |
| Пароль | Пароль этого токена — показывается один раз, при создании |
npm install nodemailer// mailer.js
import nodemailer from "nodemailer";
export const transporter = nodemailer.createTransport({
host: "smtp.fmailer.ru",
port: 587,
secure: false, // на 587 соединение поднимается в TLS через STARTTLS
requireTLS: true, // и не отправится вовсе, если поднять не удалось
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS, // параметр называется "pass", не "password"
},
});Или с шифрованием с первого байта на порту 465:
// Порт 465 — шифрование с первого байта.
const transporter = nodemailer.createTransport({
host: "smtp.fmailer.ru",
port: 465,
secure: true,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});password: означает попытку авторизации с пустым паролем, а сервер отвечает 535 Authentication failed. Проверяйте это в первую очередь. verify() подключается и авторизуется, ничего не отправляя, — неправильная настройка обнаружится при старте, а не перед пользователем:
try {
await transporter.verify();
console.log("SMTP готов");
} catch (err) {
console.error("Конфигурация SMTP сломана:", err.message);
process.exit(1);
} Задавайте и text, и html: Nodemailer соберёт multipart/alternative, а HTML без текстовой части почтовые фильтры считают признаком спама:
const info = await transporter.sendMail({
from: '"Магазин" <noreply@mail.example.ru>',
to: "client@example.ru",
subject: "Подтверждение заказа №4417",
text: "Заказ принят. Доставка — завтра до 18:00.",
html: "<h1>Заказ принят</h1><p>Доставка — завтра до 18:00.</p>",
});
console.log("Принято:", info.accepted);
console.log("Идентификатор письма:", info.messageId); В объекте info возвращается то, что принял сервер: accepted, rejected, response и messageId. Логируйте messageId — по нему потом сопоставляются вебхуки о доставке и отказах.
await transporter.sendMail({
from: '"Магазин" <noreply@mail.example.ru>',
to: "client@example.ru",
subject: "Счёт на оплату",
text: "Счёт за март во вложении.",
html: '<p>Счёт во вложении.</p><img src="cid:logo" width="120">',
attachments: [
{ filename: "invoice.pdf", path: "/srv/invoices/2026-03.pdf" },
{ filename: "report.csv", content: csvString },
{ filename: "logo.png", path: "./assets/logo.png", cid: "logo" },
],
}); Переподключение на каждое письмо медленно и рано или поздно упрётся в ограничение скорости. Включите пул и управляйте им по событию idle:
const bulk = nodemailer.createTransport({
host: "smtp.fmailer.ru",
port: 587,
requireTLS: true,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
pool: true,
maxConnections: 5, // параллельных соединений
maxMessages: 100, // писем на соединение до переподключения
rateDelta: 1000,
rateLimit: 10, // не больше 10 писем в секунду
});
bulk.on("idle", () => {
while (bulk.isIdle() && queue.length) {
const user = queue.shift();
bulk.sendMail({
from: '"Магазин" <noreply@mail.example.ru>',
to: user.email,
subject: "Еженедельный отчёт",
text: renderReport(user),
}).catch((err) => console.error(user.email, err.message));
}
});Ответ 4xx или оборванный сокет — временная проблема; 5xx — окончательный, и повторять его — значит только портить репутацию домена:
async function sendWithRetry(message, attempts = 3) {
for (let i = 1; i <= attempts; i++) {
try {
return await transporter.sendMail(message);
} catch (err) {
// Ответы 4xx и обрывы сокета временные, 5xx — окончательные.
const permanent = err.responseCode >= 500 && err.responseCode < 600;
if (permanent || i === attempts) throw err;
await new Promise((r) => setTimeout(r, 2 ** i * 1000));
}
}
}| Код | Причина и решение |
|---|---|
EAUTH | pass написан как password, либо неверные доступы. |
ETIMEDOUT, ECONNREFUSED | Порт заблокирован. Используйте 8587 или 8465. |
ESOCKET — wrong version number | secure: false на порту 465. Поставьте true. |
| Зависает без ошибки | secure: true на порту 587. Нужен requireTLS. |
EENVELOPE | Домен отправителя не подтверждён либо адрес получателя некорректен. |
EMESSAGE — 552 | Письмо слишком большое. Выложите вложение и дайте ссылку. |
secure: true — сокет шифруется с первого байта, это порт 465. secure: false открывает незашифрованное соединение и вместе с requireTLS: true обязан поднять его через STARTTLS до отправки — это порт 587. ignoreTLS вообще пропускает шифрование и отправляет доступы открытым текстом, использовать его не нужно.
Чаще всего в конфигурации написано auth.password вместо auth.pass. Незнакомый ключ Nodemailer игнорирует и авторизуется с пустым паролем. Если ключ верный — значит неверны логин или пароль токена.
Нет. Каждый createTransport с отправкой открывает TCP-соединение и выполняет TLS-рукопожатие. Создайте транспорт один раз на уровне модуля и переиспользуйте. Для больших объёмов включите pool: true — тогда набор соединений держится открытым.
SMTP-серверы ограничивают число писем на соединение, поэтому длинный цикл рано или поздно получит отказ. С pool: true задайте maxMessages, чтобы Nodemailer переоткрывал соединение сам, и rateLimit, чтобы не выходить за допустимую скорость.
Передайте массив attachments. В каждом элементе укажите filename и одно из: path (файл на диске), content (строка, Buffer или поток) либо href (удалённый адрес). Поле cid позволяет вставить картинку прямо в HTML через <img src="cid:имя">.
Обычно это secure: true на порту 587 — клиент ждёт TLS-рукопожатия, которого сервер не начинает. На 587 нужен secure: false с requireTLS: true, либо переходите на порт 465 с secure: true.