| name | aws-ses-best-practices |
| description | Email deliverability, warming, compliance, and operational guidelines for AWS Simple Email Service. |
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
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:
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:
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const typoSuggestions: Record<string, string> = {
'gmial.com': 'gmail.com',
'gmal.com': 'gmail.com',
'hotmal.com': 'hotmail.com',
'yaho.com': 'yahoo.com',
};
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:
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
const sendAtLocalTime = (email: string, preferredHour: number) => {
const userTimezone = getUserTimezone(email);
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
const BATCH_SIZE = 1000;
const DELAY_BETWEEN_BATCHES_MS = 60000;
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:
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
aws ses get-send-quota
aws sesv2 get-account
aws sesv2 get-account --query 'SuppressionAttributes'
Identity Verification
aws ses list-identities --identity-type Domain
aws ses list-identities --identity-type EmailAddress
aws ses get-identity-verification-attributes \
--identities yourapp.com
aws sesv2 get-email-identity --email-identity yourapp.com
DKIM Status
aws ses get-identity-dkim-attributes --identities yourapp.com
aws sesv2 get-email-identity --email-identity yourapp.com \
--query 'DkimAttributes'
MAIL FROM Configuration
aws ses get-identity-mail-from-domain-attributes \
--identities yourapp.com
Configuration Sets
aws sesv2 list-configuration-sets
aws sesv2 get-configuration-set \
--configuration-set-name my-config-set
aws sesv2 get-configuration-set-event-destinations \
--configuration-set-name my-config-set
Sending Statistics
aws ses get-send-statistics
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
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
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
aws sesv2 list-suppressed-destinations
aws sesv2 list-suppressed-destinations --reasons BOUNCE
aws sesv2 list-suppressed-destinations --reasons COMPLAINT
aws sesv2 get-suppressed-destination \
--email-address user@example.com
aws sesv2 delete-suppressed-destination \
--email-address user@example.com
aws sesv2 put-suppressed-destination \
--email-address user@example.com \
--reason COMPLAINT
Test Sending
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."
aws ses verify-email-identity --email-address test@example.com
DNS Verification (External)
dig +short TXT selector1._domainkey.yourapp.com
dig +short TXT yourapp.com | grep spf
dig +short TXT _dmarc.yourapp.com
dig +short MX mail.yourapp.com