DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Email Sending with Claude Code: SES, Queue Processing, and Bounce Handling

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)
Enter fullscreen mode Exit fullscreen mode

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(),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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()}`,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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)