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.
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.
// 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
}
);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.