← Blog/Transactional Email on AWS: From SMTP Failures to Reliable SES Deliver…
Messaging

Transactional Email on AWS: From SMTP Failures to Reliable SES Delivery

May 24, 2026·4 min read
Med Amine Mahmoud
Med Amine Mahmoud
Founder and Editor, Smash The Exam
Reviewed: 2026-05-26 · LinkedIn

Transactional Email on AWS: From SMTP Failures to Reliable SES Delivery turns the concept into a usable execution plan with concrete checks and production-minded guardrails.

AWSDevOpsMessaging

Transactional Email on AWS: From SMTP Failures to Reliable SES Delivery

Consolidated from real email system sessions covering SMTP migration, SES sandbox escape, Lambda formatters, and welcome email pipelines.

Messaging Focus 1: Risk controls worth enforcing early for predictable operations (Transactional Email Aws)

This article documents building a reliable transactional email system on AWS — from initial SMTP failures through SES integration, Lambda email formatting, and production-grade delivery with monitoring.


Editorial review note for Transactional Email Aws

This section was reviewed by a human editor to keep the recommendations actionable and technically grounded. Reviewed by: Med Amine Mahmoud. Last editorial review: 2026-05-26T16:10:01Z.

Messaging Focus 3: How to keep cost and reliability aligned for cleaner ownership (Transactional Email Aws)

  1. SES > self-hosted for transactional email — 99%+ cheaper, better deliverability
  2. IAM roles eliminate credential management — Lambda in same account needs no keys
  3. SNS → Lambda pattern decouples email from application logic
  4. SPF + DKIM + DMARC are non-negotiable for inbox delivery
  5. Sandbox escape requires a support ticket with use-case justification
  6. Format emails before delivery — raw JSON alarms are unreadable
  7. Bounce handling is mandatory — SES will suspend sending without it

Messaging Focus 4: What to document for your team for measurable outcomes (Transactional Email Aws)

Initial Architecture (Broken)

Application → Lambda (SMTP) → Self-hosted Postfix on EC2 → Recipient

Failure Modes

  1. Lambda cold start: SMTP connection timeout (25s Lambda limit)
  2. EC2 dependency: Single point of failure, required maintenance
  3. Missing credentials: Lambda crashed with KeyError: 'SMTP_USERNAME'
  4. Port 25 blocking: AWS blocks outbound port 25 by default on EC2
  5. Deliverability: No SPF/DKIM on self-hosted = spam folder

Cost of Self-Hosted Mail

ResourceMonthly Cost
EC2 t3.micro (Postfix)$8.35
NLB for mail routing$16.20
Elastic IP$3.65
Total$28.20/month

Messaging Focus 5: Where this architecture earns its value for fewer incident surprises (Transactional Email Aws)

Architecture (Fixed)

Application Event → SNS Topic → Lambda Formatter → SES API → Recipient

SES Setup Steps

# 1. Verify sender identity (domain-level)
aws ses verify-domain-identity --domain example.com --region us-east-1

# 2. Add DKIM records (3 CNAME records in Route 53)
aws ses get-identity-dkim-attributes --identities example.com

# 3. Add SPF record to Route 53
# Type: TXT, Name: example.com
# Value: "v=spf1 include:amazonses.com ~all"

# 4. Add DMARC record
# Type: TXT, Name: _dmarc.example.com
# Value: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"

SES Sandbox Escape

SES starts in sandbox mode (can only send to verified addresses). To send to any address:

# Check current sending status
aws ses get-account --region us-east-1

# Request production access (via AWS Console or support ticket)
# Requires: use case description, expected volume, bounce handling plan

Lambda: SMTP → SES Migration

# BEFORE (broken)
import smtplib
smtp = smtplib.SMTP(os.environ['SMTP_HOST'], int(os.environ['SMTP_PORT']))
smtp.login(os.environ['SMTP_USERNAME'], os.environ['SMTP_PASSWORD'])
smtp.sendmail(sender, recipient, msg.as_string())

# AFTER (working)
import boto3
ses = boto3.client('ses', region_name='us-east-1')

ses.send_raw_email(
Source=sender,
Destinations=[recipient],
RawMessage={'Data': msg.as_string()}
)

Cost: $0.10 per 1000 emails (vs $28.20/month for self-hosted)


Messaging Focus 6: Operational notes from real-world usage for this workload (Transactional Email Aws)

Architecture

User Signs Up → Backend API → SNS Topic (user-events) → Lambda → SES → Welcome Email

Backend: Publish Event

# services/user_service.py
import boto3, json

class UserService:
def __init__(self, sns_topic_arn: str):
self.sns = boto3.client('sns', region_name='us-east-1')
self.topic_arn = sns_topic_arn

def register(self, email: str, display_name: str, ...) -> User:
# Create user in DB
user = self._create_user(email, display_name, ...)

# Publish welcome event
self.sns.publish(
TopicArn=self.topic_arn,
Subject="New User Registration",
Message=json.dumps({
"event": "user_registered",
"email": email,
"display_name": display_name,
"timestamp": datetime.utcnow().isoformat()
})
)
return user

Lambda: Format and Send

import boto3, json, os
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

ses = boto3.client('ses', region_name=os.environ.get('SES_REGION', 'us-east-1'))
SENDER = os.environ['SENDER_EMAIL'] # e.g., "noreply@example.com"

def handler(event, context):
for record in event['Records']:
message = json.loads(record['Sns']['Message'])

