Sending Email with Amazon SES
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Commands and behaviour on this page are current for AWS CLI v2 (2.37 line, the sesv2 command group), the SES API
v2, AWS SDK for Java 2.x (software.amazon.awssdk:sesv2), Spring Boot 3.5 and 4 with spring-boot-starter-mail, and
Quarkus with quarkus-mailer, all checked against the official documentation on 2026-10-01; re-verify against
aws --version and the linked reference pages before relying on them in a pipeline. Examples use us-east-1, the
placeholder account ID 111122223333, the placeholder domain example.com and the sender no-reply@example.com;
all of them are stand-ins for your own values, and every credential below is an obvious placeholder such as
<smtp-password>.
Amazon Simple Email Service (SES) is the AWS service for sending (and optionally receiving) email at scale. In
the Bookshelf scenario the notifier worker sends an order-confirmation message from no-reply@example.com every
time orders-api places an order. This page takes that from nothing to production: verify the domain, authenticate
it with DKIM, SPF and DMARC, leave the sandbox, send through the API and through SMTP, and observe bounces and
complaints through a configuration set that publishes to EventBridge. The Spring and Quarkus mail APIs themselves are
not repeated here; see Configuration and Profiles for the MailSender bean shape and
Scheduling and Mail for the Quarkus Mailer. For the Azure counterpart, see
Email with Azure Communication Services.
How SES works
SES is a Regional service. Everything on this page (identities, quotas, configuration sets, SMTP credentials,
suppression settings) belongs to one Region in one account; if notifier can run in two Regions, you set all of it
up twice. The SES v2 API (sesv2 in the CLI, SesV2Client in the Java SDK) is the current API; the older v1 API
(aws ses) still works but newer features, including a 40 MB message limit, are v2 only.
The path in the diagram is the whole model: your application hands a message to SES, SES signs it and delivers it over SMTP, and the outcome comes back as an event that you must act on. A bounce or complaint that nobody handles keeps hurting the sending reputation of the domain, which is the single most common reason SES accounts get throttled.
Verified identities
An identity is the thing you are allowed to send from: either a domain (every address at example.com) or a
single email address. SES refuses to send from anything that is not a verified identity in the same Region.
| Aspect | Domain identity | Email address identity |
|---|---|---|
Verification |
Publish DNS records (three DKIM CNAMEs with Easy DKIM) and SES checks them. |
Click a link in a message SES sends to that address. |
Covers |
Any address at the domain and its subdomains (if you verify the parent). |
Only that exact address. |
Authentication |
DKIM-signed with your domain, DMARC-alignable, custom MAIL FROM possible. |
Signed by SES’s own keys for the amazonses.com domain unless the domain is also verified; weak for deliverability. |
Choose it when |
Production mail. Always. |
A quick test in the sandbox, or a personal address you must verify as a recipient. |
An address is covered by a verified domain identity automatically, so Bookshelf verifies example.com once and then
sends from no-reply@example.com, orders@example.com and any other address without further steps. The domain
identity is also where authentication is configured.
Verify and authenticate a domain
In the AWS Management Console
-
Open Amazon SES and select the Region in the selector (the same Region as the application).
-
Choose Configuration > Identities > Create identity.
-
Choose Domain and enter
example.com. For a delegated sub-domain such asmail.example.com, enter that instead. -
Under Advanced DKIM settings keep Easy DKIM with RSA_2048_BIT (use Provide DKIM authentication token only for BYODKIM, see below). Leave DKIM signatures enabled.
-
Optionally assign a default configuration set (see Configuration sets and event destinations) and choose Create identity.
-
On the identity page copy the three DKIM CNAME records into your DNS zone. If the zone is a Route 53 hosted zone in this account, choose Publish DNS records to Route 53 and SES creates them for you.
-
Within minutes (up to 72 hours for other DNS hosts) the Identity status turns Verified and DKIM configuration turns Successful.
-
Open the identity’s Custom MAIL FROM domain tab, choose Edit, set
mail.example.comand Use default value as the behaviour on MX failure, and publish the MX and TXT records it shows (see Custom MAIL FROM and SPF).
With the AWS CLI
export AWS_REGION=us-east-1
export DOMAIN=example.com
aws sesv2 create-email-identity --email-identity "$DOMAIN" \
--dkim-signing-attributes NextSigningKeyLength=RSA_2048_BIT \
--tags Key=app,Value=bookshelf
# The three DKIM tokens to publish as CNAME records
aws sesv2 get-email-identity --email-identity "$DOMAIN" \
--query 'DkimAttributes.Tokens' --output text
Each token <token> becomes a record <token>._domainkey.example.com CNAME <token>.dkim.amazonses.com. For a
Route 53 hosted zone, a change batch does it in one call:
{
"Comment": "SES Easy DKIM for example.com",
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "<token-1>._domainkey.example.com",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{ "Value": "<token-1>.dkim.amazonses.com" }]
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "<token-2>._domainkey.example.com",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{ "Value": "<token-2>.dkim.amazonses.com" }]
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "<token-3>._domainkey.example.com",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{ "Value": "<token-3>.dkim.amazonses.com" }]
}
}
]
}
aws route53 change-resource-record-sets --hosted-zone-id "$ZONE_ID" \
--change-batch file://ses-dkim.json
# Poll until verification completes
aws sesv2 get-email-identity --email-identity "$DOMAIN" \
--query '{Verified:VerifiedForSendingStatus,Dkim:DkimAttributes.Status}'
Reference: aws sesv2 create-email-identity,
Creating and verifying identities in Amazon SES
and Easy DKIM. Route 53 itself is
covered on Route 53, DNS and ACM Certificates.
DKIM, SPF and DMARC
Three DNS-based standards decide whether a receiving mail server trusts the message. They answer different questions, and all three are expected by the large mailbox providers for bulk senders.
| Standard | What it proves | What SES gives you |
|---|---|---|
DKIM |
The message was signed by a key published in the sender’s DNS and not altered in transit. |
Easy DKIM: SES generates a 2048-bit key pair, you publish three CNAME records, and SES rotates the keys without a further DNS change. BYODKIM (bring your own DKIM): you generate the key pair and publish the public key as a TXT record; use it when policy requires you to own the private key. A DKIM key of 1024 or 2048 bits is accepted for BYODKIM; 2048 is the recommended length. |
SPF |
The sending server’s IP is authorised by the domain in the envelope (MAIL FROM, or return-path) address. |
By default the envelope domain is an amazonses.com subdomain, so SPF passes but for the wrong domain. A custom MAIL FROM domain makes the envelope domain yours, which lets SPF align with the From address. |
DMARC |
The domain in the visible |
SES does not publish DMARC for you. You publish one TXT record at |
Custom MAIL FROM and SPF
Choose a subdomain that does not receive any other mail, such as mail.example.com. Two records are required in
that subdomain: an MX record that points at the regional SES feedback endpoint, and an SPF TXT record.
aws sesv2 put-email-identity-mail-from-attributes --email-identity "$DOMAIN" \
--mail-from-domain mail.example.com \
--behavior-on-mx-failure USE_DEFAULT_VALUE
mail.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com
mail.example.com. TXT "v=spf1 include:amazonses.com ~all"
With USE_DEFAULT_VALUE SES falls back to its default envelope domain if the MX record disappears, which keeps mail
flowing; REJECT_MESSAGE fails the send instead, which is stricter but riskier. The MX priority and host are
region-specific, so copy them from the console or from get-email-identity (MailFromAttributes).
DMARC
Start in monitoring mode and tighten the policy once the reports show only legitimate sources:
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; adkim=s; aspf=r"
Move to p=quarantine, then p=reject, after the aggregate (rua) reports are clean. DMARC reports go to the
mailbox named in rua, which must be an address you actually read or a DMARC report-processing service.
Reference: SPF,
DMARC,
Custom MAIL FROM domain and
put-email-identity-mail-from-attributes.
The sandbox and production access
Every new SES account in a Region starts in the sandbox. In the sandbox you can send only to verified identities
(or to the mailbox simulator addresses), at most 200 messages per 24 hours and 1 message per second. That is enough
to develop notifier but not to email customers. Moving out of the sandbox is a request that AWS reviews; it is a
per-Region, per-account step, and AWS asks for the kind of mail you send, how you obtain recipient addresses, and
how you handle bounces and complaints.
In the AWS Management Console
-
Open Amazon SES in the target Region and choose Get set up on the dashboard, or Account dashboard > Request production access.
-
Choose the Mail type: Transactional for Bookshelf’s order confirmations (Marketing if you send newsletters).
-
Enter the Website URL of the service that sends the mail.
-
In Use case description explain the recipients (customers who placed an order), the opt-in method, the volume you expect and, importantly, how bounces and complaints are processed (point to the event destination below).
-
Add up to four Additional contact addresses and the Preferred contact language, acknowledge the policies and choose Submit request.
-
AWS usually answers within one business day. Until then the dashboard shows the account as Sandbox.
With the AWS CLI
aws sesv2 put-account-details \
--mail-type TRANSACTIONAL \
--website-url https://www.example.com \
--use-case-description "Order-confirmation emails to customers who placed an order; recipients opt in at checkout; bounces and complaints are consumed from SES event publishing and suppressed." \
--contact-language EN \
--additional-contact-email-addresses ops@example.com \
--production-access-enabled
# Check the result at any time
aws sesv2 get-account \
--query '{Production:ProductionAccessEnabled,Sending:SendingEnabled,Max24h:SendQuota.Max24HourSend,PerSecond:SendQuota.MaxSendRate,Sent24h:SendQuota.SentLast24Hours}'
Reference: Request production access (moving out of the Amazon SES sandbox)
and aws sesv2 put-account-details.
While in the sandbox, verify a recipient address with aws sesv2 create-email-identity --email-identity
you@example.org and use the mailbox simulator addresses (success@simulator.amazonses.com,
bounce@simulator.amazonses.com, complaint@simulator.amazonses.com) for tests that must not hurt your reputation.
Configuration sets and event destinations
A configuration set is a named bundle of sending settings that you attach to a message (or set as the identity’s default). Its most important job is carrying event destinations: places where SES publishes one event per send, delivery, bounce, complaint, reject, delivery delay, subscription, open and click.
| Destination | Typical use |
|---|---|
Amazon EventBridge |
Route events by pattern to Lambda, SQS, Step Functions or another account. SES publishes to the default event bus of the account and Region. This is the best fit for Bookshelf’s bounce handling. |
Amazon SNS |
Fan out bounce and complaint notifications to subscribers (email, HTTPS, SQS). Simple, but less filtering than EventBridge. |
Amazon CloudWatch |
Metrics per dimension (for example a message tag |
Amazon Data Firehose |
Archive every event, with its full detail, to S3 or an analytics store. |
A configuration set also controls the sending pool (a dedicated IP pool), custom open and click-tracking domains, reputation metrics and whether sending is enabled for that set, which lets you pause one stream of mail without touching the rest. Up to 10 event destinations can be attached to one configuration set.
In the AWS Management Console
-
In Amazon SES choose Configuration > Configuration sets > Create set, name it
bookshelf-default, and enable Reputation metrics (so the console tracks the set’s bounce and complaint rates). -
Open the set, choose the Event destinations tab > Add destination.
-
Select the events: Rejects, Bounces, Complaints, Deliveries and Delivery delays (open and click events require tracking domains and add volume you may not need).
-
Choose Amazon EventBridge as the destination type, name it
to-eventbridge, keep Enable event publishing, and choose Add destination. -
In Amazon EventBridge create a rule on the default bus (see below) that matches these events.
-
In Identities, open
example.com, Configuration set > Edit and selectbookshelf-defaultas the default so every message from the domain is tracked even if the application forgets to pass a name.
With the AWS CLI
aws sesv2 create-configuration-set --configuration-set-name bookshelf-default \
--reputation-options ReputationMetricsEnabled=true \
--sending-options SendingEnabled=true
cat > ses-event-destination.json <<'EOF'
{
"Enabled": true,
"MatchingEventTypes": ["BOUNCE", "COMPLAINT", "REJECT", "DELIVERY", "DELIVERY_DELAY"],
"EventBridgeDestination": {
"EventBusArn": "arn:aws:events:us-east-1:111122223333:event-bus/default"
}
}
EOF
aws sesv2 create-configuration-set-event-destination \
--configuration-set-name bookshelf-default \
--event-destination-name to-eventbridge \
--event-destination file://ses-event-destination.json
# Make it the identity default
aws sesv2 put-email-identity-configuration-set-attributes \
--email-identity "$DOMAIN" --configuration-set-name bookshelf-default
A rule on the default bus that forwards bounces and complaints to the queue that notifier (or a small handler)
reads:
aws events put-rule --name bookshelf-ses-feedback --event-pattern '{
"source": ["aws.ses"],
"detail": { "eventType": ["Bounce", "Complaint"] }
}'
aws events put-targets --rule bookshelf-ses-feedback \
--targets "Id"="feedback-queue","Arn"="arn:aws:sqs:us-east-1:111122223333:bookshelf-ses-feedback"
The queue needs a resource policy that lets events.amazonaws.com send messages from that rule’s ARN; the
messaging side of that pattern (queues, dead-letter queues, handlers) is on
Messaging and Integration, and a Lambda handler with partial batch responses is on
Lambda: Event-Driven and Serverless Apps. SNS works the same way with an SnsDestination
(TopicArn) in the JSON instead.
Reference: Using configuration sets in Amazon SES,
EventBridge event destination
and create-configuration-set-event-destination.
Confirm the exact event fields (detail.eventType, detail.bounce, detail.mail.messageId) against
Monitoring SES events using Amazon EventBridge
before you write the handler.
Sending: API or SMTP
SES offers two ways in. Choose by what the caller can do, not by habit.
| Aspect | SES v2 API (SendEmail) |
SMTP interface |
|---|---|---|
Authentication |
AWS Signature Version 4 with IAM credentials: roles, task roles, Lambda execution roles, Pod Identity. No long-lived secret. |
An SMTP user name and password. The password is derived from an IAM access key and is valid in one Region only. |
Features |
Simple, raw (MIME) and templated messages, bulk templated sends, configuration set and message tags as typed fields, 40 MB message limit. |
Whatever the SMTP client supports; configuration set and tags via headers ( |
Network |
HTTPS to |
|
Choose it when |
You control the code. This is the default for Bookshelf’s |
A framework or product only speaks SMTP: an existing |
With the SES v2 API from the CLI
# Simple message
aws sesv2 send-email \
--from-email-address "Bookshelf <no-reply@example.com>" \
--destination "ToAddresses=customer@example.org" \
--content '{"Simple":{"Subject":{"Data":"Your Bookshelf order 4711"},"Body":{"Text":{"Data":"Thank you for your order."},"Html":{"Data":"<p>Thank you for your order.</p>"}}}}' \
--configuration-set-name bookshelf-default
# A stored template with replacement data
aws sesv2 create-email-template --template-name order-confirmation \
--template-content '{"Subject":"Order {{orderId}}","Text":"Hello {{name}}, order {{orderId}} is confirmed.","Html":"<p>Hello {{name}}, order {{orderId}} is confirmed.</p>"}'
aws sesv2 send-email \
--from-email-address no-reply@example.com \
--destination "ToAddresses=customer@example.org" \
--content '{"Template":{"TemplateName":"order-confirmation","TemplateData":"{\"name\":\"Ana\",\"orderId\":\"4711\"}"}}' \
--configuration-set-name bookshelf-default
Raw sends (Content.Raw.Data, a base64 MIME message) are needed for attachments; send-bulk-email sends one
template to many destinations in a single call. A single message may have at most 50 recipients across To, CC and
BCC. Reference: aws sesv2 send-email,
create-email-template and
Using the SES API to send email.
From Java with the AWS SDK for Java 2.x
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sesv2</artifactId>
<version><!-- use the AWS SDK BOM --></version>
</dependency>
package com.example.bookshelf.notifier;
import software.amazon.awssdk.services.sesv2.SesV2Client;
import software.amazon.awssdk.services.sesv2.model.Body;
import software.amazon.awssdk.services.sesv2.model.Content;
import software.amazon.awssdk.services.sesv2.model.Destination;
import software.amazon.awssdk.services.sesv2.model.EmailContent;
import software.amazon.awssdk.services.sesv2.model.Message;
import software.amazon.awssdk.services.sesv2.model.MessageTag;
import software.amazon.awssdk.services.sesv2.model.SendEmailRequest;
import software.amazon.awssdk.services.sesv2.model.SendEmailResponse;
public class OrderMailer {
// Default credential chain: the ECS task role, the Lambda execution role or an EKS Pod Identity.
// Region comes from AWS_REGION; never put access keys in code or configuration.
private final SesV2Client ses = SesV2Client.create();
public String sendConfirmation(String to, String orderId) {
SendEmailRequest request = SendEmailRequest.builder()
.fromEmailAddress("Bookshelf <no-reply@example.com>")
.destination(Destination.builder().toAddresses(to).build())
.configurationSetName("bookshelf-default")
.emailTags(MessageTag.builder().name("orderId").value(orderId).build())
.content(EmailContent.builder()
.simple(Message.builder()
.subject(Content.builder().data("Your Bookshelf order " + orderId).charset("UTF-8").build())
.body(Body.builder()
.text(Content.builder().data("Thank you for your order.").charset("UTF-8").build())
.build())
.build())
.build())
.build();
SendEmailResponse response = ses.sendEmail(request);
return response.messageId(); // store it: bounce and complaint events carry it
}
}
Store the returned messageId (or a message tag such as orderId) next to the order. SES accepts the message
asynchronously, so a successful call means "accepted", not "delivered"; the delivery outcome arrives only as an
event. Spring Cloud AWS 4.x also offers spring-cloud-aws-starter-ses, whose MailSender implementation wraps the
same client; its reference is linked below.
Over SMTP: Spring JavaMailSender and Quarkus Mailer
The SMTP interface lets any SMTP client talk to SES by changing four settings: host, port, user name and password.
Create the SMTP credentials
SMTP credentials are not your AWS access key. They are created for an IAM user and are valid only in the Region they were generated for.
-
In Amazon SES choose SMTP settings and note the endpoint (for example
email-smtp.us-east-1.amazonaws.com) and the ports. -
Choose Create SMTP credentials. The IAM console opens with a suggested user name such as
ses-smtp-user.<date>; choose Create user. -
Choose Show under SMTP password and store the SMTP user name and SMTP password immediately in AWS Secrets Manager (next section). They cannot be displayed again.
-
In IAM, confirm the user belongs to the group created for it (
AWSSESSendingGroupDoNotRename) with theAmazonSesSendingAccesspolicy; keep the permission toses:SendRawEmail.
From the CLI, create a dedicated user, give it send-only permission, create an access key, and derive the SMTP password with the documented algorithm (the SMTP user name is the access key ID):
aws iam create-user --user-name bookshelf-ses-smtp
aws iam put-user-policy --user-name bookshelf-ses-smtp --policy-name ses-send-only \
--policy-document file://ses-smtp-send-policy.json
aws iam create-access-key --user-name bookshelf-ses-smtp
# Derive <smtp-password> from the secret access key and the Region with the script in the SES
# documentation ("Obtaining Amazon SES SMTP credentials"), then discard the access key if you wish.
Store the result, once, in Secrets Manager rather than in a file or an environment variable baked into an image:
aws secretsmanager create-secret --name bookshelf/notifier/ses-smtp \
--secret-string '{"username":"<smtp-username>","password":"<smtp-password>"}'
Do not derive SMTP credentials from temporary credentials (a role session); SES does not support that. SMTP therefore always means a long-lived IAM user, which is the main reason to prefer the API. Rotating the credentials is a manual procedure (create a second user or key, update the secret, delete the old user), so schedule it. Secrets Manager and KMS are covered on Secrets Manager, Parameter Store and KMS.
Spring Boot
Add spring-boot-starter-mail and point it at the regional endpoint, loading the credentials from Secrets
Manager through Spring Cloud AWS (the import mechanism is explained on
Configuration and Profiles):
spring:
config:
import: "aws-secretsmanager:bookshelf/notifier/ses-smtp"
mail:
host: email-smtp.us-east-1.amazonaws.com
port: 587
username: ${username}
password: ${password}
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
mail.smtp.starttls.required: true
mail.smtp.connectiontimeout: 5000
mail.smtp.timeout: 10000
mail.smtp.writetimeout: 10000
package com.example.bookshelf.notifier;
import jakarta.mail.internet.MimeMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@Service
public class SmtpOrderMailer {
private final JavaMailSender mailSender;
public SmtpOrderMailer(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
public void sendConfirmation(String to, String orderId) throws Exception {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
helper.setFrom("no-reply@example.com");
helper.setTo(to);
helper.setSubject("Your Bookshelf order " + orderId);
helper.setText("Thank you for your order.", false);
// Route the message through the configuration set and tag it
message.addHeader("X-SES-CONFIGURATION-SET", "bookshelf-default");
message.addHeader("X-SES-MESSAGE-TAGS", "orderId=" + orderId);
mailSender.send(message);
}
}
The ${username} and ${password} placeholders resolve from the JSON keys of the imported secret. If your property
names clash with other configuration, store the secret with prefixed keys such as ses.username instead.
Quarkus Mailer
quarkus.mailer.from=no-reply@example.com
quarkus.mailer.host=email-smtp.us-east-1.amazonaws.com
quarkus.mailer.port=587
quarkus.mailer.start-tls=REQUIRED
quarkus.mailer.username=${SES_SMTP_USERNAME}
quarkus.mailer.password=${SES_SMTP_PASSWORD}
%dev.quarkus.mailer.mock=true
%test.quarkus.mailer.mock=true
The Mailer and ReactiveMailer APIs are unchanged from Scheduling and Mail; only the
host, port and credentials differ. Inject SES_SMTP_USERNAME and SES_SMTP_PASSWORD from the secret at deploy time
(an ECS secrets entry, a Kubernetes secret, or the Quarkus AWS Secrets Manager config source), never from a
committed file. Add the two SES headers with Mail.addHeader("X-SES-CONFIGURATION-SET", "bookshelf-default") so
that SMTP mail also publishes events.
Port 25 and EC2
Mail servers talk to each other on port 25, so EC2 throttles outbound port 25 by default to stop abuse, and many other networks block it entirely. Do not try to use port 25 for SES from compute services. Use 587 (STARTTLS, the standard submission port), 2587 (the same, when 587 is blocked), 465 or 2465 (implicit TLS), or use the API over HTTPS. If you truly need port 25 from EC2 you can ask AWS to remove the throttle with a request form, and you may also need a reverse DNS record for an Elastic IP.
Permissions
Applications should send with an IAM role, scoped to the sending identities and the configuration set. This is
the permission policy for the notifier task role or Lambda execution role (its trust policy is the ECS tasks or
Lambda principal, see Identity and Access):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SendFromBookshelfDomain",
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail", "ses:SendBulkEmail"],
"Resource": [
"arn:aws:ses:us-east-1:111122223333:identity/example.com",
"arn:aws:ses:us-east-1:111122223333:configuration-set/bookshelf-default"
],
"Condition": {
"StringEquals": {
"ses:FromAddress": "no-reply@example.com"
}
}
},
{
"Sid": "ManageOwnSuppressionList",
"Effect": "Allow",
"Action": ["ses:PutSuppressedDestination", "ses:GetSuppressedDestination"],
"Resource": "*"
}
]
}
The ses:FromAddress condition stops a compromised notifier from sending as any other address in the domain. When
a message uses a stored template, add arn:aws:ses:us-east-1:111122223333:template/order-confirmation to
Resource. The send-only policy for an SMTP user is the same two statements reduced to the single action
ses:SendRawEmail on , which is what SES’s own AmazonSesSendingAccess policy contains. The administrator who
sets SES up needs ses: plus Route 53 write access to the zone, and, for the console’s "Create SMTP credentials"
flow, iam:CreateUser, iam:CreateGroup, iam:PutGroupPolicy, iam:AddUserToGroup and iam:CreateAccessKey, which
are high-privilege permissions that deserve a separate break-glass role. SES also supports identity policies so that
another account can send as your identity; see the SES developer guide on sending authorization.
Bounces, complaints and the suppression list
A hard bounce means the address will never work; a soft bounce is a temporary failure that SES retries for a while before reporting it; a complaint means the recipient pressed "report spam". Sending again to hard-bouncing or complaining addresses is what damages a sender’s reputation, so the loop is: receive the event, mark the address, stop sending.
SES helps with the account-level suppression list. Once enabled for bounces and complaints, SES refuses to deliver to any address on the list and counts the attempt as a suppressed send rather than a bounce. You can list, add and remove addresses.
# Suppress both bounces and complaints at the account level
aws sesv2 put-account-suppression-attributes --suppressed-reasons BOUNCE COMPLAINT
# Add and inspect entries
aws sesv2 put-suppressed-destination --email-address customer@example.org --reason BOUNCE
aws sesv2 list-suppressed-destinations \
--query 'SuppressedDestinationSummaries[].[EmailAddress,Reason,LastUpdateTime]' --output table
aws sesv2 delete-suppressed-destination --email-address customer@example.org
# Override for one configuration set (for example a stream that must never be suppressed)
aws sesv2 put-configuration-set-suppression-options \
--configuration-set-name bookshelf-default --suppressed-reasons BOUNCE COMPLAINT
In the console these settings are under Configuration > Suppression list. The separate, AWS-wide global suppression list (addresses that recently hard-bounced for any SES customer) is applied by SES regardless of your settings, which is why a send to a never-used address can bounce immediately. Remove an address from your own list only when the recipient has fixed the mailbox and asked to receive mail again.
Bookshelf’s feedback handler consumes Bounce and Complaint events from the EventBridge rule above, flags the
customer record (so orders-api stops asking notifier to mail the address) and logs the SES messageId. The
correlation uses the orderId message tag, because SES documents message tags as the reliable way to correlate
events with sends.
Deliverability
Mailbox providers decide where a message lands from your reputation. SES gives you three levels of visibility:
-
The Reputation metrics page and the CloudWatch metrics
Reputation.BounceRateandReputation.ComplaintRateshow the account’s rates. AWS reviews an account whose rates stay high and can pause sending if they are not corrected; the official guidance gives the exact review and pause thresholds, and the safe habit is to keep the bounce rate well under a few percent and the complaint rate under a tenth of a percent. Alarm on both. -
Virtual Deliverability Manager (VDM) is an optional, paid dashboard and advisor with per-ISP delivery rates, engagement metrics and recommendations. Enable it per account; it adds a charge per message and, for the global view, a monthly fee.
-
Dedicated IPs give you your own sending addresses and reputation. A standard dedicated IP pool you warm up yourself; a managed pool lets SES allocate and warm IPs automatically. The shared pools are enough for Bookshelf’s volume; consider dedicated IPs only when you send large, steady volumes and need isolation.
Practical rules that matter more than any feature: send only to people who expect the message, include a
List-Unsubscribe header for anything that is not strictly transactional, keep the From domain the verified
identity, and do not retry blindly on an auto-response or a hard bounce.
Receiving email, Mail Manager and tenants
SES can also receive mail for a verified domain. Receipt rules (in a rule set, with an MX record pointing the
domain at inbound-smtp.<region>.amazonaws.com) run actions such as storing the message in S3, publishing to SNS or
invoking Lambda; the receiving feature is available only in some Regions. Mail Manager is the newer, broader
product: ingress endpoints with traffic policies, rule sets, SMTP relays to a downstream mail server and message
archiving. Tenants let one account separate sending streams (for example per customer of a multi-tenant SaaS) with
their own reputation tracking and automatic pausing. Bookshelf needs none of them, so this page only names them; see
the SES developer guide for each.
Quotas
SES quotas are per Region and per account. The ones that matter first:
| Quota | Default | Adjustable |
|---|---|---|
Sending quota, 24 hours |
200 in the sandbox; set by AWS from your use case once in production. |
Yes, by request. |
Sending rate (messages per second) |
1 in the sandbox; set from your use case in production. |
Yes, by request. |
Maximum message size (v2 API and SMTP, including attachments, after base64) |
40 MB (10 MB through the older v1 API). |
No. |
Recipients per message (To, CC and BCC) |
50. |
No. |
Verified identities per Region |
10,000. |
Contact your account manager. |
Configuration sets per Region |
10,000, with up to 10 event destinations each. |
No. |
Email templates per Region |
20,000, each at most 500 KB. |
No. |
API request rate for actions other than the send calls |
1 request per second. |
No. |
Quotas count recipients, not messages: a message to ten addresses uses ten of the daily quota. To request an
increase choose Account dashboard > Request a sending limit increase (or use Service Quotas where listed),
explain the volume and the bounce handling, and expect AWS to raise the limit in steps as your reputation allows.
Check the current numbers in aws sesv2 get-account. Reference:
Service quotas in Amazon SES and
Managing your Amazon SES sending limits.
Pricing model and cost drivers
Prices change and differ by Region; this page names the dimensions and not the amounts. Check the Amazon SES pricing page and the AWS Pricing Calculator (both dated by the page, 2026-10-01 at the time of writing) before you plan.
-
Messages sent. SES is pay-as-you-go per thousand messages. In 2026 AWS introduced optional pricing plans with tiered per-thousand rates that include a number of add-on features and, for the higher tiers, a monthly minimum; the per-thousand pay-as-you-go ("a la carte") model remains available. Compare them against your expected monthly volume.
-
Attachments and data. Attachment data is charged per gigabyte, so large attachments cost far more than the message count suggests.
-
Received mail. Inbound messages and their chunks are charged separately.
-
Dedicated IPs. A monthly charge per standard IP or per managed pool.
-
Virtual Deliverability Manager. Per message, with an additional monthly fee for global visibility.
-
Mail Manager. Charged per message processed, per open ingress endpoint per month and per gigabyte archived.
-
Event destinations. CloudWatch dimensions, Firehose delivery, SNS and EventBridge each bill on their own pricing. A busy stream with open and click events can cost more in events than in sending.
-
Data transfer and the compute that sends. Outbound HTTPS from a private subnet may use a NAT gateway; an SES interface VPC endpoint avoids it (see VPC Networking).
-
The Free Tier for new accounts is credit-based rather than a permanent free message allowance; check the current terms on the pricing page.
Troubleshooting
| Symptom | Likely cause | What to check |
|---|---|---|
|
Sending from an address or domain that is not verified in this Region, or to an unverified recipient while in the sandbox. |
|
Identity stuck in Pending verification or DKIM Failed |
The CNAME records are missing, mistyped, or a DNS provider appended the domain to the name. |
|
|
The role lacks the action on the identity (and configuration set) ARN, or |
The policy above, the identity ARN in the same Region and account, and the exact From address. |
SMTP |
The password is the raw secret access key, or was generated for another Region, or the user name is wrong. |
Regenerate the credentials for this Region’s endpoint, use the access key ID as user name, check the secret value and for stray whitespace or newline characters. |
SMTP connection times out |
Port 25 blocked or throttled, a security group or network ACL blocks the egress, or no NAT/VPC endpoint. |
Use 587 or 2587, |
Mail goes to spam, or Gmail/Yahoo reject with a DMARC failure |
No alignment: DKIM disabled, mismatched From domain, no custom MAIL FROM for SPF, a missing DMARC record, or a poor reputation. |
Show original headers: |
|
Sending faster than the account’s per-second rate, or a burst from many |
|
|
The 24-hour recipient quota is used (messages with many recipients count per recipient). |
The Account dashboard, and a quota increase request. |
Sending suddenly disabled, or account under review |
Bounce or complaint rate crossed the review or pause threshold. |
The SES Reputation page, the notifications at the account’s contact address, and the corrective-action plan the review asks for; fix list hygiene first. |
Recipient never receives the message but SES shows |
Address is on the account or global suppression list, the message is delayed, or it is silently filtered. |
|
No events arrive in EventBridge |
The message did not use the configuration set (no default on the identity), the event type is not selected, or the rule pattern is wrong. |
The identity default and the |
Template send fails with |
A placeholder in the template has no value in |
|
Retirements and related services
Two AWS messaging products that older guides recommend are ending, and the replacement is SES or a non-AWS product:
-
Amazon Pinpoint reaches end of support on 2026-10-30, and has not accepted new customers since 2025-05-20. After that date the Pinpoint console and its resources (endpoints, segments, campaigns, journeys, analytics) are no longer available. If you used Pinpoint to send email, move to SES (and VDM for the deliverability dashboard); SMS, voice, mobile push and OTP APIs continue as AWS End User Messaging; engagement features move to Amazon Connect outbound campaigns and Customer Profiles. Because the date is close at the time of writing, verify the current status on the migration page below.
-
Amazon WorkMail (a managed mailbox for people, not for applications) reaches end of support on 2027-03-31 and has not accepted new customers since 2026-04-30. AWS recommends third-party mailbox services and provides a mailbox export guide. WorkMail was never the way for an application to send mail; SES remains the service for that.
For Bookshelf the only change is that new work targets SES for email and AWS End User Messaging for SMS or push.
Clean up
SES identities and configuration sets cost nothing while idle, but event destinations, Route 53 records, SMTP users and templates remain until you remove them. Delete in this order:
# 1. Stop the sources of mail first: scale notifier to zero or remove its deployment
# 2. Remove the feedback routing
aws events remove-targets --rule bookshelf-ses-feedback --ids feedback-queue
aws events delete-rule --name bookshelf-ses-feedback
# 3. Detach the configuration set from the identity, then delete it with its event destination
aws sesv2 put-email-identity-configuration-set-attributes --email-identity "$DOMAIN"
aws sesv2 delete-configuration-set-event-destination \
--configuration-set-name bookshelf-default --event-destination-name to-eventbridge
aws sesv2 delete-configuration-set --configuration-set-name bookshelf-default
# 4. Delete templates and suppression entries you added
aws sesv2 delete-email-template --template-name order-confirmation
aws sesv2 delete-suppressed-destination --email-address customer@example.org
# 5. Delete the identity (this stops sending from the domain and removes its DKIM keys)
aws sesv2 delete-email-identity --email-identity "$DOMAIN"
# 6. Remove the DNS records you created: the three DKIM CNAMEs, the MAIL FROM MX and TXT, and the DMARC TXT
aws route53 change-resource-record-sets --hosted-zone-id "$ZONE_ID" --change-batch file://ses-dkim-delete.json
# 7. Remove SMTP credentials and the secret
aws iam delete-access-key --user-name bookshelf-ses-smtp --access-key-id <access-key-id>
aws iam delete-user-policy --user-name bookshelf-ses-smtp --policy-name ses-send-only
aws iam delete-user --user-name bookshelf-ses-smtp
aws secretsmanager delete-secret --secret-id bookshelf/notifier/ses-smtp --recovery-window-in-days 7
Production access is an account setting and is not "cleaned up"; the account stays out of the sandbox. Delete the
SQS queue and the Lambda or ECS service that consumed the events with the commands on their own pages. If you
created the resources with CloudFormation, aws cloudformation delete-stack removes the identity, configuration
set and records the stack owns.
Related pages
-
Scheduling and Mail covers the Quarkus Mailer API used over SMTP above.
-
Configuration and Profiles covers the Spring
MailSenderbeans and the Secrets Manager config import. -
Secrets Manager, Parameter Store and KMS stores the SMTP credentials.
-
Route 53, DNS and ACM Certificates hosts the DKIM, MAIL FROM and DMARC records.
-
Messaging and Integration and Lambda: Event-Driven and Serverless Apps cover the queues, EventBridge rules and Lambda handlers that process bounce and complaint events.
-
Identity and Access explains the task and execution roles used for API sending.
-
Email with Azure Communication Services is the Azure counterpart.