Transactional Email on AWS: From SMTP Failures to Reliable SES Delivery
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.
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)
- SES > self-hosted for transactional email — 99%+ cheaper, better deliverability
- IAM roles eliminate credential management — Lambda in same account needs no keys
- SNS → Lambda pattern decouples email from application logic
- SPF + DKIM + DMARC are non-negotiable for inbox delivery
- Sandbox escape requires a support ticket with use-case justification
- Format emails before delivery — raw JSON alarms are unreadable
- 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
- Lambda cold start: SMTP connection timeout (25s Lambda limit)
- EC2 dependency: Single point of failure, required maintenance
- Missing credentials: Lambda crashed with
KeyError: 'SMTP_USERNAME' - Port 25 blocking: AWS blocks outbound port 25 by default on EC2
- Deliverability: No SPF/DKIM on self-hosted = spam folder
Cost of Self-Hosted Mail
| Resource | Monthly 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
| Error | Cause | Fix |
|---|---|---|
MessageRejected: Email address is not verified | SES sandbox mode | Request production access |
KeyError: 'SMTP_USERNAME' | Old env vars removed | Switch to SES API (no credentials needed with IAM) |
| Cold start timeout | SMTP connection overhead | SES API is instant (no connection setup) |
| Email in spam | No SPF/DKIM | Add 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)
| Resource | Was Used For | Monthly Cost |
|---|---|---|
| EC2 t3.micro | Postfix/Roundcube mail server | $8.35 |
| NLB (2x) | SMTP + IMAP routing | $32.40 |
| Elastic IP | Static mail server IP | $3.65 |
| Security groups | Mail 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
