Docs / SES + SES v2

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)

Terminal
$ 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

Terminal
$ 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

Terminal
$ 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:

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

  1. Request is normalized into a v1-shaped Simple, Raw, or Template send.
  2. For Template variants, the template is fetched from backend.templates and rendered via moto's Handlebars subset ({{var}}, {{#each}}, {{#if}}). Missing variables substitute as empty string.
  3. moto's SES backend builds the RFC 5322 message.
  4. save_for_retrospection() (ses/provider.py:98) writes a per-message JSON file under <data-dir>/ses/<message-id>.json and registers it in the in-process EMAILS dict.
  5. 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

Features supported

FeatureNotes
Send variantsv2 SendEmail with Content.Simple, Content.Raw, Content.Template. v1 SendEmail, SendRawEmail, SendTemplatedEmail. All land in the same mailbox.
Templatesv2: 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.
IdentitiesCreateEmailIdentity (email or domain), GetEmailIdentity, PutEmailIdentityMailFromAttributes, DeleteEmailIdentity. Verification status is metadata-only.
Configuration setsCreateConfigurationSet / 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 listPutSuppressedDestination, GetSuppressedDestination, ListSuppressedDestinations, DeleteSuppressedDestination. Metadata-only; sends to suppressed addresses are not blocked.
Retrospection mailboxGET /_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:

If you need this wiring during tests, route the send through the v1 endpoint (awsemu ses send-email) rather than v2.

Terminal
# 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

VariableDefaultPurpose
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

ServiceHow it touches SES
SNSv1 configuration-set event destinations publish to SNS topics on every matching send.
Firehosev1 configuration-set event destinations put_record to delivery streams on every matching send.
EventBridgev1 configuration-set event destinations put_events with source aws.ses on every matching send.
CloudTrailEvery send + configuration call lands in the CloudTrail event store and the dashboard.
CloudFormationAWS::SES::* and AWS::SESV2::* resource providers route through these providers.

Known limitations