Designing an Email Delivery System: Templates, Queuing, Deliverability, and Bounce Handling at Scale
A production-focused guide to architecting email delivery: template engines, queue decoupling, provider abstraction, SPF/DKIM/DMARC, bounce handling, suppression lists, and failover patterns.
Email looks simple until you run it in production. You call an API, the message sends, the user gets it. That model holds for a few thousand emails per month with a clean list, a single provider, and no regulatory obligations. Scale to millions of recipients, add compliance requirements, inherit a list with unknown hygiene, or get a burst of bounces that tanks your sending reputation, and the simplicity evaporates. The failure modes are non-obvious, the feedback loops are slow, and the consequences of getting it wrong (IP blacklisting, GDPR violations, CAN-SPAM penalties) are severe. This article walks through how to architect an email delivery system that holds up under those conditions.
The Architecture at a Glance
Before diving into components, here is the overall flow:
Application Layer
|
v
Template Engine (MJML / React Email)
|
v
Queue Layer (BullMQ / SQS / Inngest)
|
v
Email Service (provider-agnostic abstraction)
|
+---> Primary Provider (SES / Postmark / Resend)
|
+---> Fallback Provider (on failure)
|
v
Webhook Handler (bounces, complaints, delivery events)
|
v
Suppression List + Reputation Monitor
The core principle is that sending email should never be on the critical path of an HTTP request. The application enqueues a job; a worker handles rendering, suppression checks, and delivery. This decoupling gives you retries, backpressure, and observability without coupling send latency to user-facing request latency.
Template Engine
Why Template Rendering Belongs in the Service Layer
Rendering templates inline in application code creates coupling between product logic and email formatting. Instead, keep templates as versioned assets and render them in the email service layer, passing only structured data.
The two dominant approaches in the TypeScript ecosystem are MJML (a markup language that compiles to responsive HTML) and React Email (JSX components that render to HTML). Both solve the same problem: email clients are a decade behind browsers in CSS support, and writing responsive HTML by hand is painful.
React Email fits naturally into TypeScript codebases because templates are typed components:
import { Html, Head, Body, Container, Text, Button } from "@react-email/components";
import { render } from "@react-email/render";
interface PasswordResetEmailProps {
userName: string;
resetUrl: string;
expiresInMinutes: number;
}
function PasswordResetEmail({ userName, resetUrl, expiresInMinutes }: PasswordResetEmailProps) {
return (
<Html>
<Head />
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f4f4f4" }}>
<Container style={{ maxWidth: "600px", margin: "0 auto", padding: "24px" }}>
<Text>Hi {userName},</Text>
<Text>
You requested a password reset. This link expires in {expiresInMinutes} minutes.
</Text>
<Button href={resetUrl} style={{ backgroundColor: "#0070f3", color: "#fff" }}>
Reset Password
</Button>
<Text style={{ fontSize: "12px", color: "#888" }}>
If you did not request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export function renderPasswordResetEmail(props: PasswordResetEmailProps): string {
return render(<PasswordResetEmail {...props} />);
}
Type safety here is not cosmetic. A mismatch between the data you enqueue and the props the template expects surfaces at compile time, not in a production email with a broken link.
Queue Layer
Decoupling Send from Request Path
The queue is the most important architectural decision in the system. Without it, a provider timeout blocks a user request. A spike in sends overwhelms the provider rate limit. Retries require the caller to handle them. A queue gives you all three for free.
The job payload should contain everything needed to render and send without hitting the database again:
interface EmailJob {
jobId: string; // idempotency key
to: string;
replyTo?: string;
template: string; // template identifier
data: Record<string, unknown>;
metadata: {
userId?: string;
category: "transactional" | "marketing";
campaignId?: string;
};
scheduledAt?: string; // ISO-8601, for delayed sends
}
Note the jobId field. Email delivery is not idempotent by nature. A worker that crashes after the provider accepts the message but before it marks the job complete will retry and send a duplicate. Using a stable jobId lets you implement idempotency at the provider level (some support idempotency keys) or track sent jobs in a database before delivering.
With BullMQ, a worker looks like:
import { Worker } from "bullmq";
import { renderTemplate } from "./templates";
import { emailService } from "./email-service";
import { isSuppressionListed } from "./suppression";
import { redis } from "./redis";
const worker = new Worker<EmailJob>(
"email",
async (job) => {
const { jobId, to, template, data, metadata } = job.data;
// Idempotency guard
const alreadySent = await redis.get(`email:sent:${jobId}`);
if (alreadySent) {
return { skipped: true, reason: "duplicate" };
}
// Suppression check before rendering
if (await isSuppressionListed(to)) {
return { skipped: true, reason: "suppressed" };
}
const html = renderTemplate(template, data);
const result = await emailService.send({ to, html, metadata });
// Mark sent with TTL long enough to cover retry window
await redis.set(`email:sent:${jobId}`, "1", "EX", 86400);
return result;
},
{
connection: redis,
concurrency: 10,
limiter: { max: 100, duration: 1000 }, // 100 sends/second
}
);
Set concurrency based on your provider’s rate limits, not your server’s capacity. Running 50 concurrent workers against a provider that limits you to 100 sends per second will generate a lot of 429 errors and retries that look like load but produce no throughput.
Provider Abstraction
Building a Provider-Agnostic Email Service
Locking into a single provider’s SDK couples your system to their API shape, authentication model, and failure modes. A thin abstraction lets you swap providers, route by category (transactional vs. marketing), or fail over without touching application code.
interface EmailProvider {
send(message: OutboundMessage): Promise<SendResult>;
name: string;
}
interface OutboundMessage {
to: string;
from: string;
replyTo?: string;
subject: string;
html: string;
text?: string; // plain-text fallback
headers?: Record<string, string>;
tags?: Record<string, string>;
}
interface SendResult {
messageId: string;
provider: string;
}
class SESProvider implements EmailProvider {
name = "ses";
private client: SESv2Client;
constructor() {
this.client = new SESv2Client({ region: process.env.AWS_REGION });
}
async send(message: OutboundMessage): Promise<SendResult> {
const command = new SendEmailCommand({
FromEmailAddress: message.from,
Destination: { ToAddresses: [message.to] },
Content: {
Simple: {
Subject: { Data: message.subject },
Body: {
Html: { Data: message.html },
Text: { Data: message.text ?? stripHtml(message.html) },
},
},
},
});
const response = await this.client.send(command);
return { messageId: response.MessageId ?? "", provider: this.name };
}
}
class EmailService {
constructor(
private primary: EmailProvider,
private fallback: EmailProvider
) {}
async send(message: OutboundMessage): Promise<SendResult> {
try {
return await this.primary.send(message);
} catch (err) {
console.error(`Primary provider ${this.primary.name} failed`, err);
// Alert and attempt fallback
metrics.increment("email.provider.fallback", { from: this.primary.name, to: this.fallback.name });
return this.fallback.send(message);
}
}
}
The fallback path should not be silent. Metric the event so you know when your primary is degraded. If fallback also fails, let the queue retry rather than swallowing the error.
Deliverability Engineering
SPF, DKIM, and DMARC
Deliverability is not a marketing concern. It is an infrastructure concern. Your emails can be syntactically correct, on-brand, and wanted by the recipient, and still land in spam if your DNS records are wrong or your sending IP has no reputation.
SPF (Sender Policy Framework) is a DNS TXT record that lists the IP addresses and services authorized to send on behalf of your domain. If you send through SES, you add their SPF include to your record. If you send through multiple providers, each needs to be in the record.
DKIM (DomainKeys Identified Mail) is a cryptographic signature attached to every outgoing message. The public key lives in DNS; the provider signs with the private key. Recipients verify the signature to confirm the message was not tampered with in transit. Every provider that sends on your behalf needs a DKIM key configured in your DNS.
DMARC (Domain-based Message Authentication, Reporting, and Conformance) builds on SPF and DKIM. It tells receiving servers what to do when a message fails authentication (none, quarantine, or reject) and where to send reports. Start with p=none to collect data, then graduate to p=quarantine once you are confident your legitimate send paths are all authenticated.
A minimal but production-ready DMARC record:
_dmarc.yourdomain.com TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@yourdomain.com; sp=reject; adkim=s; aspf=s; pct=100"
Parse and monitor the aggregate reports (rua). They will tell you about unauthorized senders using your domain, which is often how you discover a compromised credential or a misconfigured third-party integration.
IP Warming
A new dedicated IP with no history that suddenly sends 100,000 messages per day looks like a spammer to ISPs. Warm the IP gradually:
| Day Range | Daily Volume |
|---|---|
| 1-3 | 200-500 |
| 4-7 | 1,000-2,000 |
| 8-14 | 5,000-10,000 |
| 15-21 | 25,000-50,000 |
| 22-30 | 100,000+ |
During warming, send to your most engaged recipients first. High open rates on a new IP signal to ISPs that the mail is wanted. If you see bounce rates above 2% or complaint rates above 0.1% during warming, pause and investigate before continuing.
Reputation Monitoring
Monitor your sending reputation continuously, not reactively. Key signals to track:
- Bounce rate per domain and per send (target below 2%)
- Complaint rate via feedback loops from major ISPs (target below 0.1%)
- Spam trap hits (these indicate list hygiene problems)
- Blacklist presence (check MXToolbox or similar against your sending IPs daily)
Hook all of these into your observability stack. A bounce rate spike at 2am is only useful if it pages someone.
Bounce Handling
Hard Bounces vs. Soft Bounces
A hard bounce means the address does not exist or the domain rejects all mail permanently. The address must be added to your suppression list immediately and never sent to again. Continuing to send to hard-bounced addresses is one of the fastest ways to destroy IP reputation.
A soft bounce is a temporary failure: the mailbox is full, the server was unavailable, the message was too large. Soft bounces should be retried with exponential backoff. After a configurable threshold of consecutive soft bounces (typically 3-5), treat the address as a hard bounce.
interface BounceEvent {
type: "hard" | "soft";
email: string;
code: string; // SMTP response code, e.g. "550"
description: string;
timestamp: string;
messageId: string;
}
async function handleBounce(event: BounceEvent): Promise<void> {
await db.insert("email_bounce_events").values({
email: event.email,
type: event.type,
code: event.code,
description: event.description,
messageId: event.messageId,
occurredAt: new Date(event.timestamp),
});
if (event.type === "hard") {
await addToSuppressionList(event.email, "hard_bounce");
return;
}
// For soft bounces, check cumulative count
const recentSoftBounces = await db
.select()
.from("email_bounce_events")
.where(
and(
eq("email", event.email),
eq("type", "soft"),
gte("occurredAt", subDays(new Date(), 30))
)
);
if (recentSoftBounces.length >= 5) {
await addToSuppressionList(event.email, "repeated_soft_bounce");
}
}
Suppression Lists
The suppression list is your safety net. Before any email sends, the worker checks this list. It should include hard bounces, repeated soft bounces, spam complaints, and explicit unsubscribes.
async function addToSuppressionList(
email: string,
reason: "hard_bounce" | "repeated_soft_bounce" | "complaint" | "unsubscribe"
): Promise<void> {
await db
.insert("suppression_list")
.values({ email: email.toLowerCase(), reason, suppressedAt: new Date() })
.onConflictDoUpdate({
target: "email",
set: { reason, suppressedAt: new Date() },
});
// Invalidate any cache
await redis.del(`suppression:${email.toLowerCase()}`);
}
async function isSuppressionListed(email: string): Promise<boolean> {
const cacheKey = `suppression:${email.toLowerCase()}`;
const cached = await redis.get(cacheKey);
if (cached !== null) return cached === "1";
const result = await db
.select()
.from("suppression_list")
.where(eq("email", email.toLowerCase()))
.limit(1);
const suppressed = result.length > 0;
await redis.set(cacheKey, suppressed ? "1" : "0", "EX", 3600);
return suppressed;
}
Feedback Loops
Major ISPs (Yahoo, Hotmail) offer feedback loop programs: when a recipient marks a message as spam, the ISP forwards a complaint report to you. Register for these programs and suppress the address immediately. Gmail does not offer a traditional feedback loop, but Google Postmaster Tools provides domain and IP reputation data that you should monitor.
Compliance
CAN-SPAM and GDPR
CAN-SPAM requirements for commercial email: physical mailing address, working unsubscribe mechanism, honor requests within 10 business days, no deceptive subject lines. Transactional emails are exempt from most requirements, but the line between transactional and commercial is not always clear. When in doubt, include the unsubscribe footer.
GDPR adds a consent layer. For marketing emails, you need explicit consent and a record of it: when the user consented, what they consented to, from which UI. If they revoke consent, honor it everywhere in your system, including third-party providers where you may have synced the address.
Your unsubscribe handler should suppress the address immediately, not after a delay:
async function handleUnsubscribe(
email: string,
source: "link_click" | "list_unsubscribe_header" | "api"
): Promise<void> {
await addToSuppressionList(email, "unsubscribe");
await db.insert("unsubscribe_events").values({
email: email.toLowerCase(),
source,
occurredAt: new Date(),
});
// If syncing lists to external providers, queue removal
await emailListSyncQueue.add("remove-from-provider", { email, providers: ["mailchimp", "klaviyo"] });
}
Provider Comparison
| Dimension | Amazon SES | Postmark | Resend |
|---|---|---|---|
| Pricing | $0.10/1k (volume) | $1.50/1k (starter) | $0.80/1k |
| Transactional focus | Moderate | High | High |
| Deliverability tooling | Basic | Strong | Moderate |
| Bounce/complaint webhooks | Yes (SNS) | Yes | Yes |
| Dedicated IPs | Yes (add-on) | Yes (add-on) | No |
| SDK quality | Good (AWS SDK v3) | Good | Good |
| Warm-up support | Manual | Manual | Managed |
| Batch/bulk | Yes | Limited | Limited |
| Setup complexity | High (IAM, DNS) | Low | Low |
SES is the right choice when volume is high enough that per-email cost is the dominant concern and you have the operational capacity to manage IAM policies, SNS topics for webhooks, and dedicated IP pools. Postmark and Resend trade lower throughput economics for faster onboarding and stronger deliverability defaults, which makes them the right choice for most products until email volume justifies the SES operational overhead.
Production Considerations
Observability
Instrument every step: queue depth and age (alert if jobs are more than 5 minutes old), send success rate per provider, bounce rate per template and sending domain, complaint rate per campaign, suppression list growth rate, and provider latency per send. Use structured logs with a consistent schema so you can join events by messageId across the queue worker, the provider response, and the webhook handler.
Scaling the Queue
A single queue works until you need to prioritize. Transactional emails (password resets, receipts) should never wait behind a marketing batch job. Separate queues with separate concurrency limits:
const transactionalQueue = new Queue("email:transactional", { connection: redis });
const marketingQueue = new Queue("email:marketing", { connection: redis });
// Transactional worker: high concurrency, no rate limit beyond provider max
const transactionalWorker = new Worker("email:transactional", processEmail, {
connection: redis,
concurrency: 50,
});
// Marketing worker: low concurrency, rate limited to preserve IP reputation
const marketingWorker = new Worker("email:marketing", processEmail, {
connection: redis,
concurrency: 5,
limiter: { max: 20, duration: 1000 },
});
Provider Failover
Failover is not just about availability. Provider incidents are often category-specific: deliverability issues to a specific ISP, rate limit changes, authentication outages. Build your failover to route by failure type rather than binary up/down:
async function sendWithFailover(message: OutboundMessage): Promise<SendResult> {
try {
return await primary.send(message);
} catch (err) {
if (err instanceof RateLimitError) {
// Back off and retry primary rather than failing over
await sleep(err.retryAfterMs);
return primary.send(message);
}
if (err instanceof AuthenticationError) {
// This will not be fixed by failover; alert and stop
await alerting.critical("Primary email provider authentication failure");
throw err;
}
// Transient failure: use fallback
metrics.increment("email.failover");
return fallback.send(message);
}
}
Circuit breakers are worth adding at the provider level. If the primary fails 10% of sends over a 60-second window, open the circuit and route to the fallback automatically rather than retrying every message individually.
Closing
Email delivery is an infrastructure problem masquerading as a feature. The template engine, the queue, and the provider API are the visible parts. The invisible parts, deliverability reputation, suppression list hygiene, bounce classification, compliance record-keeping, and provider failover, are where production systems succeed or fail. Getting the architecture right early means you are not debugging a blacklisted IP or a GDPR complaint at 2am six months after launch.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.