SES + SES v2
LocalEmu implements both SES generations in full: 71 ops on the legacy ses service and 110 ops on the modern sesv2 service. Outbound mail does not go to a real SMTP relay; instead, every send (Simple, Raw, Template) is persisted as a JSON object in an on-disk mailbox and exposed at GET /_aws/ses for test retrospection. Template variables (Handlebars subset) are substituted before the message is stored. Sends through the ses (v1) endpoint additionally dispatch real event-destination notifications to SNS, Firehose, and EventBridge so configuration-set workflows can be tested end-to-end.
Operation-level coverage: SES v2 matrix · SES v1 matrix.
Quick start (Simple)
$ awsemu sesv2 create-email-identity --email-identity hello@example.com
$ awsemu sesv2 send-email \
--from-email-address hello@example.com \
--destination 'ToAddresses=["alice@example.com"]' \
--content '{
"Simple":{
"Subject":{"Data":"Welcome"},
"Body":{
"Text":{"Data":"Hi Alice"},
"Html":{"Data":"<h1>Hi Alice</h1>"}
}
}
}' --query MessageId --output text
0100018f...
# The send did not go to a real SMTP server. It was persisted to the on-disk
# mailbox and is queryable at the retrospection endpoint.
$ curl -s http://localhost:4566/_aws/ses?id=0100018f... | python3 -m json.tool
{
"Source": "hello@example.com",
"Destination": {"ToAddresses": ["alice@example.com"]},
"Subject": "Welcome",
"Body": {"text_part":"Hi Alice","html_part":"<h1>Hi Alice</h1>"},
"Timestamp": "2026-05-20T19:00:00.000Z"
} Identity verification status is metadata-only: CreateEmailIdentity is enough to send. Sandbox-mode recipient whitelisting is not enforced.
Raw MIME variant
$ RAW=$(python3 - <<'PY'
import base64, email.mime.text
m = email.mime.text.MIMEText("Body via raw MIME", _charset="utf-8")
m["From"] = "hello@example.com"
m["To"] = "alice@example.com"
m["Subject"] = "Raw send"
print(base64.b64encode(m.as_bytes()).decode())
PY
)
$ awsemu sesv2 send-email \
--content '{"Raw":{"Data":"'$RAW'"}}' \
--query MessageId --output text
0100018f... Template variant with Handlebars substitution
$ awsemu sesv2 create-email-template \
--template-name welcome \
--template-content '{
"Subject": "Welcome, {{name}}",
"Text": "Hi {{name}}, your code is {{code}}.",
"Html": "<p>Hi {{name}}, your code is <b>{{code}}</b>.</p>"
}'
$ awsemu sesv2 send-email \
--from-email-address hello@example.com \
--destination 'ToAddresses=["alice@example.com"]' \
--content '{
"Template": {
"TemplateName": "welcome",
"TemplateData": "{\"name\":\"Alice\",\"code\":\"42\"}"
}
}' --query MessageId --output text
0100018f...
# Variable substitution happened before the message was stored.
$ curl -s http://localhost:4566/_aws/ses?id=0100018f... \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["Subject"]);print(d["Body"]["text_part"])'
Welcome, Alice
Hi Alice, your code is 42. Architecture
Two providers are registered side by side:
- •
Sesv2Provideratservices/sesv2/provider.py:65, registered assesv2:defaultviaplux.ini:119→services/providers.py:555. Seven ops are custom:SendEmail(Simple, Raw, Template) plus the five template CRUD ops plusTestRenderEmailTemplate. - •v1 provider at
services/ses/provider.py, registered asses:default. Eleven ops are custom: the three send variants, configuration-set event-destination CRUD,CloneReceiptRuleSet, identity-verification attribute reads, template CRUD, and a couple of v1-only knobs. - •
services/sesv2/adapters.pyholds wire-shape translators (SimpleSend,RawSend,TemplateSenddataclasses,normalize_send_request(),template_v2_to_v1()). v2 calls into v1'sses_backend.send_email/send_raw_emailso both endpoints land in the same mailbox.
The word "adapter" in this codebase refers only to shape translation. There is no pluggable delivery backend (no SMTP relay, no Postfix forwarder, no null sink); every send goes through the same retrospection path described below.
Send pipeline
- Request is normalized into a v1-shaped Simple, Raw, or Template send.
- For Template variants, the template is fetched from
backend.templatesand rendered via moto's Handlebars subset ({{var}},{{#each}},{{#if}}). Missing variables substitute as empty string. - moto's SES backend builds the RFC 5322 message.
save_for_retrospection()(ses/provider.py:98) writes a per-message JSON file under<data-dir>/ses/<message-id>.jsonand registers it in the in-processEMAILSdict.- For v1 sends with
ConfigurationSetName,notify_event_destinations()(ses/provider.py:440-549) fans out structured event payloads to every configured SNS, Firehose, or EventBridge destination.
Mailbox storage and retrospection
- •On-disk path:
<config.dirs.data or dirs.tmp>/ses/<message-id>.json - •Format: JSON with
Source,Destination,Subject,Body.text_part,Body.html_part, optionalRawData, optionalTemplate/TemplateData,Timestamp. - •HTTP route:
GET /_aws/sesreturns the full mailbox; filters available:?id=<message-id>for a single message,?email=<address>for everything sent to that recipient. - •Persistence: on-disk by default, so messages survive a LocalEmu restart even without
PERSISTENCE=1. - •No UI. Filesystem and REST only; assertions in tests typically
curlthe retrospection endpoint.
Features supported
| Feature | Notes |
|---|---|
| Send variants | v2 SendEmail with Content.Simple, Content.Raw, Content.Template. v1 SendEmail, SendRawEmail, SendTemplatedEmail. All land in the same mailbox. |
| Templates | v2: full CRUD via CreateEmailTemplate, UpdateEmailTemplate, GetEmailTemplate, DeleteEmailTemplate, ListEmailTemplates. TestRenderEmailTemplate renders without sending. v1: CreateTemplate, ListTemplates, DeleteTemplate. Shared storage. |
| Handlebars substitution | {{var}}, {{#each}}, {{#if}} (moto's subset). Missing variables substitute as empty string. |
| Identities | CreateEmailIdentity (email or domain), GetEmailIdentity, PutEmailIdentityMailFromAttributes, DeleteEmailIdentity. Verification status is metadata-only. |
| Configuration sets | CreateConfigurationSet / Delete / Get / List. PutConfigurationSetTrackingOptions, PutConfigurationSetReputationOptions, etc., all moto-backed. |
| Event destinations (v1 only) | CreateConfigurationSetEventDestination with SNS / Firehose / EventBridge targets dispatches structured events on every v1 send. v2 sends do not dispatch. |
| Suppression list | PutSuppressedDestination, GetSuppressedDestination, ListSuppressedDestinations, DeleteSuppressedDestination. Metadata-only; sends to suppressed addresses are not blocked. |
| Retrospection mailbox | GET /_aws/ses. File-backed; survives restart independently of PERSISTENCE. |
| Receipt rules (v1) | CreateReceiptRuleSet, CloneReceiptRuleSet, DescribeReceiptRule, etc., are accepted. Inbound mail processing is not emulated. |
Configuration set event destinations (v1)
The v1 send path dispatches a structured event for every matching MatchingEventTypes entry on every event destination of the named configuration set. Implementation at services/ses/provider.py:440-549. Targets currently wired:
- •
SNSDestination.TopicARN:sns.Publishwith the SES-standard JSON event shape. - •
KinesisFirehoseDestination.DeliveryStreamArn:firehose.PutRecordwith the event as the record body. - •
EventBridgeDestination.EventBusArn:events.PutEventswith sourceaws.sesand the event as the detail.
If you need this wiring during tests, route the send through the v1 endpoint (awsemu ses send-email) rather than v2.
# Configuration-set event destinations are dispatched by the v1 send path.
# Send via the v1 endpoint when you need SNS/Firehose/EventBridge fan-out.
$ awsemu sns create-topic --name ses-events --query TopicArn --output text
arn:aws:sns:us-east-1:000000000000:ses-events
$ awsemu ses create-configuration-set --configuration-set Name=cs-bounce
$ awsemu ses create-configuration-set-event-destination \
--configuration-set-name cs-bounce \
--event-destination '{
"Name":"to-sns",
"Enabled":true,
"MatchingEventTypes":["send","bounce","complaint"],
"SNSDestination":{"TopicARN":"arn:aws:sns:us-east-1:000000000000:ses-events"}
}'
$ awsemu sqs create-queue --queue-name ses-sink --query QueueUrl --output text >/dev/null
$ SQS_ARN=arn:aws:sqs:us-east-1:000000000000:ses-sink
$ awsemu sns subscribe --topic-arn arn:aws:sns:us-east-1:000000000000:ses-events \
--protocol sqs --notification-endpoint $SQS_ARN >/dev/null
# Send via the v1 endpoint with the configuration set attached.
$ awsemu ses send-email \
--source hello@example.com \
--destination 'ToAddresses=["alice@example.com"]' \
--message '{"Subject":{"Data":"Hi"},"Body":{"Text":{"Data":"Hi Alice"}}}' \
--configuration-set-name cs-bounce >/dev/null
# The configured SNS topic received a send event.
$ awsemu sqs receive-message --queue-url $(awsemu sqs get-queue-url --queue-name ses-sink \
--query QueueUrl --output text) --query 'Messages[0].Body' --output text \
| python3 -c 'import sys,json;d=json.loads(json.load(sys.stdin)["Message"]);print(d["eventType"])'
send Configuration
| Variable | Default | Purpose |
|---|---|---|
SNS_SES_SENDER_ADDRESS | "" | Optional Source address used by v1 when it publishes an SES event to SNS. Defined at config.py:1156. |
No SES-specific service-disable knob: use SERVICES=...,-ses,-sesv2. No SMTP relay or delivery-adapter selection variable, because there is no real adapter abstraction (see Limitations).
Integration points
| Service | How it touches SES |
|---|---|
| SNS | v1 configuration-set event destinations publish to SNS topics on every matching send. |
| Firehose | v1 configuration-set event destinations put_record to delivery streams on every matching send. |
| EventBridge | v1 configuration-set event destinations put_events with source aws.ses on every matching send. |
| CloudTrail | Every send + configuration call lands in the CloudTrail event store and the dashboard. |
| CloudFormation | AWS::SES::* and AWS::SESV2::* resource providers route through these providers. |
Known limitations
- •No real SMTP outbound. Messages are persisted to
<data-dir>/ses/and exposed at/_aws/sesonly. Nothing leaves the host. For tests that need a real inbox, point a local Postfix or MailHog at the JSON files yourself, or use SES v1 with an SNS-to-mailbox bridge. - •v2 SendEmail does not dispatch event destinations. The v2 endpoint accepts
ConfigurationSetNamebut does not fire SNS / Firehose / EventBridge events. Send via the v1 endpoint for that wiring. This is called out atservices/sesv2/provider.py:10-12. - •No DKIM signing. Outbound messages are not signed even when the identity has a DKIM key. BYODKIM is metadata-only.
- •No bounce/complaint simulator. SES's
bounce@simulator.amazonses.com/complaint@simulator.amazonses.commagic addresses do not produce synthetic notifications. - •Suppression list is metadata-only.
PutSuppressedDestinationrecords the address; subsequent sends to that address still succeed and still land in the mailbox. - •Sandbox mode is not enforced. Sends to unverified recipients succeed.
- •
SendBulkEmaildoes not iterate. Moto returns OK on the batch; the per-destination loop is a follow-up (sesv2/provider.py:14). - •VDM, dedicated IPs, dedicated-IP pools are metadata-only. No real warmup, no real routing.
- •List Management v2 (contacts, lists, subscriptions): metadata-only via moto.
- •No SMTP IAM credentials endpoint. The AWS feature for converting an IAM secret access key into an SMTP password is not implemented.
- •Inbound mail (receipt rules) is not emulated. Receipt rule sets and rules round-trip metadata, but no SMTP server accepts mail or fires the configured actions.