Email Queue
Sending an email inside an HTTP request makes the response wait on a mail server and loses the email if the process dies. The queue is one of three optional ways to deliver; see direct, queue or outbox to choose. MailerQueueModule hands emails to a BullMQ queue backed by Redis instead: a worker sends them in the background, BullMQ retries failed jobs, and emails can be scheduled for later. In v4, permanent failures fail the job at once, redeliveries keep the same Message-ID, and schedule() and concurrency are new.
Setup
Install dependencies:
pnpm add bullmqImport MailerQueueModule alongside MailerModule:
import { MailerModule, MailerQueueModule } from '@nestjs-modules/mailer';
@Module({
imports: [
MailerModule.forRoot({
transport: { host: 'smtp.example.com', port: 587 },
}),
MailerQueueModule.register({
// Set `global: true` so `MailerQueueService` can be injected in any
// feature module without re-importing `MailerQueueModule`.
global: true,
connection: {
host: 'localhost',
port: 6379,
},
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 100,
removeOnFail: 500,
},
}),
],
})
export class AppModule {}Enqueuing Emails
Use MailerQueueService instead of MailerService.sendMail():
import { MailerQueueService } from '@nestjs-modules/mailer';
@Injectable()
export class NotificationService {
constructor(private readonly queueService: MailerQueueService) {}
async sendWelcomeEmail(user: User) {
await this.queueService.enqueue({
to: user.email,
subject: 'Welcome!',
template: 'welcome',
context: { name: user.name },
});
}
}Scheduling
schedule() sends an email at a given time. A time in the past sends it right away:
await this.queueService.schedule(
{
to: user.email,
subject: 'Your trial ends tomorrow',
template: 'trial-ending',
context: { name: user.name },
},
new Date(user.trialEndsAt.getTime() - 24 * 60 * 60 * 1000),
);It takes the same job options as enqueue() as a third argument; delay is computed from the date.
Batch Enqueue
Add multiple emails to the queue at once:
await this.queueService.enqueueBatch(
users.map(user => ({
to: user.email,
subject: 'System Update',
template: 'notification',
context: { name: user.name },
})),
);Job Options
Override defaults per job:
// High priority with delay
await this.queueService.enqueue(
{ to: 'vip@example.com', subject: 'VIP Alert', template: 'vip' },
{ priority: 1, delay: 5000 },
);
// More retries for critical emails
await this.queueService.enqueue(
{ to: 'admin@example.com', subject: 'Report', template: 'report' },
{ attempts: 10 },
);Queue Metrics
Monitor queue health:
const metrics = await this.queueService.getMetrics();
console.log(metrics);
// { waiting: 5, active: 2, completed: 150, failed: 3, delayed: 1 }Concurrency
The worker sends up to 5 emails at once by default. Set concurrency to change it:
MailerQueueModule.register({
connection: { host: 'localhost', port: 6379 },
concurrency: 20,
})Async Configuration
MailerQueueModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.get('REDIS_HOST'),
port: config.get('REDIS_PORT'),
},
}),
})Retries and permanent failures
BullMQ owns the retries of queued emails, so attempts are never multiplied:
- Jobs are sent with
retry: false, so the mailer's own retries are off for them (unless the job data setsretryitself). Failover between transporters still applies within each attempt. - A permanent failure (a rejected recipient, a template error, see what is retried) is rethrown as BullMQ's
UnrecoverableError, with the original error ascause. The job fails at once instead of using up its attempts. enqueue(),enqueueBatch()andschedule()store a randomidempotencyKeywith every job (unless the email already has one), so every retry of the job carries the sameMessage-ID, mailbox providers can drop a duplicate, and duplicate protection never blocks a retry. Two jobs never share a key, even after BullMQ reuses a removed job's id.- For jobs added to the queue directly (not through
MailerQueueService), the worker falls back tomailer-job:<queue name>:<job id>:<job timestamp>.
Mail classes are not queued directly, because job data must be serializable. Enqueue the plain options instead, or render the class first:
const mail = await this.mailerService.render(WelcomeMail, { data: user });
await this.queueService.enqueue({
to: user.email,
subject: mail.subject,
html: mail.html,
text: mail.text,
});Retry Strategy
The default exponential backoff retries with increasing delays:
| Attempt | Delay |
|---|---|
| 1st retry | 1s |
| 2nd retry | 2s |
| 3rd retry | 4s |
Use fixed backoff for constant delay:
defaultJobOptions: {
attempts: 5,
backoff: { type: 'fixed', delay: 30000 }, // 30s between retries
}INFO
The queue module is completely optional and independent from MailerModule. It requires Redis and bullmq to be installed.