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.

sequenceDiagram participant App as notifier (task or Lambda role) participant SES as Amazon SES participant MTA as Recipient mail server participant EB as EventBridge default bus participant Q as SQS queue or Lambda handler App->>SES: SendEmail (identity example.com, configuration set bookshelf-default) SES-->>App: MessageId SES->>MTA: SMTP delivery, DKIM-signed alt Delivered MTA-->>SES: 250 accepted SES->>EB: event Delivery else Hard bounce MTA-->>SES: 550 mailbox does not exist SES->>EB: event Bounce EB->>Q: rule matches aws.ses Bounce Q->>SES: PutSuppressedDestination (optional, app-level) else Recipient reports spam MTA-->>SES: feedback-loop report SES->>EB: event Complaint EB->>Q: rule matches aws.ses Complaint end

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

  1. Open Amazon SES and select the Region in the selector (the same Region as the application).

  2. Choose Configuration > Identities > Create identity.

  3. Choose Domain and enter example.com. For a delegated sub-domain such as mail.example.com, enter that instead.

  4. 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.

  5. Optionally assign a default configuration set (see Configuration sets and event destinations) and choose Create identity.

  6. 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.

  7. Within minutes (up to 72 hours for other DNS hosts) the Identity status turns Verified and DKIM configuration turns Successful.

  8. Open the identity’s Custom MAIL FROM domain tab, choose Edit, set mail.example.com and 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}'

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 From: header aligns with the DKIM domain or the SPF domain, and tells receivers what to do when it does not.

SES does not publish DMARC for you. You publish one TXT record at _dmarc.<domain>. Easy DKIM alone is enough to align DKIM; a custom MAIL FROM adds SPF alignment as a second path.

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.

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

  1. Open Amazon SES in the target Region and choose Get set up on the dashboard, or Account dashboard > Request production access.

  2. Choose the Mail type: Transactional for Bookshelf’s order confirmations (Marketing if you send newsletters).

  3. Enter the Website URL of the service that sends the mail.

  4. 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).

  5. Add up to four Additional contact addresses and the Preferred contact language, acknowledge the policies and choose Submit request.

  6. 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 campaign), for dashboards and alarms.

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

  1. 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).

  2. Open the set, choose the Event destinations tab > Add destination.

  3. Select the events: Rejects, Bounces, Complaints, Deliveries and Delivery delays (open and click events require tracking domains and add volume you may not need).

  4. Choose Amazon EventBridge as the destination type, name it to-eventbridge, keep Enable event publishing, and choose Add destination.

  5. In Amazon EventBridge create a rule on the default bus (see below) that matches these events.

  6. In Identities, open example.com, Configuration set > Edit and select bookshelf-default as 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 (X-SES-CONFIGURATION-SET, X-SES-MESSAGE-TAGS). 40 MB limit.

Network

HTTPS to email.<region>.amazonaws.com (or the interface endpoint).

email-smtp.<region>.amazonaws.com, ports 587 or 2587 (STARTTLS) and 465 or 2465 (implicit TLS); 25 works but is throttled from EC2.

Choose it when

You control the code. This is the default for Bookshelf’s notifier.

A framework or product only speaks SMTP: an existing JavaMailSender, a CMS, a printer, a legacy job.

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.

  1. In Amazon SES choose SMTP settings and note the endpoint (for example email-smtp.us-east-1.amazonaws.com) and the ports.

  2. Choose Create SMTP credentials. The IAM console opens with a suggested user name such as ses-smtp-user.<date>; choose Create user.

  3. 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.

  4. In IAM, confirm the user belongs to the group created for it (AWSSESSendingGroupDoNotRename) with the AmazonSesSendingAccess policy; keep the permission to ses: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.BounceRate and Reputation.ComplaintRate show 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

MessageRejected: Email address is not verified. The following identities failed the check

Sending from an address or domain that is not verified in this Region, or to an unverified recipient while in the sandbox.

aws sesv2 list-email-identities in the same Region as the client; whether the account is still in the sandbox (get-account).

Identity stuck in Pending verification or DKIM Failed

The CNAME records are missing, mistyped, or a DNS provider appended the domain to the name.

dig CNAME <token>._domainkey.example.com, the exact record names in the console, and the 72-hour window.

AccessDenied or not authorized to perform ses:SendEmail

The role lacks the action on the identity (and configuration set) ARN, or ses:FromAddress does not match.

The policy above, the identity ARN in the same Region and account, and the exact From address.

SMTP 535 Authentication Credentials Invalid

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, openssl s_client -starttls smtp -connect email-smtp.us-east-1.amazonaws.com:587, and the VPC egress path.

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: dkim=pass, spf=pass, dmarc=pass; the DMARC reports; the reputation dashboard.

Throttling or Maximum sending rate exceeded

Sending faster than the account’s per-second rate, or a burst from many notifier replicas.

get-account quotas, add retry with exponential backoff and jitter, and request a rate increase.

Daily message quota exceeded

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 Send and no Delivery

Address is on the account or global suppression list, the message is delayed, or it is silently filtered.

aws sesv2 get-suppressed-destination --email-address …​, DeliveryDelay events, and the mailbox simulator.

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 X-SES-CONFIGURATION-SET header, the destination’s matching events, and the rule’s event pattern against a sample event.

Template send fails with RenderingFailure

A placeholder in the template has no value in TemplateData, or the data is not valid JSON.

aws sesv2 test-render-email-template and the RenderingFailure event detail.

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.