
aws-ses-best-practices
Email deliverability, warming, compliance, and operational guidelines for AWS Simple Email Service.
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
// 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
You Might Also Like
Related Skills

create-pr
Creates GitHub pull requests with properly formatted titles that pass the check-pr-title CI validation. Use when creating PRs, submitting changes for review, or when the user says /pr or asks to create a pull request.
n8n-io
electron-chromium-upgrade
Guide for performing Chromium version upgrades in the Electron project. Use when working on the roller/chromium/main branch to fix patch conflicts during `e sync --3`. Covers the patch application workflow, conflict resolution, analyzing upstream Chromium changes, and proper commit formatting for patch fixes.
electron
pr-creator
Use this skill when asked to create a pull request (PR). It ensures all PRs follow the repository's established templates and standards.
google-gemini
clawdhub
Use the ClawdHub CLI to search, install, update, and publish agent skills from clawdhub.com. Use when you need to fetch new skills on the fly, sync installed skills to latest or a specific version, or publish new/updated skill folders with the npm-installed clawdhub CLI.
moltbot
tmux
Remote-control tmux sessions for interactive CLIs by sending keystrokes and scraping pane output.
moltbot
create-pull-request
Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, and PR creation using the gh CLI tool.
cline