В Express нет своего почтового слоя, поэтому отправка письма — это Nodemailer, де-факто стандартный SMTP-клиент для Node.js. Ниже — подключение к fmailer, рабочий роут и всё остальное: HTML-письма, вложения и массовая рассылка через пул соединений.
| Параметр | Значение |
|---|---|
| SMTP-хост | smtp.fmailer.ru |
| Порт | 587 |
| Шифрование | STARTTLS (соединение переводится в TLS до авторизации) |
| Логин | Логин SMTP-токена вашего домена |
| Пароль | Пароль этого токена — показывается один раз, при создании |
npm install nodemailerСоздавайте транспорт один раз на уровне модуля и импортируйте туда, где нужна отправка. Транспорт на каждый запрос — это новое TCP-соединение и TLS-рукопожатие каждый раз:
// mailer.js
import nodemailer from "nodemailer";
export const transporter = nodemailer.createTransport({
host: "smtp.fmailer.ru",
port: 587,
secure: false, // на 587 STARTTLS поднимается после подключения
requireTLS: true, // и без него письмо не уйдёт вовсе
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS, // именно "pass", а не "password"
},
});password: приводит к попытке авторизации вообще без пароля, и сервер отвечает 535 Authentication failed. Это самая частая ошибка в конфигурации Nodemailer. Для шифрования с первого байта на порту 465:
const transporter = nodemailer.createTransport({
host: "smtp.fmailer.ru",
port: 465,
secure: true, // шифрование с первого байта
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});Доступы держите в переменных окружения, а не в исходниках:
SMTP_USER=<логин токена>
SMTP_PASS=<пароль токена>verify() подключается и авторизуется, ничего не отправляя, — поэтому сломанная конфигурация обнаружится при запуске процесса, а не когда клиент отправит форму:
// Пусть конфигурация падает при старте, а не на первом клиенте.
await transporter.verify();
console.log("SMTP готов");// app.js
import express from "express";
import { transporter } from "./mailer.js";
const app = express();
app.use(express.json());
app.post("/api/contact", async (req, res) => {
const { email, message } = req.body;
try {
const info = await transporter.sendMail({
from: '"Магазин" <noreply@mail.example.ru>',
to: "sales@mail.example.ru",
replyTo: email, // From остаётся на вашем домене, ответ уйдёт клиенту
subject: "Заявка с сайта",
text: message,
});
res.json({ ok: true, messageId: info.messageId });
} catch (err) {
console.error("Письмо не отправлено", err);
res.status(502).json({ ok: false });
}
});
app.listen(3000); Обратите внимание на replyTo: адрес в from остаётся на вашем подтверждённом домене — именно для него опубликованы DKIM и SPF, — а ответ всё равно уйдёт тому, кто заполнил форму. Подстановка адреса посетителя в from и есть причина, по которой письма с форм не проходят DMARC.
await transporter.sendMail({
from: '"Магазин" <noreply@mail.example.ru>',
to: "client@example.ru",
subject: "Подтверждение заказа №4417",
text: "Заказ принят. Доставка — завтра до 18:00.", // текстовая часть нужна всегда
html: "<h1>Заказ принят</h1><p>Доставка — завтра до 18:00.</p>",
});await transporter.sendMail({
from: '"Магазин" <noreply@mail.example.ru>',
to: "client@example.ru",
subject: "Счёт на оплату",
text: "Счёт за март во вложении.",
attachments: [
{ filename: "invoice.pdf", path: "/srv/invoices/2026-03.pdf" },
{ filename: "report.csv", content: csvString },
{ filename: "logo.png", path: "./assets/logo.png", cid: "logo" }, // <img src="cid:logo">
],
});// Держим несколько соединений открытыми вместо переподключения на каждое письмо.
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,
rateLimit: 10, // не больше 10 писем в секунду
});
for (const user of users) {
await bulk.sendMail({
from: '"Магазин" <noreply@mail.example.ru>',
to: user.email, // один получатель на письмо
subject: "Еженедельный отчёт",
text: renderReport(user),
});
}
bulk.close();| Ошибка | Причина и решение |
|---|---|
EAUTH — Invalid login: 535 | Обычно password: вместо pass:. Иначе — неверные доступы токена. |
ETIMEDOUT, ECONNREFUSED | Порт заблокирован хостингом. Используйте 8587 или 8465. |
| Рукопожатие зависает без ошибки | secure: true на порту 587. Нужен secure: false плюс requireTLS: true. |
ESOCKET — wrong version number | Обратный случай: secure: false на порту 465. |
EENVELOPE — 550 | Домен отправителя не подтверждён или не покрывается токеном. |
Too many messages per connection | Рассылка в одном соединении. Включите pool: true и maxMessages. |
Чаще всего дело в названии параметра: нужен auth.pass, а не auth.password. Незнакомый ключ Nodemailer молча игнорирует, поэтому на сервер уходит пустой пароль. Проверьте это до того, как подозревать сами доступы.
secure: false с портом 587 означает «начать открыто и подняться в TLS по STARTTLS»; добавьте requireTLS: true, чтобы письмо не ушло, если поднять шифрование не удалось. secure: true — это порт 465, где соединение шифруется сразу. secure: true на 587 приводит к зависанию рукопожатия.
Нет. Это отключает проверку TLS-сертификата, то есть соединение становится доступным для перехвата, и заодно прячет настоящую причину — обычно устаревший набор корневых сертификатов или прокси. Валидному сертификату такой обход не нужен.
Облачные провайдеры блокируют исходящие порты 25, 465 и 587. Используйте высокий порт — 8587 для STARTTLS или 8465 для шифрования с первого байта.
Нет — создайте его один раз на уровне модуля и переиспользуйте. Транспорт на запрос означает новое TCP-соединение и TLS-рукопожатие каждый раз. Для массовых рассылок включите pool: true, чтобы держать несколько соединений тёплыми.
Отрисуйте HTML тем движком, который уже используется в проекте — EJS, Pug или Handlebars — и передайте результат в поле html. Обязательно задайте и text: письмо без текстовой части почтовые фильтры считают признаком спама.