Adding Amazon SES to Your App
Disclaimer: I create this content entirely on my own time, and the views expressed here are mine alone (not my employer’s). Because I love leveraging new tech, I use AI tools like Gemini, ChatGPT, Claude, Perplexity and others as a “digital team” to help research and polish these articles so I can share the best possible insights with you!
Building a Production Email System with Amazon SES, SNS, and Your Application
Most web applications eventually need to send email.
At first, it seems simple enough. Configure an SMTP server, provide a username and password, and send a message.
But once an application starts supporting things like memberships, newsletters, account notifications, contact forms, business outreach, password resets, and other transactional messages, email becomes more than a simple SMTP connection.
You need to think about:
- Deliverability
- Domain authentication
- Bounces
- Complaints
- Unsubscribes
- Suppression lists
- Different types of email
- Development versus production
- Security
- Monitoring
I recently went through this process for a web application and decided to use Amazon Simple Email Service (SES) as the production email platform.
This article documents the architecture and general process. It isn’t intended as a click-by-click AWS tutorial because AWS consoles change over time. Instead, it focuses on the pieces that need to exist and how they fit together.
Why Amazon SES?
There are plenty of services for application email.
For applications where I control the application code and database, I like the idea of keeping the email intelligence inside the application while using a service such as Amazon SES primarily for reliable email transport and delivery.
The application remains responsible for things such as:
- Who should receive email
- Membership status
- Newsletter subscriptions
- Unsubscribes
- Suppression rules
- Templates
- Campaign or message classification
- Application-specific logging
SES handles the actual delivery infrastructure.
That separation is important.
I don’t want my application to simply say:
Send this email.
I want it to know:
This is a newsletter message for this subscriber, using this sending category, and this recipient is currently eligible to receive it.
Then SES handles getting the message onto the Internet.
The Architecture
The resulting architecture looks roughly like this:
PRODUCTION
Application
|
| SendEmail
v
Amazon SES
|
+----> Recipient
|
+----> Delivery / Bounce / Complaint / Delay events
|
v
Amazon SNS
|
v
Application Webhook
|
v
Database
|
+-------+-------+
| |
Event Log Suppressions
Development follows a completely different path:
DEVELOPMENT
Application
|
| SMTP
v
Mailpit
|
v
Local Test Inbox
That distinction turned out to be one of the most important architectural decisions.
Development email should never accidentally become real production email.
Step 1: Verify the Sending Domain
Before SES can send mail from your domain, AWS needs to verify that you control it.
Instead of verifying one individual email address at a time, I prefer verifying the domain used by the application.
For example:
example.com
SES provides DNS records that must be added to the domain’s DNS provider.
Once the domain is verified, SES can use that verified identity for sending addresses under the domain, subject to the SES identity rules and configuration.
AWS describes a verified identity as a domain or email address authorized for use as a sending identity. Domain verification is therefore usually a better foundation for an application that may eventually use several sender addresses.
Step 2: Configure DKIM
Domain verification is only part of the email authentication story.
DKIM — DomainKeys Identified Mail — allows receiving mail systems to cryptographically verify that a message claiming to come from your domain was authorized by the sending infrastructure.
SES provides the required DKIM DNS records.
Those records are added to your DNS provider and SES subsequently verifies them.
This is an important distinction:
Application identity
+
Domain authentication
+
Email transport
They are related, but they are not the same thing.
Step 3: Configure a Custom MAIL FROM Domain
There are actually two “from” concepts involved when sending email.
The first is the address people normally see:
From: My Application <hello@example.com>
The second is the envelope sender, commonly called the MAIL FROM or Return-Path address.
SES can automatically use an Amazon SES domain for this, but it can also be configured to use a subdomain you control.
For example:
bounce.example.com
That requires additional MX and SPF-related DNS configuration.
The visible sender can still be:
hello@example.com
The bounce infrastructure operates behind the scenes through the MAIL FROM domain.
This helps create a cleaner, domain-aligned email configuration.
Step 4: Configure DMARC
I also recommend configuring DMARC for the sending domain.
DMARC works with SPF and DKIM and gives receiving systems guidance about how mail claiming to originate from your domain should be authenticated and handled.
Don’t treat DNS authentication as an optional finishing touch.
For production email, authentication should be part of the initial architecture.
Step 5: Move SES Out of the Sandbox
New SES accounts begin in a sandbox.
The sandbox is useful for testing, but it restricts where messages can be sent and imposes much lower sending limits.
A production application needs to request production access in the AWS Region where SES will operate.
This regional aspect matters.
Your SES identities, configuration and production status need to be considered in the context of the AWS Region you selected.
Once production access is approved, the application can send to normal recipients rather than only verified destinations.
Step 6: Don’t Treat All Email the Same
This is where the architecture became more interesting.
A modern application might send several fundamentally different types of messages.
For example:
Newsletter
"Here is this week's community newsletter."
Transactional
"Your account has been created."
"Your listing has been approved."
"We received your request."
Outreach
"We're introducing our service to businesses in your area."
These messages have different purposes and potentially different rules.
I don’t want to mix everything together simply because all of it happens to be email.
SES provides configuration sets, which allow rules and event publishing behavior to be associated with messages.
For example, an application might create:
app-newsletter
app-outreach
A larger application could decide that transactional email deserves its own configuration as well.
The important part is not the names.
The important part is creating logical separation between different email workloads.
Step 7: Create SES Configuration Sets
Configuration sets can be attached to messages sent through SES.
They provide a mechanism for applying configuration and publishing events associated with particular classes of messages.
For example:
Newsletter
|
+--> app-newsletter
Outreach
|
+--> app-outreach
When the application sends a newsletter, it explicitly tells SES which configuration set applies.
This is critical.
Simply creating a configuration set in AWS does not automatically associate every email with it. The application must specify the appropriate configuration set when sending the message.
That makes message classification an application responsibility rather than an assumption buried inside the email infrastructure.
Step 8: Decide Which Events Matter
SES can generate events associated with email delivery.
Depending on the application, useful events can include:
- Delivery
- Bounce
- Complaint
- Delivery delay
- Rendering failure
SES also supports engagement-related events such as opens and clicks.
I intentionally view those differently.
If an application doesn’t need open and click tracking, there is no reason to collect that information merely because the email system supports it.
For many membership, community and transactional applications, the operational questions are more important:
Did it deliver?
Did it bounce?
Did the recipient complain?
Is delivery being delayed?
Did something prevent the message from rendering?
Collect the information the application actually needs.
Step 9: Use SNS to Bring Events Back Into the Application
Sending is only half of the system.
The application also needs to learn what happened after SES accepted the message.
Amazon Simple Notification Service (SNS) provides a convenient mechanism for doing this.
The flow becomes:
SES
|
| Email Event
v
SNS Topic
|
| HTTPS POST
v
Application
The application exposes an endpoint such as:
POST /api/email/ses-events
SNS sends event notifications to that endpoint.
Configuration sets can have SNS event destinations, allowing different classes of email to publish their events appropriately.
Step 10: Secure the SNS Webhook
A public webhook should never blindly trust incoming JSON just because it looks like an AWS message.
The application should validate SNS notifications.
That includes verifying the SNS message signature and validating information such as the topic from which the notification originated.
The application should know which SNS topics it expects.
Conceptually:
Incoming SNS message
|
v
Verify signature
|
v
Verify expected Topic ARN
|
v
Determine SES event
|
v
Process event
SNS subscriptions also involve a confirmation process.
When an HTTPS endpoint subscribes to a topic, SNS sends a subscription confirmation request. The endpoint needs to securely process that confirmation before normal notifications begin arriving.
Step 11: Store Email Events
I don’t like treating webhook events as temporary application logs.
They can be valuable operational records.
A simple table might contain information such as:
email_events
id
message_id
event_type
recipient
configuration_set
received_at
payload
One particularly important field is the SNS message identifier.
Webhooks can be delivered more than once.
Therefore event processing should be idempotent.
If the same SNS message arrives twice, the application should recognize that it has already processed it rather than creating duplicate actions.
Step 12: Build a Suppression System
This is one of the most important pieces of the entire design.
Imagine this sequence:
Application sends newsletter
|
v
Address hard-bounces
|
v
SES reports bounce
|
v
Application ignores it
|
v
Next newsletter sends again
|
v
Address bounces again
That’s exactly what I don’t want.
Instead:
Hard Bounce
|
v
SNS Event
|
v
Application
|
v
Suppression Table
Then, before sending future email:
Recipient
|
v
Check suppression
|
+---- Suppressed ---> DO NOT SEND
|
+---- Allowed ------> SES
A simple suppression table could contain:
email
reason
scope
created_at
Possible reasons might include:
hard_bounce
complaint
unsubscribe
Global Versus Scoped Suppression
Not every suppression necessarily means the same thing.
Suppose someone unsubscribes from the newsletter.
That probably means:
Do not send newsletters.
It doesn’t necessarily mean:
Never send an important account-related transactional message.
A hard bounce is different.
If the mailbox doesn’t exist, continuing to send any type of email to it doesn’t make much sense.
That suggests two useful concepts:
GLOBAL SUPPRESSION
Blocks all email.
NEWSLETTER SUPPRESSION
Blocks newsletter email,
but may allow legitimate transactional email.
The application’s business rules should make that distinction explicit.
SES also provides account-level suppression capabilities, but I still find application-level suppression valuable because the application understands concepts that SES doesn’t necessarily understand, such as membership state, newsletter consent and application-specific communication rules.
Step 13: Handle Complaints Aggressively
A complaint is different from a temporary delivery failure.
If a recipient reports a message as spam, the safest application behavior is generally to stop sending further email to that address.
For example:
Complaint
|
v
Global suppression
The exact policy depends on the application, but complaints should never simply disappear into a log file.
They need to affect future sending behavior.
Step 14: Treat Soft and Hard Bounces Differently
Not every bounce means an address is permanently invalid.
A temporary problem might be caused by:
- Mailbox capacity
- Temporary receiving-server problems
- Rate limiting
- Other transient delivery conditions
A permanent or hard bounce is different.
The event-processing layer should distinguish between temporary delivery problems and permanent failures rather than suppressing every address after any delivery issue.
Step 15: Keep Unsubscribe Logic in the Application
Newsletter unsubscribe behavior belongs closely to the membership/subscriber system.
For example:
newsletter_subscribers
email
subscribed_at
unsubscribed_at
When someone unsubscribes, the application should update its subscriber state and ensure future newsletter sends respect that state.
A newsletter send therefore shouldn’t begin with:
Send email.
It should begin with something more like:
Is this person subscribed?
Are they unsubscribed?
Are they suppressed?
Is this email category permitted?
If yes -> send.
That is a much healthier model.
Step 16: Give the Application Least-Privilege AWS Credentials
The web application does not need to administer AWS.
It needs to send email.
Those are very different permissions.
Instead of giving the application administrator credentials, create a dedicated IAM identity with only the permissions required for sending.
Conceptually:
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
}
The exact policy should always be reviewed for the application’s requirements, but the principle is simple:
Give the application only the AWS permissions it actually needs.
The application doesn’t need permission to create SES identities, change DNS, modify SNS topics or administer the AWS account just because it sends email.
Step 17: Keep Credentials Outside the Code
AWS credentials should never appear in source code.
For a self-hosted application running outside AWS, the production environment might provide variables such as:
AWS_REGION
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
Those values belong in the deployment’s secret/environment configuration.
Not in Git.
Not in a JavaScript file.
Not in Dockerfile source.
Not pasted into application configuration committed to the repository.
The AWS SDK can obtain credentials from the runtime environment.
Step 18: Keep Mailpit for Development
This is one of my favorite parts of the architecture.
I was already using Mailpit during development.
There was no reason to replace it.
Development remains:
Application
|
v
SMTP
|
v
Mailpit
Production becomes:
Application
|
v
Amazon SES
This means I can test things such as:
- Registration messages
- Newsletter templates
- Account notifications
- Contact forms
- HTML rendering
- Unsubscribe links
without sending anything to the Internet.
The development application can generate 50 test emails and they simply appear inside Mailpit.
That’s exactly what I want.
Make the Provider Explicit
I prefer explicit configuration over clever detection.
For example:
EMAIL_PROVIDER=smtp
for development and:
EMAIL_PROVIDER=ses
for production.
The application can then implement a small provider abstraction:
sendEmail()
|
+---- smtp ---> Mailpit
|
+---- ses ----> Amazon SES
Templates and application logic don’t need to know the transport details.
They simply construct the message and hand it to the configured provider.
Never Silently Fall Back in Production
This is another important rule.
Suppose SES fails.
I don’t want this:
SES failed
|
v
Let's quietly try the old SMTP server
That can hide configuration problems and create unpredictable sending behavior.
Instead:
SES failed
|
v
Log failure
|
v
Return controlled application error
Production email should have one clearly defined transport.
If it fails, I want to know that it failed.
Step 19: Integrate SES Using the AWS SDK
For a Node.js application, the AWS SDK can communicate directly with SES.
Conceptually, the production email provider does something like:
Application
|
v
SES SDK
|
+-- From
+-- To
+-- Subject
+-- HTML
+-- Text
+-- Reply-To
+-- Configuration Set
|
v
Amazon SES
The important part is that the application selects the configuration set based on the purpose of the message.
For example:
sendNewsletter()
|
+--> dcc-newsletter
sendOutreach()
|
+--> dcc-outreach
The configuration-set names here are examples. Use names appropriate for your application.
Transactional Email Deserves Special Attention
One mistake I wanted to avoid was classifying every message that wasn’t a newsletter as “outreach.”
Transactional messages are fundamentally different.
Examples include:
Welcome to your account
Reset your password
Your submission was approved
We received your request
Your membership changed
Those messages are triggered by a user’s interaction or an application event.
They should not automatically inherit the behavior or classification of marketing/outreach email.
Depending on the size of the application, transactional messages might use:
- Their own configuration set
- A general SES configuration
- Separate sending rules
The important thing is to make the decision deliberately.
Step 20: Test the Infrastructure Separately From the Application
This was extremely useful.
Before changing the application to use SES, test SES directly.
That separates two completely different questions:
Does AWS SES work?
from:
Did I correctly integrate SES into my application?
For example, a temporary AWS CLI container can be used on a Docker server without installing the AWS CLI inside the application container.
A controlled SES test can verify:
Server
|
v
AWS credentials
|
v
IAM permissions
|
v
SES
|
v
Configuration Set
|
v
Recipient
If the message arrives, you’ve eliminated a large number of possible infrastructure problems before touching the application’s mail provider.
Step 21: Test the Entire Feedback Loop
Receiving the email is not the end of the test.
The complete test should be:
Application / Test Client
|
v
SES
|
+-------> Recipient
|
v
Configuration Set
|
v
SNS
|
v
Webhook
|
v
Event Table
Now you know both sides work:
Outbound
Application -> SES -> Recipient
and:
Feedback
SES -> SNS -> Application
That is a production email system rather than simply an email sender.
The Final Architecture
Putting everything together:
APPLICATION
|
+----------+----------+
| |
Development Production
| |
SMTP SES API
| |
Mailpit +-------+-------+
| |
Newsletter Outreach
| |
Config Set A Config Set B
\ /
\ /
v v
SES
|
+---------+---------+
| |
Recipient Events
|
v
SNS
|
v
Webhook
|
+--------------+--------------+
| |
Event History Suppressions
|
v
Future Send Checks
What I Like About This Approach
The most important result isn’t that the application now “uses Amazon SES.”
It’s that email became a proper application subsystem.
The application understands:
- What kind of message it is sending
- Whether the recipient should receive it
- Whether the recipient previously bounced
- Whether the recipient complained
- Whether the subscriber unsubscribed
- Which delivery events belong to which type of email
- Whether it is running in development or production
Meanwhile, SES handles the infrastructure required to actually deliver the message.
That feels like the right separation of responsibilities.
A Practical Implementation Checklist
Before calling an application email system production-ready, I would verify the following:
- Sending domain verified
- DKIM configured
- SPF/Mail FROM configured
- DMARC configured
- SES production access approved
- Dedicated least-privilege application credentials created
- Credentials stored outside source code
- Email categories identified
- Configuration sets created where appropriate
- Bounce events captured
- Complaint events captured
- Delivery events captured if needed
- SNS topics configured
- HTTPS webhook implemented
- SNS signatures validated
- Expected SNS topics validated
- Event processing is idempotent
- Hard bounces suppress future sending
- Complaints suppress future sending
- Newsletter unsubscribes are respected
- Suppression checked before sending
- Transactional and marketing email treated differently
- Development continues using Mailpit
- Production uses SES
- Production does not silently fall back to development SMTP
- Automated tests mock SES rather than sending real messages
- One controlled production message tested end-to-end
One Final Thought
Email looks deceptively simple because sending one message is simple.
Operating email responsibly is not.
The interesting part isn’t this:
sendEmail("someone@example.com")
It’s everything surrounding that call.
- Who is the recipient?
- Why are we contacting them?
- Did they subscribe?
- Did they unsubscribe?
- Has the address bounced?
- Did they complain?
- What happens when delivery fails?
- How do we know what happened?
- How do we test all of this without accidentally emailing real people?
Once those questions become part of the architecture, Amazon SES becomes much more than an SMTP replacement. It becomes the delivery layer of an email system that the application itself understands and controls.
And for applications with memberships, newsletters, transactional messages, or outreach, that distinction matters.


