AWS SES Best Practices
Email deliverability, warming, compliance, and operational guidelines for AWS Simple Email Service.
Email Authentication
SPF (Sender Policy Framework)
Add to your domain's DNS:
v=spf1 include:amazonses.com ~all
For custom MAIL FROM domain:
# TXT record for mail.yourapp.com v=spf1 include:amazonses.com ~all
DKIM (DomainKeys Identified Mail)
SES provides three CNAME records for DKIM. Add all three:
# Example (actual values from SES console) selector1._domainkey.yourapp.com CNAME selector1.dkim.amazonses.com selector2._domainkey.yourapp.com CNAME selector2.dkim.amazonses.com selector3._domainkey.yourapp.com CNAME selector3.dkim.amazonses.com
DMARC (Domain-based Message Authentication)
Add TXT record to your domain:
# Start with monitoring mode _dmarc.yourapp.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourapp.com" # After monitoring, move to quarantine _dmarc.yourapp.com TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourapp.com" # Production: reject unauthenticated emails _dmarc.yourapp.com TXT "v=DMARC1; p=reject; rua=mailto:dmarc@yourapp.com"
Custom MAIL FROM Domain
Improves deliverability by using your domain instead of amazonses.com:
# MX record for mail.yourapp.com mail.yourapp.com MX 10 feedback-smtp.us-east-1.amazonses.com # SPF record for mail.yourapp.com mail.yourapp.com TXT "v=spf1 include:amazonses.com ~all"
IP Warming
New SES accounts have limited sending reputation. Warm up gradually.
Warming Schedule
| Day | Daily Volume | Notes |
|---|---|---|
| 1-2 | 200 | Start small, monitor bounces |
| 3-4 | 500 | Check complaint rate |
| 5-7 | 1,000 | Monitor reputation dashboard |
| 8-14 | 5,000 | Steady increase |
| 15-21 | 10,000 | |
| 22-30 | 25,000 | |
| 30+ | 50,000+ | Scale as needed |
Warming Best Practices
- •Start with engaged users — Send to users who recently opened/clicked
- •Prioritize transactional — Welcome emails, password resets have high engagement
- •Avoid cold lists — Don't send to addresses that haven't engaged in 6+ months
- •Monitor daily — Check bounce/complaint rates in SES dashboard
- •Slow down if issues — If bounces > 5% or complaints > 0.1%, reduce volume
Bounce Handling
Types of Bounces
Hard Bounces (Permanent):
- •Invalid/non-existent email address
- •Domain doesn't exist
- •Action: Remove immediately, never send again
Soft Bounces (Temporary):
- •Mailbox full
- •Server temporarily unavailable
- •Action: Retry with exponential backoff, remove after 3-5 attempts
Transient Bounces:
- •Auto-responders
- •Challenge-response systems
- •Action: Generally ignore, don't count against reputation
Bounce Rate Thresholds
| Rate | Status | Action |
|---|---|---|
| < 2% | Healthy | Continue normally |
| 2-5% | Warning | Investigate, clean list |
| 5-10% | Critical | Stop sends, clean list aggressively |
| > 10% | Danger | SES may suspend account |
Handling Bounces with Wraps
// Wraps CLI sets up automatic bounce handling via SNS → Lambda → DynamoDB
// Query bounced addresses:
import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb';
const client = new DynamoDBClient({});
const bounces = await client.send(new QueryCommand({
TableName: 'wraps-email-events',
KeyConditionExpression: 'pk = :pk',
ExpressionAttributeValues: {
':pk': { S: 'BOUNCE#hard' },
},
}));
Complaint Handling
Complaint Rate Thresholds
| Rate | Status | Action |
|---|---|---|
| < 0.1% | Healthy | Continue normally |
| 0.1-0.3% | Warning | Review content, add unsubscribe |
| 0.3-0.5% | Critical | Pause marketing emails |
| > 0.5% | Danger | SES may suspend account |
Reducing Complaints
- •Clear unsubscribe — One-click unsubscribe in every email
- •Set expectations — Tell users what/when you'll email during signup
- •Honor preferences — Let users choose email types/frequency
- •Clean lists — Remove unengaged users proactively
- •Relevant content — Only send what users signed up for
List-Unsubscribe Header
Add to all marketing emails:
// With raw email or custom headers
const headers = {
'List-Unsubscribe': '<mailto:unsubscribe@yourapp.com>, <https://yourapp.com/unsubscribe>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
};
List Hygiene
Email Validation
Validate emails at signup:
// Basic format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Check for common typos
const typoSuggestions: Record<string, string> = {
'gmial.com': 'gmail.com',
'gmal.com': 'gmail.com',
'hotmal.com': 'hotmail.com',
'yaho.com': 'yahoo.com',
};
// Use email verification service for important signups
// (e.g., ZeroBounce, NeverBounce, Hunter)
Engagement-Based Cleanup
Remove or suppress addresses based on engagement:
| Last Engagement | Action |
|---|---|
| < 30 days | Active, send normally |
| 30-90 days | Reduce frequency |
| 90-180 days | Re-engagement campaign |
| 180+ days | Suppress from regular sends |
| 365+ days | Remove from list |
Suppression Lists
Maintain lists of addresses to never email:
// Hard bounces - permanent suppression // Complaints - permanent suppression // Unsubscribes - honor indefinitely // Role addresses - suppress (admin@, info@, support@)
Content Best Practices
Subject Lines
- •Keep it short — Under 50 characters
- •Be specific — Tell them what's inside
- •Avoid spam triggers — No ALL CAPS, excessive punctuation, "FREE!!!"
- •Personalize — Include name or relevant detail
Email Body
- •Text version — Always include plain text alternative
- •Image-to-text ratio — Keep images < 40% of content
- •Hosted images — Use absolute URLs, not embedded images
- •Alt text — Every image needs alt text
- •Mobile-friendly — Single column, 600px max width
- •Clear CTA — One primary call-to-action
Avoid Spam Triggers
Words to avoid:
- •FREE, WINNER, CONGRATULATIONS
- •Act now, Limited time, Urgent
- •$$, Make money, Cash bonus
- •Click here, Buy now
Formatting to avoid:
- •ALL CAPS
- •Excessive punctuation!!!
- •Red text
- •Large fonts
- •Invisible text (white on white)
Sending Patterns
Consistent Schedule
- •Send at consistent times (users learn to expect your emails)
- •Avoid sudden volume spikes (looks like spam)
- •Spread large sends over time (don't blast all at once)
Time Zone Awareness
// Send at optimal local time
const sendAtLocalTime = (email: string, preferredHour: number) => {
const userTimezone = getUserTimezone(email); // from user preferences
const now = new Date();
const targetTime = new Date(now.toLocaleString('en-US', { timeZone: userTimezone }));
targetTime.setHours(preferredHour, 0, 0, 0);
if (targetTime < now) {
targetTime.setDate(targetTime.getDate() + 1);
}
return targetTime;
};
Throttling for Large Lists
// Don't send 100k emails at once
const BATCH_SIZE = 1000;
const DELAY_BETWEEN_BATCHES_MS = 60000; // 1 minute
async function sendBulk(recipients: string[], template: string) {
for (let i = 0; i < recipients.length; i += BATCH_SIZE) {
const batch = recipients.slice(i, i + BATCH_SIZE);
await email.sendBulkTemplate({
from: 'hello@yourapp.com',
template,
destinations: batch.map(to => ({ to, templateData: {} })),
});
if (i + BATCH_SIZE < recipients.length) {
await sleep(DELAY_BETWEEN_BATCHES_MS);
}
}
}
Monitoring & Alerts
Key Metrics to Track
| Metric | Target | Alert Threshold |
|---|---|---|
| Bounce Rate | < 2% | > 5% |
| Complaint Rate | < 0.1% | > 0.3% |
| Delivery Rate | > 95% | < 90% |
| Open Rate | > 20% | < 10% |
| Click Rate | > 2% | < 1% |
CloudWatch Alarms
Wraps CLI sets up basic alarms. Add custom ones:
// High bounce rate alarm
const alarm = new cloudwatch.Alarm(this, 'HighBounceRate', {
metric: new cloudwatch.Metric({
namespace: 'AWS/SES',
metricName: 'Bounce',
statistic: 'Sum',
period: Duration.hours(1),
}),
threshold: 50,
evaluationPeriods: 1,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
});
SES Reputation Dashboard
Check regularly in AWS Console → SES → Reputation Metrics:
- •Account status (Healthy, Under review, Paused)
- •Bounce rate trend
- •Complaint rate trend
Compliance
CAN-SPAM (US)
- •Accurate headers — From/reply-to must be accurate
- •No deceptive subjects — Subject must reflect content
- •Physical address — Include valid postal address
- •Unsubscribe — Clear, working unsubscribe mechanism
- •Honor opt-outs — Process within 10 business days
GDPR (EU)
- •Consent — Clear, explicit opt-in for marketing
- •Right to access — Provide data on request
- •Right to erasure — Delete data on request
- •Data portability — Export data in common format
- •Lawful basis — Document why you're processing data
CASL (Canada)
- •Express consent — Written/verbal permission required
- •Implied consent — Limited (existing relationship, published email)
- •Unsubscribe — Honor within 10 days
- •Identification — Sender identity and contact info
Footer Template
<footer style="font-size: 12px; color: #666;">
<p>
You're receiving this email because you signed up at yourapp.com.
</p>
<p>
<a href="{{unsubscribe_url}}">Unsubscribe</a> |
<a href="{{preferences_url}}">Email Preferences</a>
</p>
<p>
Your Company, Inc.<br>
123 Main Street<br>
City, State 12345
</p>
</footer>
SES Limits & Quotas
Default Limits (Sandbox)
- •200 emails/24 hours
- •1 email/second
- •Can only send to verified addresses
Production Limits
- •Varies based on account history
- •Start at 50,000/day, 14/second
- •Request increases as needed
Requesting Limit Increase
- •AWS Console → SES → Account Dashboard
- •Click "Request Production Access" or "Request Limit Increase"
- •Provide:
- •Use case description
- •Expected volume
- •Bounce/complaint handling process
- •List collection method
Troubleshooting
"Email not delivered"
- •Check SES console for bounces/complaints
- •Verify recipient didn't unsubscribe
- •Check spam folder
- •Test with mail-tester.com
"High bounce rate"
- •Validate email addresses at signup
- •Remove old addresses (180+ days inactive)
- •Use double opt-in
- •Check for typos in bulk imports
"High complaint rate"
- •Add clear unsubscribe link
- •Review email content
- •Reduce frequency
- •Segment engaged vs unengaged users
"Emails going to spam"
- •Verify SPF, DKIM, DMARC are set up
- •Check content for spam triggers
- •Warm up sending volume gradually
- •Build engagement (opens/clicks improve reputation)
AWS CLI Diagnostics
Account Status
# Get sending quota and daily usage aws ses get-send-quota # Check if in sandbox (SESv2) aws sesv2 get-account # Account-level suppression settings aws sesv2 get-account --query 'SuppressionAttributes'
Identity Verification
# List verified domains aws ses list-identities --identity-type Domain # List verified email addresses aws ses list-identities --identity-type EmailAddress # Check verification status for a domain aws ses get-identity-verification-attributes \ --identities yourapp.com # Full identity details (SESv2) aws sesv2 get-email-identity --email-identity yourapp.com
DKIM Status
# Check DKIM configuration aws ses get-identity-dkim-attributes --identities yourapp.com # SESv2 DKIM details aws sesv2 get-email-identity --email-identity yourapp.com \ --query 'DkimAttributes'
MAIL FROM Configuration
# Check custom MAIL FROM domain aws ses get-identity-mail-from-domain-attributes \ --identities yourapp.com
Configuration Sets
# List all configuration sets aws sesv2 list-configuration-sets # Get details for a configuration set aws sesv2 get-configuration-set \ --configuration-set-name my-config-set # List event destinations aws sesv2 get-configuration-set-event-destinations \ --configuration-set-name my-config-set
Sending Statistics
# Basic send stats (last 2 weeks, 15-min intervals) aws ses get-send-statistics # CloudWatch metrics for sends aws cloudwatch get-metric-statistics \ --namespace AWS/SES \ --metric-name Send \ --start-time $(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) \ --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \ --period 3600 \ --statistics Sum
Reputation Metrics
# Bounce rate (last 7 days) aws cloudwatch get-metric-statistics \ --namespace AWS/SES \ --metric-name Reputation.BounceRate \ --start-time $(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) \ --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \ --period 86400 \ --statistics Average # Complaint rate (last 7 days) aws cloudwatch get-metric-statistics \ --namespace AWS/SES \ --metric-name Reputation.ComplaintRate \ --start-time $(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) \ --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \ --period 86400 \ --statistics Average
Suppression List
# List suppressed addresses aws sesv2 list-suppressed-destinations # Filter by reason aws sesv2 list-suppressed-destinations --reasons BOUNCE aws sesv2 list-suppressed-destinations --reasons COMPLAINT # Check specific address aws sesv2 get-suppressed-destination \ --email-address user@example.com # Remove from suppression list aws sesv2 delete-suppressed-destination \ --email-address user@example.com # Add to suppression list aws sesv2 put-suppressed-destination \ --email-address user@example.com \ --reason COMPLAINT
Test Sending
# Send test email aws ses send-email \ --from verified@yourapp.com \ --to recipient@example.com \ --subject "Test Email" \ --text "This is a test email sent via AWS CLI." # Verify email address (sandbox mode) aws ses verify-email-identity --email-address test@example.com
DNS Verification (External)
# Check DKIM records dig +short TXT selector1._domainkey.yourapp.com # Check SPF record dig +short TXT yourapp.com | grep spf # Check DMARC record dig +short TXT _dmarc.yourapp.com # Check MX record (for custom MAIL FROM) dig +short MX mail.yourapp.com