Sending Emails with AWS SES
AWS Simple Email Service (SES) is a cloud email platform designed for sending transactional and marketing emails at scale. It handles deliverability, bounce handling, and spam compliance so you don't have to build your own email infrastructure.
Common use cases in a backend application:
- Welcome emails after registration
- OTP / verification codes
- Password reset links
- Order confirmations
- Invoice delivery
Before You Start: Verify a Sender
SES won't let you send from just any address. You must verify the sender email (or your entire domain) first.
- Go to AWS Console → Amazon SES → Verified identities → Create identity
- Choose Email address (faster) or Domain (better for production)
- Click the verification link AWS sends to that address
- Once verified, the status will show Verified
Sandbox mode
By default, SES accounts are in sandbox mode, which means you can only send to verified email addresses. Before going live, request production access via AWS Console → SES → Account dashboard → Request production access.
Install
- npm
- Yarn
- pnpm
- Bun
npm install @aws-sdk/client-ses dotenv
yarn add @aws-sdk/client-ses dotenv
pnpm add @aws-sdk/client-ses dotenv
bun add @aws-sdk/client-ses dotenv
Configure the SES Client
config/ses.js
import { SESClient } from "@aws-sdk/client-ses";
const ses = new SESClient({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
export default ses;
Add SES_SENDER_EMAIL to your .env:
.env
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AWS_REGION=us-east-1
SES_SENDER_EMAIL=no-reply@yourdomain.com
Sending a Simple Email
utils/sendEmail.js
import { SendEmailCommand } from "@aws-sdk/client-ses";
import ses from "../config/ses.js";
export async function sendEmail({ to, subject, html, text }) {
const command = new SendEmailCommand({
Source: process.env.SES_SENDER_EMAIL,
Destination: {
ToAddresses: Array.isArray(to) ? to : [to],
},
Message: {
Subject: { Data: subject, Charset: "UTF-8" },
Body: {
Html: { Data: html, Charset: "UTF-8" },
Text: { Data: text || "", Charset: "UTF-8" },
},
},
});
return ses.send(command);
}
Common Email Templates
Welcome Email
emails/welcome.js
export function welcomeEmail(name) {
return {
subject: `Welcome to MyApp, ${name}!`,
html: `
<h1>Welcome, ${name}!</h1>
<p>Thanks for signing up. We're excited to have you on board.</p>
<p>Get started by exploring your dashboard.</p>
`,
text: `Welcome, ${name}! Thanks for signing up.`,
};
}
OTP / Verification Code
emails/otp.js
export function otpEmail(otp) {
return {
subject: "Your verification code",
html: `
<h2>Verification Code</h2>
<p>Use the code below to verify your account. It expires in 10 minutes.</p>
<h1 style="letter-spacing: 8px; font-size: 36px;">${otp}</h1>
<p>If you didn't request this, ignore this email.</p>
`,
text: `Your verification code is: ${otp}. It expires in 10 minutes.`,
};
}
Password Reset
emails/passwordReset.js
export function passwordResetEmail(resetLink) {
return {
subject: "Reset your password",
html: `
<h2>Password Reset Request</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetLink}" style="
display: inline-block;
padding: 12px 24px;
background-color: #4f46e5;
color: white;
text-decoration: none;
border-radius: 6px;
">Reset Password</a>
<p>If you didn't request a password reset, you can safely ignore this email.</p>
`,
text: `Reset your password: ${resetLink}`,
};
}
Express Routes Using SES
routes/auth.js
import express from "express";
import { sendEmail } from "../utils/sendEmail.js";
import { welcomeEmail } from "../emails/welcome.js";
import { otpEmail } from "../emails/otp.js";
import { passwordResetEmail } from "../emails/passwordReset.js";
import UserModel from "../models/user.js";
import crypto from "crypto";
const router = express.Router();
// Send welcome email on registration
router.post("/register", async (req, res) => {
const { name, email, password } = req.body;
try {
const user = await UserModel.create({ name, email, password });
await sendEmail({
to: email,
...welcomeEmail(name),
});
res.status(201).json({ message: "Registration successful. Check your email." });
} catch (error) {
res.status(500).json({ message: "Registration failed", error: error.message });
}
});
// Send OTP for verification
router.post("/send-otp", async (req, res) => {
const { email } = req.body;
const otp = Math.floor(100000 + Math.random() * 900000).toString(); // 6-digit OTP
try {
// Save OTP hash in database with expiry
await UserModel.findOneAndUpdate(
{ email },
{
otpHash: crypto.createHash("sha256").update(otp).digest("hex"),
otpExpiry: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
}
);
await sendEmail({ to: email, ...otpEmail(otp) });
res.json({ message: "OTP sent to your email" });
} catch (error) {
res.status(500).json({ message: "Failed to send OTP" });
}
});
// Send password reset email
router.post("/forgot-password", async (req, res) => {
const { email } = req.body;
try {
const token = crypto.randomBytes(32).toString("hex");
const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${token}`;
await UserModel.findOneAndUpdate(
{ email },
{
resetToken: token,
resetTokenExpiry: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
}
);
await sendEmail({ to: email, ...passwordResetEmail(resetLink) });
res.json({ message: "Password reset email sent" });
} catch (error) {
res.status(500).json({ message: "Failed to send password reset email" });
}
});
export default router;
IAM Permissions for SES
Add this to your IAM policy:
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": "*"
}
Key Takeaways
- Verify your sender email or domain in SES before sending
- SES sandbox mode only allows sending to verified addresses — request production access before going live
- Always send both
htmlandtextversions of an email for better deliverability - Never include plain OTPs in logs — hash them before storing