if message.get('event') == 'user_registered':
send_welcome(message['email'], message['display_name'])

def send_welcome(email: str, name: str):
subject = "Welcome to SmashTheExam!"
html_body = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #1a73e8;">Welcome, {name}! 🎓</h1>
<p>You're now part of a community of certification learners.</p>
<h2>Get Started:</h2>
<ul>
<li><a href="https://www.example.com/aws/clf-c02">AWS Cloud Practitioner</a></li>
<li><a href="https://www.example.com/azure/az-900">Azure Fundamentals</a></li>
<li><a href="https://www.example.com/cisco/ccna">Cisco CCNA</a></li>
</ul>
<p>Good luck with your certification journey!</p>
</div>
"""

msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"SmashTheExam <{SENDER}>"
msg['To'] = email
msg.attach(MIMEText(html_body, 'html'))

ses.send_raw_email(
Source=SENDER,
Destinations=[email],
RawMessage={'Data': msg.as_string()}
)

Common Failures and Fixes

ErrorCauseFix
MessageRejected: Email address is not verifiedSES sandbox modeRequest production access
KeyError: 'SMTP_USERNAME'Old env vars removedSwitch to SES API (no credentials needed with IAM)
Cold start timeoutSMTP connection overheadSES API is instant (no connection setup)
Email in spamNo SPF/DKIMAdd DNS records for domain verification

Messaging Focus 7: How to avoid expensive rework for your runbook (Transactional Email Aws)

Problem: Unreadable CloudWatch Alarm Emails

Default SNS alarm notifications are raw JSON blobs:

{"AlarmName":"myapp-prod-traffic-spike","AlarmDescription":"...","AWSAccountId":"123456789012","NewStateValue":"ALARM",...}

Solution: Centralized Formatter Lambda

def format_alarm(alarm_data: dict) -> str:
"""Convert raw CloudWatch alarm JSON into human-readable email."""

state_emoji = {
"ALARM": "🚨",
"OK": "✅",
"INSUFFICIENT_DATA": "⚠️"
}

emoji = state_emoji.get(alarm_data['NewStateValue'], '📢')

return f"""
{emoji} CloudWatch Alarm: {alarm_data['AlarmName']}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

State Change: {alarm_data['OldStateValue']} → {alarm_data['NewStateValue']}
Time: {alarm_data['StateChangeTime']}
Region: {alarm_data['Region']}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Reason: {alarm_data['NewStateReason']}

Description:
{alarm_data.get('AlarmDescription', 'No description')}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Metric: {alarm_data['Trigger']['MetricName']}
Namespace: {alarm_data['Trigger']['Namespace']}
Statistic: {alarm_data['Trigger']['Statistic']}
Threshold: {alarm_data['Trigger']['Threshold']}
Period: {alarm_data['Trigger']['Period']}s

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""

SNS Topic Routing

prod-critical-alarms → Lambda Formatter → SES (immediate)
dev-idle-notify → Lambda Formatter → SES (informational)
billing-alerts → Lambda Formatter → SES (weekly digest)
user-events → Lambda Welcome → SES (transactional)

Messaging Focus 8: Where teams usually get this wrong for production readiness (Transactional Email Aws)

DNS Records Required

# SPF — authorize SES to send on your behalf
example.com TXT "v=spf1 include:amazonses.com ~all"

# DKIM — cryptographic signature (3 CNAME records from SES)
selector1._domainkey.example.com CNAME selector1.dkim.amazonses.com
selector2._domainkey.example.com CNAME selector2.dkim.amazonses.com
selector3._domainkey.example.com CNAME selector3.dkim.amazonses.com

# DMARC — policy for authentication failures
_dmarc.example.com TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com"

Bounce and Complaint Handling

# Configure SES to send bounces/complaints to SNS
aws ses set-identity-notification-topic `
--identity example.com `
--notification-type Bounce `
--sns-topic "arn:aws:sns:us-east-1:ACCOUNT:ses-bounces"

aws ses set-identity-notification-topic `
--identity example.com `
--notification-type Complaint `
--sns-topic "arn:aws:sns:us-east-1:ACCOUNT:ses-complaints"

Messaging Focus 9: The practical decision path for sustained reliability (Transactional Email Aws)

Resources Deleted (Total Savings: $28.20/month)

ResourceWas Used ForMonthly Cost
EC2 t3.microPostfix/Roundcube mail server$8.35
NLB (2x)SMTP + IMAP routing$32.40
Elastic IPStatic mail server IP$3.65
Security groupsMail ports (25, 143, 993)

Cleanup Commands

# Terminate mail server EC2
aws ec2 terminate-instances --instance-ids i-EXAMPLE

# Delete NLBs
aws elbv2 delete-load-balancer --load-balancer-arn $SMTP_NLB_ARN
aws elbv2 delete-load-balancer --load-balancer-arn $IMAP_NLB_ARN

# Release Elastic IP
aws ec2 release-address --allocation-id eipalloc-EXAMPLE

# Clean up security groups, target groups, etc.

Messaging Focus 10: How to execute without guesswork for secure delivery (Transactional Email Aws)

Before (Self-Hosted)

App → SNS → Lambda → SMTP → EC2 Postfix → Recipient
↕
NLB (port 25, 143)
↕
Elastic IP (static)

Cost: $28.20/month, complex, fragile, poor deliverability

After (SES)

App → SNS → Lambda → SES API → Recipient

Cost: ~$0.10/month (< 1000 emails), simple, reliable, excellent deliverability