Надсилання електронної пошти через BullMQ у NestJS
У сучасних вебзастосунках надсилання електронних листів є звичайною справою — підтвердження пошти, скидання пароля, повідомлення користувачам тощо. Але якщо це робити синхронно (тобто прямо в обробнику запиту), ви ризикуєте:
- уповільнити відповідь клієнту,
- отримати помилку через збій SMTP або перевищення тайм-ауту,
- створити вузьке місце в масштабованості системи.
Щоб цього уникнути, ми можемо делегувати надсилання листів у фонову чергу, де за це відповідатиме окремий воркер. У цьому блозі я покажу, як реалізувати таке рішення на базі NestJS, BullMQ, Redis та @react-email/components для шаблонів.
Переваги використання воркера для надсилання листів
BullMQ — це потужна черга задач на базі Redis. Вона дозволяє:
- обробляти листи асинхронно (поза межами HTTP-запиту),
- автоматично повторювати задачі у разі помилки,
- масштабувати процес через окремі воркери,
- логувати успішні/невдалі надсилання,
- налаштовувати пріоритети, затримки, обмеження на кількість обробок.
1. Конфігурація BullMQ (Redis)
Почнемо з налаштування BullMQ через bullmq.config.ts
:
import { ConfigService } from '@nestjs/config'
import type { QueueOptions } from 'bullmq'
export function getBullmqConfig(configService: ConfigService): QueueOptions {
return {
connection: {
username: configService.getOrThrow<string>('REDIS_USER'),
host: configService.getOrThrow<string>('REDIS_HOST'),
port: configService.getOrThrow<number>('REDIS_PORT'),
password: configService.getOrThrow<string>('REDIS_PASSWORD'),
maxRetriesPerRequest: null,
retryStrategy: times => Math.min(times * 50, 2000)
},
prefix: configService.getOrThrow<string>('QUEUE_PREFIX')
}
}
Цей конфіг дозволяє кастомізувати з'єднання з Redis — ми беремо всі параметри з .env
, додаємо retry-стратегію, а також задаємо префікс для назв черг, щоб розділяти їх між мікросервісами.
2. Сервіс для пошти: MailService
У сервісі ми формуємо шаблон листа через @react-email/components
, а саму задачу відправляємо в чергу:
await this.queue.add(
'send-email',
{ email: user.email, subject: 'Верифікація пошти', html },
{ removeOnComplete: true }
)
Так ми гарантуємо, що сама поштова логіка буде оброблена пізніше і воркером.
Сервіс виглядає так:
@Injectable()
export class MailService {
public constructor(
private readonly mailerService: MailerService,
@InjectQueue('mail') private readonly queue: Queue
) {}
public async sendEmailVerification(user: User, token: string) {
const html = await render(EmailVerificationTemplate({ user, token }))
await this.queue.add('send-email', { email: user.email, subject: 'Верифікація пошти', html }, { removeOnComplete: true })
return true
}
public async sendPasswordReset(user: User, token: string) {
const html = await render(ResetPasswordTemplate({ user, token }))
await this.queue.add('send-email', { email: user.email, subject: 'Скидання пароля', html }, { removeOnComplete: true })
return true
}
public sendMail(email: string, subject: string, html: string) {
return this.mailerService.sendMail({ to: email, subject, html })
}
}
3. Воркер (процесор): MailProcessor
Воркер — це той самий "робітник", який слухає чергу "mail"
і обробляє задачі типу "send-email"
:
@Processor('mail')
@Injectable()
export class MailProcessor extends WorkerHost {
private readonly logger = new Logger(MailProcessor.name)
public constructor(private readonly mailService: MailService) {
super()
}
public async process(job: Job<{ email: string; subject: string; html: string }>): Promise<void> {
const { email, subject, html } = job.data
try {
await this.mailService.sendMail(email, subject, html)
this.logger.log(`📧 Email successfully sent to ${email}`)
} catch (error) {
this.logger.error(`❌ Error sending email to ${email}: ${error.message}`)
}
}
}
4. Модуль: MailModule
У модулі ми підключаємо MailerModule та BullModule, а також реєструємо наш воркер:
@Global()
@Module({
imports: [
MailerModule.forRootAsync({
imports: [ConfigModule],
useFactory: getMailerConfig,
inject: [ConfigService]
}),
BullModule.registerQueue({
name: 'mail'
})
],
providers: [MailService, MailProcessor],
exports: [MailService]
})
export class MailModule {}
Модуль @nestjs/bullmq
автоматично підключає Redis, створює чергу та запускає воркер — тобто нам не потрібно вручну створювати Queue, Worker чи QueueScheduler.
5. Підтримка SWC у NestJS
Якщо ти використовуєш swc
як компілятор (для швидшої збірки), потрібно правильно налаштувати nest-cli.json
. Інакше BullMQ може не знайти процесори (через особливості трансформації).
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": {
"type": "swc",
"options": {
"swcrcPath": "/.swcrc.json",
"extensions": [".js", ".ts", ".jsx", ".tsx"]
}
},
"typeCheck": true
}
}
6. Шаблони листів через React
Замість шаблонів у форматі .hbs
чи .mjml
, я використовую @react-email/components — вони дозволяють будувати шаблони на React, використовувати Tailwind і навіть рендерити прев’ю в dev mode. Наприклад, шаблон для скидання пароля виглядає ось так:
export function ResetPasswordTemplate({ user, token }: ResetPasswordTemplateProps) {
const resetLink = `${baseUrl}/auth/recovery/${token}`
return (
<Html>
<Head>
<Font
fontFamily='Geist'
webFont={{ url: 'https://fonts.googleapis.com/css2?family=Geist', format: 'woff2' }}
/>
</Head>
<Tailwind>
<Body>
<Preview>Скидання пароля</Preview>
<Container className='...'>
<Heading>Скидання пароля</Heading>
<Text>Привіт, {user.displayName}!</Text>
<Button href={resetLink}>Скинути пароль</Button>
</Container>
</Body>
</Tailwind>
</Html>
)
}
Підсумок
Цей підхід дозволяє нам:
- відділити відправку листів від основного потоку логіки,
- уникати затримок і тайм-аутів,
- масштабувати обробку email-повідомлень,
- легко кастомізувати шаблони і логіку надсилання.
Кінець!)