Photo by Joanna Kosinska on Unsplash
To send email with AWS SES and SDK v3, install @aws-sdk/client-sesv2, create an SESv2Client in the Region where your sender identity is verified, and send a SendEmailCommand with FromEmailAddress, Destination and Content. Content is one of three shapes: Simple (subject, body, attachments), Template (a stored template plus JSON data) or Raw (a full MIME message).
This guide is for Node.js and TypeScript developers who need an application to send email through Amazon SES: receipts, password resets, reports with a CSV attached. It covers the calls to send email with AWS SES and SDK v3 that a real service needs, with a typed module for all three message shapes, the sandbox rules that trip up every first attempt, the IAM policy, and the errors you’ll meet. If your users live in a Cognito user pool, Cognito sends its own invitation and reset emails, which the guide to create Cognito users with AdminCreateUser in AWS SDK v3 covers.
Moving an older new AWS.SES().sendEmail(params).promise() call? The guide to migrate a Node.js app from AWS SDK v2 to v3 covers the client pattern, and the AWS SDK JavaScript v2 to v3 converter drafts the change from your code. For a Python mailer, the boto3 to AWS SDK v3 converter does the same.
SES v1 or v2: which client should you use?
AWS ships two SES clients for JavaScript v3. @aws-sdk/client-ses wraps the original API, with separate SendEmail, SendTemplatedEmail and SendRawEmail operations. @aws-sdk/client-sesv2 wraps SES API v2, where one SendEmail operation takes any of the three content types. Use v2 for new code. The practical difference beyond style is size: SES documents a maximum message size of 10 MB with the v1 API and 40 MB with the v2 API or SMTP, both including attachments after base64 encoding.
| Content type | You provide | Use it for |
|---|---|---|
Simple |
Subject, text and/or HTML body, optional Attachments and Headers |
Most transactional mail; SES builds the MIME |
Template |
TemplateName and TemplateData (a JSON string) |
Mail whose wording is managed outside the code |
Raw |
A complete MIME message as bytes | Full control of headers and parts, or mail built by another library |
Prerequisites
- Node.js 18 or later, TypeScript and
tsx, with"type": "module"inpackage.jsonfor the top-levelawaitin the runner. @aws-sdk/client-sesv2.- A verified identity, a domain or an email address, in the Region you send from. Identities, templates, configuration sets and quotas are all per Region.
- While the account is in the SES sandbox (see below), every recipient must be verified too.
- Credentials the SDK can resolve; the guide to the AWS SDK v3 credentials provider chain covers profiles, SSO and roles.
How to send email with AWS SES and SDK v3, step by step
- Verify a sender identityVerify your domain (preferred) or a single address in the SES console for the Region you’ll use.
- Check sandbox statusCall
GetAccountCommand;ProductionAccessEnabled: falsemeans sandbox limits apply in this Region. - Create one client per processAn
SESv2Clientat module level, in the identity’s Region. - Pick the content type
Simpleunless you have a reason not to. Always include a text part alongside HTML. - Add a configuration set
ConfigurationSetNameties the message to event publishing (bounces, complaints, deliveries) so failures don’t vanish silently. - Log the MessageIdIt’s what SES events and support cases refer to.
Example: a typed SES mail module
// ses-email.ts
// Sending email with Amazon SES API v2 and AWS SDK for JavaScript v3:
// simple (text + HTML, optional attachments), templated, and raw MIME messages.
import { randomUUID } from "node:crypto";
import {
SESv2Client,
SendEmailCommand,
type Attachment,
type MessageTag,
} from "@aws-sdk/client-sesv2";
export const ses = new SESv2Client({}); // region from AWS_REGION or your profile: identities are per Region
const FROM = process.env.MAIL_FROM ?? "Orders <[email protected]>"; // a verified address or domain
const CONFIGURATION_SET = process.env.SES_CONFIGURATION_SET; // optional: event publishing, IP pool, tracking
const tags = (type: string): MessageTag[] => [{ Name: "type", Value: type }];
/** 1. Simple message: SES builds the MIME for you. Attachments are optional. */
export async function sendSimple(opts: {
to: string[];
subject: string;
text: string;
html?: string;
attachments?: { fileName: string; contentType: string; data: Uint8Array }[];
}): Promise<string> {
const attachments: Attachment[] | undefined = opts.attachments?.map((a) => ({
FileName: a.fileName,
ContentType: a.contentType,
RawContent: a.data, // the SDK base64-encodes it for you
ContentDisposition: "ATTACHMENT",
}));
const out = await ses.send(new SendEmailCommand({
FromEmailAddress: FROM,
Destination: { ToAddresses: opts.to },
Content: {
Simple: {
Subject: { Data: opts.subject, Charset: "UTF-8" },
Body: {
Text: { Data: opts.text, Charset: "UTF-8" },
...(opts.html ? { Html: { Data: opts.html, Charset: "UTF-8" } } : {}),
},
Attachments: attachments,
},
},
ConfigurationSetName: CONFIGURATION_SET,
EmailTags: tags("simple"),
}));
return out.MessageId ?? "";
}
/** 2. Templated message: the template (with {{placeholders}}) is stored in SES. */
export async function sendTemplated(to: string, templateName: string, data: Record<string, string | number>): Promise<string> {
const out = await ses.send(new SendEmailCommand({
FromEmailAddress: FROM,
Destination: { ToAddresses: [to] },
Content: { Template: { TemplateName: templateName, TemplateData: JSON.stringify(data) } },
ConfigurationSetName: CONFIGURATION_SET,
EmailTags: tags("templated"),
}));
return out.MessageId ?? "";
}
// RFC 2047 encoded-word for non-ASCII header values such as the subject.
const encodeHeader = (value: string): string =>
/^[\x20-\x7e]*$/.test(value) ? value : `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
// Base64 body lines of at most 76 characters, CRLF line endings.
const base64Lines = (data: Uint8Array): string =>
(Buffer.from(data).toString("base64").match(/.{1,76}/g) ?? []).join("\r\n");
/** 3. Raw MIME message: you control every header and part. */
export async function sendRaw(opts: { to: string[]; subject: string; text: string; fileName: string; contentType: string; data: Uint8Array }): Promise<string> {
const boundary = `part-${randomUUID()}`;
const mime = [
`From: ${FROM}`,
`To: ${opts.to.join(", ")}`,
`Subject: ${encodeHeader(opts.subject)}`,
"MIME-Version: 1.0",
`Content-Type: multipart/mixed; boundary="${boundary}"`,
"",
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
"Content-Transfer-Encoding: base64",
"",
base64Lines(Buffer.from(opts.text, "utf8")),
`--${boundary}`,
`Content-Type: ${opts.contentType}; name="${opts.fileName}"`,
`Content-Disposition: attachment; filename="${opts.fileName}"`,
"Content-Transfer-Encoding: base64",
"",
base64Lines(opts.data),
`--${boundary}--`,
"",
].join("\r\n");
const out = await ses.send(new SendEmailCommand({
FromEmailAddress: FROM,
Destination: { ToAddresses: opts.to },
Content: { Raw: { Data: Buffer.from(mime, "utf8") } },
ConfigurationSetName: CONFIGURATION_SET,
EmailTags: tags("raw"),
}));
return out.MessageId ?? "";
}
The templated function expects a stored template. Create it once per Region; SES templates use {{name}} placeholders in the subject, HTML and text parts:
// create-template.ts: run once per Region (templates are regional).
import { SESv2Client, CreateEmailTemplateCommand } from "@aws-sdk/client-sesv2";
const ses = new SESv2Client({});
await ses.send(new CreateEmailTemplateCommand({
TemplateName: "OrderShipped",
TemplateContent: {
Subject: "Order {{orderId}} has shipped",
Text: "Hi {{name}}, your order {{orderId}} is on its way.",
Html: "<p>Hi {{name}}, your order <strong>{{orderId}}</strong> is on its way.</p>",
},
}));
console.log("Template OrderShipped created");
A runner that checks the account first, then sends one of each:
// run-ses.ts
// Usage: AWS_REGION=us-east-1 MAIL_FROM="Orders <[email protected]>" [email protected] npx tsx run-ses.ts
import { GetAccountCommand, MessageRejected, SendingPausedException } from "@aws-sdk/client-sesv2";
import { ses, sendRaw, sendSimple, sendTemplated } from "./ses-email.js";
const to = process.env.MAIL_TO;
if (!to) {
console.error("Set MAIL_TO to a recipient (in the sandbox it must be verified too).");
process.exit(1);
}
// Sandbox status and quotas are per Region.
const account = await ses.send(new GetAccountCommand({}));
console.log(`production access: ${account.ProductionAccessEnabled}, sending enabled: ${account.SendingEnabled}`);
console.log(`quota: ${account.SendQuota?.SentLast24Hours}/${account.SendQuota?.Max24HourSend} in 24h, ${account.SendQuota?.MaxSendRate}/s`);
const csv = new TextEncoder().encode("order,total\no-1001,129.50\n");
try {
console.log("simple", await sendSimple({
to: [to],
subject: "Your order o-1001 has shipped",
text: "Your order is on its way. The invoice is attached.",
html: "<p>Your order is on its way. The invoice is attached.</p>",
attachments: [{ fileName: "invoice.csv", contentType: "text/csv", data: csv }],
}));
console.log("templated", await sendTemplated(to, "OrderShipped", { name: "Sam", orderId: "o-1001" }));
console.log("raw", await sendRaw({ to: [to], subject: "Rapport de commande prêt", text: "Bonjour, voici le rapport.", fileName: "report.csv", contentType: "text/csv", data: csv }));
} catch (err) {
if (err instanceof MessageRejected) console.error("Rejected:", err.message); // e.g. unverified address in the sandbox
else if (err instanceof SendingPausedException) console.error("Sending is paused for this account in this Region.");
else throw err;
process.exitCode = 1;
}
npm install @aws-sdk/client-sesv2
npm install --save-dev tsx typescript @types/node
npm pkg set type=module
AWS_PROFILE=dev AWS_REGION=us-east-1 npx tsx create-template.ts
AWS_PROFILE=dev AWS_REGION=us-east-1 MAIL_FROM="Orders <[email protected]>" [email protected] npx tsx run-ses.ts
# production access: false, sending enabled: true
# quota: 3/200 in 24h, 1/s
# simple 0100019a4c7e2b1f-3d0e8f52-EXAMPLE-000000
# templated 0100019a4c7e2c88-91ab40d6-EXAMPLE-000000
# raw 0100019a4c7e2df0-5e6c1a37-EXAMPLE-000000
Message IDs are illustrative. The quota line is the sandbox: 200 messages per 24 hours and 1 per second.
What to notice in the module:
- Attachments don’t need raw MIME any more.
SimpleandTemplatecontent both accept anAttachmentslist, and the SDK base64-encodesRawContentfor you. SES restricts some file extensions, so check its unsupported attachment types before sending executables or archives. FromEmailAddresscan include a display name. InOrders <[email protected]>, the address part is what must belong to a verified identity.EmailTagsare for events. They travel with the message into your configuration set’s event destinations, so you can count bounces by message type.
How do raw MIME messages work?
With Raw, SES sends exactly the bytes you give it, so the message has to follow the Internet Message Format in RFC 5322: header fields, one blank line, then the body. The SDK model spells out SES’s own rules on top of that:
- All required header fields present, and each part of a multipart message formatted properly.
- No line longer than 1,000 characters (the limit from RFC 5321). Base64-encoded parts, wrapped at 76 characters, satisfy this automatically.
- Content outside 7-bit ASCII encoded, and non-ASCII subjects written as RFC 2047 encoded words, which is what
encodeHeaderdoes for the French subject in the runner. - Attachments in a file format SES supports.
The module uses CRLF line endings, a random boundary from node:crypto, and base64 for both the text part and the attachment. If you already generate MIME with a mail library, pass its output as Raw.Data and skip the hand-built part.
Configuration sets, bounces and template failures
SES accepting a message doesn’t mean it was delivered. The SDK documentation says so directly: SES can accept a message without sending it, for example when an attachment contains a virus or a templated email has invalid personalization content. A configuration set with an event destination is how you find out. Publish bounce, complaint and rendering failure events to an SNS topic, and the guide to publish an SNS message with AWS SDK v3 shows how that topic can fan out to a queue, which you then read as shown in send and receive SQS messages with AWS SDK v3 in TypeScript. A configuration set can have up to 10 event destinations. EventBridge is one of the destination types, so SES events can be routed by the same rules as your own application events, which you send as shown in the guide to send events to EventBridge with AWS SDK v3 (PutEvents).
Keep the configuration set name and sender address out of the code; the guide to get SSM Parameter Store values with AWS SDK v3 shows a cached loader that fits.
Which IAM permissions does sending email need?
The SES API v2 SendEmail operation is authorized by ses:SendEmail, which can be scoped to the identity, configuration set and template it uses. GetAccount has no resource type. Replace the Region, account ID and names:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SendAsOurDomain",
"Effect": "Allow",
"Action": "ses:SendEmail",
"Resource": [
"arn:aws:ses:us-east-1:123456789012:identity/example.com",
"arn:aws:ses:us-east-1:123456789012:configuration-set/transactional",
"arn:aws:ses:us-east-1:123456789012:template/OrderShipped"
]
},
{
"Sid": "ReadSendingQuota",
"Effect": "Allow",
"Action": "ses:GetAccount",
"Resource": "*"
}
]
}
Two variations are common. The SMTP interface and the v1 SendRawEmail API are authorized by ses:SendRawEmail instead, so add it if part of the system sends over SMTP. And the ses:FromAddress and ses:Recipients condition keys can pin a role to one sender address or a recipient domain. Creating templates (ses:CreateEmailTemplate) belongs in a deploy role, not the application’s. To derive the actions from your own code, see how to find the IAM actions your AWS SDK for JavaScript code needs, or paste the module into the IAM policy generator for TypeScript code.
Troubleshooting common SES SendEmail errors
MessageRejected: “Email address is not verified”. In the sandbox the recipient must be verified as well as the sender, and verification is per Region. The error lists the identities and the Region that failed the check.MailFromDomainNotVerifiedException. The sending domain isn’t verified in this Region.NotFoundException. The template or configuration set doesn’t exist in this Region; they don’t follow you across Regions.TooManyRequestsException. You’re over the sending rate. The SDK treats it as a throttling error and retries, up to 3 attempts by default; the guide to configure retry and timeout settings in AWS SDK for JavaScript v3 shows how to raise that for bursty senders.SendingPausedExceptionandAccountSuspendedException. Sending is paused, or permanently restricted, for the account in this Region. Code can’t fix this; check the account dashboard in the SES console for the reason.AccessDeniedException. Usually a missing resource in the policy, such as the configuration set ARN. The steps to troubleshoot AWS IAM access denied errors apply.- A 200 response but no email. A rendering failure, a suppressed address or a bounce. Only a configuration set with event publishing will tell you which.
Limits and pricing
- Sandbox. Verified recipients only, 200 messages per 24 hours, 1 message per second, per Region. Production access is requested from the SES console or with
PutAccountDetails, and AWS aims to give an initial response within 24 hours. - Recipients. At most 50 per message across To, CC and BCC. Quotas count recipients, not messages.
- Size. 40 MB per message with the v2 API, after base64 encoding. Messages over 10 MB are subject to bandwidth throttling.
- Price. As of September 2026, the AWS Price List gives $0.0001 per recipient ($0.10 per 1,000) for
SendEmailandSendRawEmailin us-east-1, plus $0.12 per GB of attachments. Dedicated IPs and add-ons are priced separately.
Production quotas vary by account, so read them from GetAccount rather than hard-coding them; for alerting on quotas in general, see how to monitor AWS service quota usage and get alerts. ChatWithCloud can also answer “Which SES identities are verified in eu-west-1, and is this account still in the sandbox?” from a read-only profile; it runs the AWS calls on your machine and sends the results to the AI model to write the answer.
Frequently asked questions
How do I send an email with an attachment using SES in Node.js?
With @aws-sdk/client-sesv2, put the file bytes in Content.Simple.Attachments with a FileName and ContentType. The SDK handles the base64 encoding. Build a raw MIME message only when you need full control of the parts.
What’s the difference between SendEmail in client-ses and client-sesv2?
client-ses uses SES API v1, with separate operations for simple, templated and raw mail and a 10 MB message limit. client-sesv2 has one SendEmail for all three and a 40 MB limit.
Why does SES say my email address is not verified?
Either the sender isn’t verified in the Region you’re calling, or your account is still in the sandbox there and the recipient isn’t verified. Sandbox status is per Region.
How many emails can SES send per second?
1 per second in the sandbox. In production the rate depends on your account; GetAccount returns it as SendQuota.MaxSendRate.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud