Артур Доценко

Frontend Engineer

Надсилання електронної пошти через 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-повідомлень,
  • легко кастомізувати шаблони і логіку надсилання.

Кінець!)