Sending email synchronously inside an API response is a classic trap. SES latency causes request timeouts. Ignoring bounces causes delivery rate to drop steadily over time.
Claude Code, guided by a well-written CLAUDE.md, generates safe email sending patterns from the start — queued delivery, retry logic, bounce tracking, and idempotency built in.
CLAUDE.md: Email Sending Rules
This is what you put in CLAUDE.md to guide Claude Code:
## Email Sending Rules
- Send email via BullMQ queue — never block the API response
- Retry 3x with exponential backoff: 1min → 5min → 30min
- Record every send attempt in DB for idempotency (prevent duplicate sends)
- Use HTML + plain text templates (Handlebars)
- Inline CSS in HTML emails (many clients strip <style> tags)
- From address: noreply@ (reply-to disabled by default)
- Escape all user-supplied input before template render
- Dev/staging: route through Mailhog or Mailtrap (never real SES)
With these rules in place, Claude Code generates consistent, production-safe email code without being asked for each constraint individually.
emailService.ts: Core Send Logic
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
import { prisma } from "./prisma";
const ses = new SESClient({ region: process.env.AWS_REGION ?? "ap-northeast-1" });
const templateCache = new Map<string, HandlebarsTemplateDelegate>();
async function getTemplate(name: string): Promise<HandlebarsTemplateDelegate> {
if (templateCache.has(name)) return templateCache.get(name)!;
const source = await fs.readFile(`./templates/${name}.hbs`, "utf-8");
const compiled = Handlebars.compile(source);
templateCache.set(name, compiled);
return compiled;
}
export async function sendEmailDirect(params: {
to: string;
subject: string;
templateName: string;
templateData: Record<string, string>;
idempotencyKey: string;
}): Promise<void> {
// Idempotency check — skip if already sent
const existing = await prisma.emailLog.findUnique({
where: { idempotencyKey: params.idempotencyKey },
});
if (existing?.status === "sent") return;
const htmlTemplate = await getTemplate(`${params.templateName}.html`);
const textTemplate = await getTemplate(`${params.templateName}.text`);
const command = new SendEmailCommand({
Source: "noreply@example.com",
Destination: { ToAddresses: [params.to] },
Message: {
Subject: { Data: params.subject },
Body: {
Html: { Data: htmlTemplate(params.templateData) },
Text: { Data: textTemplate(params.templateData) },
},
},
});
await ses.send(command);
await prisma.emailLog.create({
data: {
idempotencyKey: params.idempotencyKey,
to: params.to,
subject: params.subject,
status: "sent",
sentAt: new Date(),
},
});
}
Key points: idempotency check before every send, template cache to avoid repeated disk reads, DB record written only after confirmed SES success.
emailQueue.ts: Queue Jobs
import { Queue } from "bullmq";
import { connection } from "./redis";
const emailQueue = new Queue("email", { connection });
export async function queueWelcomeEmail(userId: string, email: string, name: string) {
await emailQueue.add(
"welcome",
{ userId, email, name, templateName: "welcome" },
{
attempts: 3,
backoff: { type: "exponential", delay: 60_000 }, // 1min → 5min → 30min
jobId: `welcome-${userId}`, // Deduplication: one job per user
}
);
}
export async function queuePasswordResetEmail(userId: string, email: string, token: string) {
await emailQueue.add(
"password-reset",
{ userId, email, token, templateName: "password-reset" },
{
attempts: 3,
backoff: { type: "exponential", delay: 60_000 },
jobId: `password-reset-${userId}-${Date.now()}`,
}
);
}
jobId deduplication ensures no duplicate welcome emails if the caller enqueues twice. Exponential backoff with 3 attempts covers transient SES failures without overwhelming the service.
emailWorker.ts: Worker Process
import { Worker } from "bullmq";
import { connection } from "./redis";
import { sendEmailDirect } from "./emailService";
const worker = new Worker(
"email",
async (job) => {
const { userId, email, templateName, token, name } = job.data;
const templateData: Record<string, string> = {};
if (name) templateData.name = name;
if (token) templateData.resetLink = `https://example.com/reset?token=${token}`;
await sendEmailDirect({
to: email,
subject: job.name === "welcome" ? "Welcome!" : "Reset your password",
templateName,
templateData,
idempotencyKey: job.id!,
});
},
{ connection, concurrency: 5 }
);
worker.on("failed", (job, err) => {
console.error(`Email job ${job?.id} failed after ${job?.attemptsMade} attempts:`, err.message);
// Alert to Slack / Sentry here
});
concurrency: 5 processes five emails in parallel without overwhelming SES. The failed handler is where you add alerting — after all 3 retries are exhausted, you need to know.
Summary
| Concern | Solution |
|---|---|
| SES latency blocks API | BullMQ queue — fire and forget |
| Duplicate sends | Idempotency key per job |
| Transient SES failures | 3x retry with exponential backoff |
| Template consistency | Handlebars + inline CSS |
| Bounce tracking | emailLog table, status field |
CLAUDE.md + BullMQ queue + idempotency key + Handlebars templates — define these rules once, and Claude Code generates consistent email infrastructure across every service that needs it.
Want Claude Code to review whether your generated email code follows these patterns?
Code Review Pack (¥980) — A Claude Code custom skill that audits queue usage, idempotency, retry logic, and security in your codebase.
Available on prompt-works.jp
Top comments (0)