Документация / NestJS

Отправка email в NestJS через SMTP

В ядре NestJS почтового модуля нет, но @nestjs-modules/mailer оборачивает Nodemailer в привычную модель внедрения зависимостей и добавляет отрисовку шаблонов. Ниже — настройка на fmailer, шаблоны Handlebars и вынос отправки в очередь.

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

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

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

Установка

bash
npm install @nestjs-modules/mailer nodemailer handlebars
npm install -D @types/nodemailer

Настройка MailerModule

Используйте forRootAsync, чтобы доступы пришли из ConfigService, а не читались в момент описания модуля, когда конфигурация ещё не загружена:

src/mail/mail.module.tsjavascript
// src/mail/mail.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { MailerModule } from "@nestjs-modules/mailer";
import { HandlebarsAdapter } from "@nestjs-modules/mailer/dist/adapters/handlebars.adapter";
import { join } from "path";
import { MailService } from "./mail.service";

@Module({
  imports: [
    MailerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        transport: {
          host: "smtp.fmailer.ru",
          port: 587,
          secure: false,        // STARTTLS на 587
          requireTLS: true,
          auth: {
            user: config.getOrThrow("SMTP_USER"),
            pass: config.getOrThrow("SMTP_PASS"),   // "pass", не "password"
          },
        },
        defaults: {
          from: '"Магазин" <noreply@mail.example.ru>',
        },
        template: {
          dir: join(__dirname, "templates"),
          adapter: new HandlebarsAdapter(),
          options: { strict: true },
        },
      }),
    }),
  ],
  providers: [MailService],
  exports: [MailService],
})
export class MailModule {}

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

Держите отправку за собственным сервисом: контроллеры будут зависеть от вашего доменного метода, а не от интерфейса мейлера, и подменить его в тестах станет тривиально:

src/mail/mail.service.tsjavascript
// src/mail/mail.service.ts
import { Injectable, Logger } from "@nestjs/common";
import { MailerService } from "@nestjs-modules/mailer";

@Injectable()
export class MailService {
  private readonly logger = new Logger(MailService.name);

  constructor(private readonly mailer: MailerService) {}

  async sendConfirmation(user: User, token: string): Promise<void> {
    try {
      await this.mailer.sendMail({
        to: user.email,
        subject: "Подтверждение адреса",
        template: "./confirmation",     // templates/confirmation.hbs
        context: {
          name: user.name,
          url: `https://example.ru/confirm?token=${token}`,
        },
      });
    } catch (error) {
      this.logger.error(`Письмо на ${user.email} не отправлено`, error);
      throw error;
    }
  }
}
Компилятор Nest выдаёт только JavaScript, поэтому файлы .hbs не попадают в dist/ и отправка падает с ENOENT — как правило, только в продакшене, где никто не запускается из src/.
nest-cli.jsonjson
// nest-cli.json — файлы .hbs не компилируются, их нужно скопировать в dist
{
  "compilerOptions": {
    "assets": [{ "include": "mail/templates/**/*", "outDir": "dist" }],
    "watchAssets": true
  }
}

Отправка через очередь

javascript
// Отправка внутри запроса привязывает ответ к задержкам SMTP.
// Кладём письмо в очередь BullMQ.
@Injectable()
export class UsersService {
  constructor(@InjectQueue("mail") private readonly mailQueue: Queue) {}

  async register(dto: RegisterDto): Promise<User> {
    const user = await this.repo.save(dto);
    await this.mailQueue.add(
      "confirmation",
      { userId: user.id },
      { attempts: 3, backoff: { type: "exponential", delay: 2000 } },
    );
    return user;
  }
}

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

ОшибкаПричина и решение
ENOENT на файле .hbs Шаблоны не скопированы в dist. Добавьте assets в nest-cli.json.
Доступы undefined при старте Использован forRoot вместо forRootAsync.
EAUTH — 535pass написан как password, либо неверные доступы.
ETIMEDOUT в продакшене Порт заблокирован. Используйте 8587 или 8465.
Nest can't resolve dependencies of MailServiceMailModule не импортирован или не экспортирует сервис.
Задачи из очереди не выполняютсяНе запущен воркер либо недоступен Redis.

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

Почему NestJS выдаёт ENOENT на файл шаблона .hbs?

Компилятор Nest переносит в dist только .js, поэтому шаблоны остаются в src и в сборке их нет. Добавьте в nest-cli.json запись assets, копирующую mail/templates в выходной каталог, и watchAssets, чтобы они обновлялись в разработке.

Что использовать — forRoot или forRootAsync?

forRootAsync во всех случаях, когда доступы приходят из ConfigService или другого провайдера, то есть почти всегда. forRoot вычисляет объект в момент описания модуля, до загрузки конфигурации, поэтому прочитанные там переменные окружения часто оказываются undefined.

Обязателен ли @nestjs-modules/mailer или хватит Nodemailer?

Хватит и Nodemailer: оберните транспорт в провайдер и внедряйте его. Модуль добавляет отрисовку шаблонов, значения по умолчанию и более удобную подмену в тестах. Если шаблоны не нужны, прямой подход обойдётся без лишней зависимости.

Как подменить мейлер в тестах NestJS?

Переопределите MailerService в тестовом модуле заглушкой, у которой sendMail — это jest.fn(). Поскольку MailService зависит от внедрённого MailerService, а не от Nodemailer напрямую, сеть не задействуется и можно проверять переданные аргументы.

Как не замедлять запросы отправкой писем?

Поставьте отправку в очередь — обычно это BullMQ вместе с @nestjs/bullmq. Контроллер ставит задачу и возвращает ответ, а процессор отправляет письмо с автоматическими повторами и экспоненциальной задержкой, поэтому кратковременный сбой SMTP больше не превращается в ошибку 500.

Почему авторизация падает с 535, хотя доступы выглядят верными?

Проверьте, не написано ли в транспорте auth.password вместо auth.pass — незнакомый ключ Nodemailer игнорирует и отправляет пустой пароль. Иначе убедитесь, что в username указан полный логин SMTP-токена домена.

Что дальше