В ядре NestJS почтового модуля нет, но @nestjs-modules/mailer оборачивает Nodemailer в привычную модель внедрения зависимостей и добавляет отрисовку шаблонов. Ниже — настройка на fmailer, шаблоны Handlebars и вынос отправки в очередь.
| Параметр | Значение |
|---|---|
| SMTP-хост | smtp.fmailer.ru |
| Порт | 587 |
| Шифрование | STARTTLS (соединение переводится в TLS до авторизации) |
| Логин | Логин SMTP-токена вашего домена |
| Пароль | Пароль этого токена — показывается один раз, при создании |
npm install @nestjs-modules/mailer nodemailer handlebars
npm install -D @types/nodemailer Используйте forRootAsync, чтобы доступы пришли из ConfigService, а не читались в момент описания модуля, когда конфигурация ещё не загружена:
// 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.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;
}
}
}.hbs не попадают в dist/ и отправка падает с ENOENT — как правило, только в продакшене, где никто не запускается из src/. // nest-cli.json — файлы .hbs не компилируются, их нужно скопировать в dist
{
"compilerOptions": {
"assets": [{ "include": "mail/templates/**/*", "outDir": "dist" }],
"watchAssets": true
}
}// Отправка внутри запроса привязывает ответ к задержкам 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 — 535 | pass написан как password, либо неверные доступы. |
ETIMEDOUT в продакшене | Порт заблокирован. Используйте 8587 или 8465. |
Nest can't resolve dependencies of MailService | MailModule не импортирован или не экспортирует сервис. |
| Задачи из очереди не выполняются | Не запущен воркер либо недоступен Redis. |
Компилятор Nest переносит в dist только .js, поэтому шаблоны остаются в src и в сборке их нет. Добавьте в nest-cli.json запись assets, копирующую mail/templates в выходной каталог, и watchAssets, чтобы они обновлялись в разработке.
forRootAsync во всех случаях, когда доступы приходят из ConfigService или другого провайдера, то есть почти всегда. forRoot вычисляет объект в момент описания модуля, до загрузки конфигурации, поэтому прочитанные там переменные окружения часто оказываются undefined.
Хватит и Nodemailer: оберните транспорт в провайдер и внедряйте его. Модуль добавляет отрисовку шаблонов, значения по умолчанию и более удобную подмену в тестах. Если шаблоны не нужны, прямой подход обойдётся без лишней зависимости.
Переопределите MailerService в тестовом модуле заглушкой, у которой sendMail — это jest.fn(). Поскольку MailService зависит от внедрённого MailerService, а не от Nodemailer напрямую, сеть не задействуется и можно проверять переданные аргументы.
Поставьте отправку в очередь — обычно это BullMQ вместе с @nestjs/bullmq. Контроллер ставит задачу и возвращает ответ, а процессор отправляет письмо с автоматическими повторами и экспоненциальной задержкой, поэтому кратковременный сбой SMTP больше не превращается в ошибку 500.
Проверьте, не написано ли в транспорте auth.password вместо auth.pass — незнакомый ключ Nodemailer игнорирует и отправляет пустой пароль. Иначе убедитесь, что в username указан полный логин SMTP-токена домена.