DEV Community

Cover image for Sending Emails with Amazon SES (and Why Your Form Shouldn't Wait for Them)
Elizabeth Sobiya
Elizabeth Sobiya

Posted on

Sending Emails with Amazon SES (and Why Your Form Shouldn't Wait for Them)

I recently added email notifications to a contact form using Amazon SES (Simple Email Service). The setup was simple, but my first version had a problem: every form submission waited for the email to finish sending before the user got a response. In this post I'll walk through the basic SES setup and then show how I moved email sending into a background job so the form responds instantly.

Why Amazon SES?

SES is AWS's email sending service. It's cheap (a fraction of a dollar per thousand emails), reliable, and scales well beyond what most side projects will ever need. If you're already on AWS, it's an easy choice for transactional email like confirmations, notifications, and password resets.

Step 1: Verify a Sender Identity

SES won't send from an address until you prove you own it. In the AWS Console, go to Amazon SES → Configuration → Identities → Create identity. You can verify either a single email address or a whole domain.

Verifying a domain is the better long-term option. SES gives you DNS records to add, including DKIM records, which help your emails land in the inbox instead of spam. While you're in your DNS settings, it's also worth adding SPF and DMARC records.

Step 2: Get Out of the Sandbox

New SES accounts start in sandbox mode. In the sandbox you can only send to verified addresses, and there's a low daily sending limit. That's fine for testing, but before going live, request production access from SES → Account dashboard → Request production access. AWS usually asks how you'll use email and how you'll handle bounces and complaints.

Step 3: Create IAM Credentials

Create an IAM user (or role, if you're running on EC2/ECS/Lambda) with permission to send email. A minimal policy looks like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "*"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Add the credentials to your .env:

AWS_REGION=ap-south-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
SES_FROM_EMAIL=no-reply@yourdomain.com
Enter fullscreen mode Exit fullscreen mode

Make sure the region matches the region where you verified your identity. Identities are region-specific, so this is a common source of "email address not verified" errors.

Step 4: Send an Email from Node.js

Install the AWS SDK v3 SES client:

npm install @aws-sdk/client-sesv2
Enter fullscreen mode Exit fullscreen mode

Then create a small helper:

// lib/email.js
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";

const ses = new SESv2Client({ region: process.env.AWS_REGION });

export async function sendEmail({ to, subject, html, text }) {
  const command = new SendEmailCommand({
    FromEmailAddress: process.env.SES_FROM_EMAIL,
    Destination: { ToAddresses: [to] },
    Content: {
      Simple: {
        Subject: { Data: subject },
        Body: {
          Html: { Data: html },
          Text: { Data: text },
        },
      },
    },
  });

  return ses.send(command);
}
Enter fullscreen mode Exit fullscreen mode

The SDK picks up AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the environment automatically, so you don't need to pass them in.

My First (Slow) Version

Here's roughly what my form submission route looked like at first:

app.post("/api/contact", async (req, res) => {
  const { name, email, message } = req.body;

  await saveSubmission({ name, email, message });

  // ❌ The user waits for this
  await sendEmail({
    to: "team@yourdomain.com",
    subject: `New contact form submission from ${name}`,
    html: `<p><b>${name}</b> (${email}) wrote:</p><p>${message}</p>`,
    text: `${name} (${email}) wrote:\n\n${message}`,
  });

  res.json({ success: true });
});
Enter fullscreen mode Exit fullscreen mode

It worked, but it had real problems. The form took noticeably longer to respond because each request waited on a network call to AWS. Worse, if SES was slow, throttled, or failed, the whole submission returned an error, even though the data had already been saved. The user would see "something went wrong" and might submit again, creating duplicates.

The fix is to recognize that sending the email is not part of the form submission. The user only cares that their submission was received. The email is a side effect that can happen a moment later.

Option 1: Fire and Forget

The quickest fix is to stop awaiting the email:

app.post("/api/contact", async (req, res) => {
  const { name, email, message } = req.body;

  await saveSubmission({ name, email, message });

  // Don't await, but always catch, or you'll get unhandled rejections
  sendEmail({ /* ... */ }).catch((err) => {
    console.error("Failed to send contact email:", err);
  });

  res.json({ success: true });
});
Enter fullscreen mode Exit fullscreen mode

The response is instant now. But there's a catch: if the email fails, it's just logged and lost. If the server restarts mid-send, it's lost too. There are no retries. For a hobby project this may be acceptable, but for anything important you want a proper job queue.

Option 2: A Real Background Job with BullMQ

A job queue stores the work somewhere durable (Redis, in this case), and a separate worker processes it with retries. I used BullMQ:

npm install bullmq
Enter fullscreen mode Exit fullscreen mode

Create the queue:

// queues/emailQueue.js
import { Queue } from "bullmq";

export const connection = {
  host: process.env.REDIS_HOST || "127.0.0.1",
  port: Number(process.env.REDIS_PORT) || 6379,
};

export const emailQueue = new Queue("emails", { connection });
Enter fullscreen mode Exit fullscreen mode

Add a job from the route:

import { emailQueue } from "./queues/emailQueue.js";

app.post("/api/contact", async (req, res) => {
  const { name, email, message } = req.body;

  await saveSubmission({ name, email, message });

  // ✅ Just enqueue: takes a few milliseconds
  await emailQueue.add(
    "contact-notification",
    { name, email, message },
    {
      attempts: 5,
      backoff: { type: "exponential", delay: 2000 },
      removeOnComplete: true,
      removeOnFail: 100,
    }
  );

  res.json({ success: true });
});
Enter fullscreen mode Exit fullscreen mode

Process jobs in a worker:

// workers/emailWorker.js
import { Worker } from "bullmq";
import { connection } from "../queues/emailQueue.js";
import { sendEmail } from "../lib/email.js";

const worker = new Worker(
  "emails",
  async (job) => {
    const { name, email, message } = job.data;

    await sendEmail({
      to: "team@yourdomain.com",
      subject: `New contact form submission from ${name}`,
      html: `<p><b>${name}</b> (${email}) wrote:</p><p>${message}</p>`,
      text: `${name} (${email}) wrote:\n\n${message}`,
    });
  },
  { connection, concurrency: 5 }
);

worker.on("failed", (job, err) => {
  console.error(`Email job ${job?.id} failed:`, err.message);
});
Enter fullscreen mode Exit fullscreen mode

Run the worker as its own process (node workers/emailWorker.js) alongside your API. Now the form responds right away, and if SES hiccups, BullMQ retries with exponential backoff (2s, 4s, 8s, and so on). If a job fails all five attempts, it stays in the failed set so you can inspect or retry it.

The concurrency option also helps you stay under your SES sending rate limit, since it caps how many emails the worker sends at once.

Not using Redis? If you're all-in on AWS, Amazon SQS plus a Lambda consumer is a great alternative. The API pushes a message to SQS, and a Lambda function sends the email. You get retries and a dead-letter queue built in.

Before vs After

Awaiting email in request Background job
Response time Includes SES network call Just the DB write + enqueue
SES failure Form shows an error User sees success, job retries
Retries None Automatic with backoff
Server restart mid-send Email lost Job still in queue

A Few Extra Tips

Handle bounces and complaints. SES tracks your bounce and complaint rates, and high rates can get your account paused. Set up SNS notifications (or an SES configuration set with event destinations) so you can stop emailing addresses that bounce.

Always send a plain-text version alongside HTML. Some clients prefer it, and it slightly improves deliverability.

Escape user input before putting it into HTML emails. In the examples above, name and message come straight from the form, so in a real app you should sanitize them to avoid HTML injection.

Keep the email content in the job payload minimal. Store IDs if the data is large, and let the worker fetch what it needs.

Wrapping Up

Setting up SES comes down to verifying an identity, leaving the sandbox, creating IAM credentials, and calling SendEmailCommand. The bigger lesson for me was architectural: anything that isn't essential to the user's request, like sending emails, generating PDFs, or calling webhooks, shouldn't block the response. Save the data, enqueue the side effect, and respond. Your users get a faster form, and your emails get retries for free.

Have you handled emails differently in your projects? I'd love to hear how in the comments! 👇


Top comments (0)