Документация / Express.js

Отправка email в Express.js через SMTP

В Express нет своего почтового слоя, поэтому отправка письма — это Nodemailer, де-факто стандартный SMTP-клиент для Node.js. Ниже — подключение к fmailer, рабочий роут и всё остальное: HTML-письма, вложения и массовая рассылка через пул соединений.

Логин и пароль можно получить на странице управления токенами домена.

Параметры подключения

ПараметрЗначение
SMTP-хостsmtp.fmailer.ru
Порт587
ШифрованиеSTARTTLS (соединение переводится в TLS до авторизации)
ЛогинЛогин SMTP-токена вашего домена
ПарольПароль этого токена — показывается один раз, при создании
Каждый режим доступен и на высоком порту: 8465 (SSL/TLS), 8587 (STARTTLS) и 8025 (без шифрования). Многие хостинги и облака блокируют исходящие 25, 465 и 587 — если соединение отваливается по таймауту, используйте соответствующий высокий порт

Установка

bash
npm install nodemailer

Транспорт

Создавайте транспорт один раз на уровне модуля и импортируйте туда, где нужна отправка. Транспорт на каждый запрос — это новое TCP-соединение и TLS-рукопожатие каждый раз:

mailer.jsjavascript
// 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"
  },
});
Nodemailer игнорирует незнакомые ключи, поэтому password: приводит к попытке авторизации вообще без пароля, и сервер отвечает 535 Authentication failed. Это самая частая ошибка в конфигурации Nodemailer.

Для шифрования с первого байта на порту 465:

javascript
const transporter = nodemailer.createTransport({
  host: "smtp.fmailer.ru",
  port: 465,
  secure: true,         // шифрование с первого байта
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});

Доступы держите в переменных окружения, а не в исходниках:

.envproperties
SMTP_USER=<логин токена>
SMTP_PASS=<пароль токена>

Проверка при старте

verify() подключается и авторизуется, ничего не отправляя, — поэтому сломанная конфигурация обнаружится при запуске процесса, а не когда клиент отправит форму:

javascript
// Пусть конфигурация падает при старте, а не на первом клиенте.
await transporter.verify();
console.log("SMTP готов");

Отправка из роута

app.jsjavascript
// 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.

HTML-письмо

javascript
await transporter.sendMail({
  from: '"Магазин" <noreply@mail.example.ru>',
  to: "client@example.ru",
  subject: "Подтверждение заказа №4417",
  text: "Заказ принят. Доставка — завтра до 18:00.",   // текстовая часть нужна всегда
  html: "<h1>Заказ принят</h1><p>Доставка — завтра до 18:00.</p>",
});

Вложения и картинки в теле письма

javascript
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">
  ],
});

Массовая рассылка

javascript
// Держим несколько соединений открытыми вместо переподключения на каждое письмо.
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();
При использовании CC и BCC на каждого получателя внутри системы создаётся отдельное письмо, и лимиты расходуются соответственно.

Разбор ошибок

ОшибкаПричина и решение
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.

Частые вопросы

Почему Nodemailer выдаёт «Invalid login: 535 Authentication failed»?

Чаще всего дело в названии параметра: нужен auth.pass, а не auth.password. Незнакомый ключ Nodemailer молча игнорирует, поэтому на сервер уходит пустой пароль. Проверьте это до того, как подозревать сами доступы.

Что ставить в secure — true или false?

secure: false с портом 587 означает «начать открыто и подняться в TLS по STARTTLS»; добавьте requireTLS: true, чтобы письмо не ушло, если поднять шифрование не удалось. secure: true — это порт 465, где соединение шифруется сразу. secure: true на 587 приводит к зависанию рукопожатия.

Можно ли ставить rejectUnauthorized: false?

Нет. Это отключает проверку TLS-сертификата, то есть соединение становится доступным для перехвата, и заодно прячет настоящую причину — обычно устаревший набор корневых сертификатов или прокси. Валидному сертификату такой обход не нужен.

Почему на сервере таймаут, а локально всё работает?

Облачные провайдеры блокируют исходящие порты 25, 465 и 587. Используйте высокий порт — 8587 для STARTTLS или 8465 для шифрования с первого байта.

Нужно ли создавать транспорт на каждый запрос?

Нет — создайте его один раз на уровне модуля и переиспользуйте. Транспорт на запрос означает новое TCP-соединение и TLS-рукопожатие каждый раз. Для массовых рассылок включите pool: true, чтобы держать несколько соединений тёплыми.

Как отправить HTML-письмо из шаблона?

Отрисуйте HTML тем движком, который уже используется в проекте — EJS, Pug или Handlebars — и передайте результат в поле html. Обязательно задайте и text: письмо без текстовой части почтовые фильтры считают признаком спама.

Что дальше