Skip to content
Writing
NestJSBullMQRedisEnterprise

NestJS Enterprise Email Microservice with BullMQ & Redis Streams

Learn how to design an asynchronous email processor in NestJS with BullMQ that processes 50,000+ jobs/minute with guaranteed delivery and rate limiting.

The Decoupling Mandate: Why Synchronous Sends Kill Latency

In enterprise NestJS architectures, executing synchronous email requests inside HTTP controllers (such as user signup or invoice checkout) degrades customer experience. If the remote mail gateway experiences a 1.5s latency spike or temporary TCP timeout, the user HTTP connection hangs and risks timing out.

By decoupling email creation from execution using BullMQ and Redis streams, HTTP controllers acknowledge caller requests immediately by enqueuing a job. Asynchronous background worker processes then pull jobs, enforce tier rate limits, and execute dispatches with exponential backoff retries.

1. NestJS Worker Processor (email.processor.ts)

Using the @nestjs/bullmq module, the processor handles queued jobs, attaches correlation IDs, and safely communicates with SadaSend.

TYPESCRIPT
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

export interface EmailJobData {
  to: string;
  subject: string;
  html?: string;
  text: string;
  idempotencyKey?: string;
}

@Injectable()
@Processor('transactional-email', {
  concurrency: 25,
  limiter: { max: 100, duration: 1000 }, // 100 sends / second ceiling
})
export class EmailProcessor extends WorkerHost {
  private readonly logger = new Logger(EmailProcessor.name);
  private readonly apiKey: string;

  constructor(private readonly config: ConfigService) {
    super();
    this.apiKey = this.config.getOrThrow<string>('SADASEND_API_KEY');
  }

  async process(job: Job<EmailJobData>): Promise<{ id: string }> {
    this.logger.log(`Processing email job ${job.id} for recipient: ${job.data.to}`);

    const res = await fetch('https://api.sadasend.com/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': job.data.idempotencyKey || `job-${job.id}`,
      },
      body: JSON.stringify({
        to: job.data.to,
        subject: job.data.subject,
        html: job.data.html,
        text: job.data.text,
      }),
    });

    if (res.status === 403) {
      // Unrecoverable allowlist error: Do NOT retry
      const err = await res.json();
      this.logger.error(`Job ${job.id} blocked by security allowlist: ${err.message}`);
      throw new Error(`Security allowlist rejection: ${err.message}`);
    }

    if (!res.ok) {
      const errText = await res.text();
      this.logger.warn(`Job ${job.id} transient failure (${res.status}): ${errText}`);
      // Throw error to trigger BullMQ exponential retry
      throw new Error(`Upstream delivery failure: ${res.status}`);
    }

    const result = await res.json();
    return { id: result.id };
  }
}

2. Enqueuing with Dead Letter Queue (DLQ) Policies

Jobs must be configured with finite attempts and exponential backoff to avoid hammering remote services during network partitions. Permanently failed jobs are moved to a dead letter queue for SRE inspection.

TYPESCRIPT
// In your Auth or Order Service:
await this.emailQueue.add(
  'send-verification',
  {
    to: user.email,
    subject: 'Confirm your work email',
    text: `Verify your account: ${verificationUrl}`,
    idempotencyKey: `verify-${user.id}`,
  },
  {
    attempts: 5,
    backoff: {
      type: 'exponential',
      delay: 2000, // 2s, 4s, 8s, 16s, 32s
    },
    removeOnComplete: { count: 1000 },
    removeOnFail: false, // Keep in failed set for inspection
  }
);
Free plan

Building AI agents that send email?

Scoped API keys, per-key recipient allowlists, approval mode and a hosted MCP server with ten tools — on the free plan, without a card.