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