Install
openclaw skills install @airo7/aws-ses-skillopenclaw skills install @airo7/aws-ses-skillEverything you need to go from a fresh AWS account to a working, production-capable email-sending setup through Amazon SES, including Sendy newsletter integration as a worked example.
Trigger this skill when the user wants to:
You need an AWS account with billing enabled and a programmatic IAM user (or role) that has these permissions at minimum:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail",
"ses:GetAccount",
"ses:GetSendQuota",
"ses:GetSendStatistics",
"ses:GetIdentityVerificationAttributes",
"ses:GetIdentityDkimAttributes",
"ses:GetIdentityMailFromDomainAttributes",
"ses:VerifyDomainIdentity",
"ses:VerifyEmailIdentity",
"sesv2:GetAccount",
"sesv2:GetEmailIdentity",
"sesv2:CreateEmailIdentity",
"sesv2:SendEmail",
"sesv2:ListSuppressedDestinations",
"sesv2:PutEmailIdentityMailFromAttributes",
"cloudwatch:GetMetricStatistics"
],
"Resource": "*"
}
]
}
Set these (e.g. in a .env file that you source before running scripts):
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_DEFAULT_REGION=eu-north-1 # or nearest SES region
Never echo or hardcode keys. The scripts in this skill read from the environment
via boto3's default credential chain. Prefer a .env file that you source .env
before running commands.
SES is available in most commercial AWS regions. Pick the one closest to your users:
| Region | Code |
|---|---|
| US East (N. Virginia) | us-east-1 |
| US West (Oregon) | us-west-2 |
| EU (Stockholm) | eu-north-1 |
| EU (Ireland) | eu-west-1 |
| EU (Frankfurt) | eu-central-1 |
| Asia Pacific (Sydney) | ap-southeast-2 |
| Asia Pacific (Tokyo) | ap-northeast-1 |
If no strong reason to choose otherwise, eu-north-1 is a safe default for EU users.
Run the status script:
export SES_DOMAIN=mydomain.com # optional; script also accepts a CLI argument
python3 scripts/check_ses_status.py mydomain.com
| Field | Meaning |
|---|---|
production_access | true after AWS approves your production request. Until then you are in the sandbox. |
sending_enabled | true means your account is not under a sending suspension. |
enforcement | Should read HEALTHY. Anything else (e.g. GRACEFUL, PROBATION) means you have delivery issues. |
quota.Max24HourSend | How many emails you may send per 24-hour window. |
quota.MaxSendRate | Maximum sending rate in emails per second. |
quota.SentLast24Hours | How many emails you have already sent today. |
review.Status | PENDING, GRANTED, or DENIED. |
review.CaseId | AWS Support case ID for your production access case. |
mail_type | The mail type recorded in your support case. |
website | The website URL recorded in your support case. |
domain_verification | SUCCESS / PENDING / FAILED for the domain identity. |
dkim | DKIM signing attributes for the domain. |
| Limit | Sandbox | Production |
|---|---|---|
| Max 24h sends | 200 | 50,000 (default; auto-ramps with good reputation) |
| Max send rate | 1 / sec | 14 / sec (default; ramps) |
| Recipients | Verified addresses only | Any address |
| Outbound to unverified | Blocked | Allowed |
If production_access is false and sending_enabled is true, you're in sandbox.
Proceed to domain verification (Step 3), then production access (Step 4).
Create the domain identity in the SES console or via CLI:
aws sesv2 create-email-identity --email-identity mydomain.com
Or use the AWS Console: SES → Verified identities → Create identity → Domain.
You will get three DKIM CNAME records and you should also add an SPF TXT record.
<selector> values given by AWS — they look like abcdefg...):Name: <selector1>._domainkey.mydomain.com
Type: CNAME
Value: <selector1>.dkim.amazonses.com.
Name: <selector2>._domainkey.mydomain.com
Type: CNAME
Value: <selector2>.dkim.amazonses.com.
Name: <selector3>._domainkey.mydomain.com
Type: CNAME
Value: <selector3>.dkim.amazonses.com.
Important: The trailing dots (.) on the value side are part of the CNAME target.
If your DNS provider appends the zone name automatically (common), check what you
actually entered — a missing trailing dot can result in a double-zone suffix like
<selector>.dkim.amazonses.com.mydomain.com.
Name: mydomain.com (or "@" depending on DNS provider)
Type: TXT
Value: "v=spf1 +a +mx include:amazonses.com ~all"
If you already have an SPF record, add include:amazonses.com before the final ~all or -all:
"v=spf1 +a +mx include:_spf.google.com include:amazonses.com ~all"
python3 scripts/verify_dns.py mydomain.com
It runs dig +short for all three DKIM selectors and the SPF record, printing PASS/FAIL.
"Record points to a target inside this zone that does not exist" (Hetzner DNS Console):
This is a false-positive warning from some DNS providers. They check whether the
CNAME target is itself within the same zone and warn because it isn't. The record
is actually correct. Confirm with dig:
dig +short <selector>._domainkey.mydomain.com CNAME
# Should return: <selector>.dkim.amazonses.com.
If it returns the correct target, the record is fine regardless of the UI warning.
Domain stuck in PENDING:
DKIM won't verify even though dig returns the right value:
SUCCESS in the SES console, not just
the domain verification status.This is the step where most people stall. Follow this precisely.
AWS grants production access based on your answers to these questions (asked in the SES console when you request production access):
This is the single most common reason for denial. AWS is strict about classification:
| If you send… | Choose… |
|---|---|
| Password resets, order confirmations, receipts, one-time codes, account notifications | TRANSACTIONAL |
| Newsletters, marketing campaigns, product updates, drip sequences, weekly digests | MARKETING |
Crucially: if you classify a newsletter/marketing use case as TRANSACTIONAL, your request WILL be denied. AWS expects newsletters to go through MARKETING because MARKETING requires stronger opt-in, bounce, and unsubscribe handling — and AWS wants to see that you understand those requirements.
When in doubt: be honest. If your email has any promotional or newsletter character, use MARKETING. Transactional is only for system-triggered, one-to-one, non-promotional messages.
Request production access through the SES console: Account dashboard → "Request production access."
Fill in the form carefully. If denied, you get a support case ID. The key insight:
You cannot resubmit via the API or console while status is DENIED.
ConflictExceptionis thrown. You MUST reply to the existing support case — do NOT open a new case.
Use templates/support-case-reply.md as a template. It contains two parts:
Fill in the placeholders with your real details:
{{website_url}} — where users sign up (e.g. https://mysaas.com/newsletter){{verified_domain}} — the domain identity you verified (e.g. yourdomain.com){{expected_volume}} — e.g. 6,000 emails per week (one weekly newsletter to ~6K subscribers){{mail_type}} — MARKETING or TRANSACTIONAL (see table above){{justification_for_mail_type}} — one sentence why that type fits{{description_of_what_the_site_does_and_how_users_sign_up}} — site purpose + signup flow{{email_type_1_description}} / {{email_type_2_description}} — what you send{{opt_in_mechanism}} — how consent is obtained (e.g. Double opt-in via email confirmation link after signup form submission){{soft_bounce_threshold}} — e.g. 3{{case_id}} — the AWS support case ID (Part B only){{your_name}} / {{your_email}} / {{your_aws_account_id}} — sign-offA strong case-reply:
Weak replies get rejected again. Be specific.
Once your case status changes to GRANTED and ProductionAccessEnabled is true:
check_ses_status.py again to confirm.SES SMTP credentials are not your IAM secret key. They are derived from it
via a keyed HMAC-SHA256 with "SendRawEmail" as the key material.
python3 scripts/gen_smtp_password.py
This reads AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION from the environment
and outputs:
SMTP_USERNAME: AKIA...
SMTP_PASSWORD: BLBM... (derived from IAM secret via SigV4 chain — NOT the raw secret)
SMTP_SERVER: email-smtp.eu-north-1.amazonaws.com
SMTP_PORT: 587
The SMTP password is a SigV4-style derivation, not the raw IAM secret:
"11111111" with HMAC-SHA256, keyed by "AWS4" + secret."ses", then "aws4_request"."SendRawEmail" with the resulting key.0x04 version byte prepended to that signature.The password is region-dependent — the same secret yields a different
password per region, so --region is required. The username is the IAM
Access Key ID (same key pair as the secret). This is the standard AWS
algorithm documented for SES SMTP credentials.
| Port | Protocol | Notes |
|---|---|---|
| 587 | STARTTLS | Recommended. Use this. |
| 2587 | STARTTLS | Alternative for networks that block 587. |
| 465 | TLS Wrapper | Legacy; works but 587 is preferred. |
| 25 | Plain / STARTTLS | Often blocked by cloud providers. |
# Test SMTP connectivity (openssl)
openssl s_client -connect email-smtp.eu-north-1.amazonaws.com:587 -starttls smtp -crlf
# EHLO mydomain.com
# AUTH LOGIN
# (paste base64(username))
# (paste base64(password))
If you need to rotate, create a new IAM access key pair, derive the SMTP password from the new secret, update your app config, and then deactivate/delete the old key.
In sandbox, you can ONLY send to verified email addresses or domains. Both the FROM and TO addresses must be verified.
import boto3
import os
client = boto3.client("sesv2", region_name=os.getenv("AWS_DEFAULT_REGION", "eu-north-1"))
response = client.send_email(
FromEmailAddress="hello@mydomain.com",
Destination={
"ToAddresses": ["hello@mydomain.com"], # sandbox: must be verified
},
Content={
"Simple": {
"Subject": {"Data": "SES test email", "Charset": "UTF-8"},
"Body": {
"Text": {"Data": "This is a test email from Amazon SES.", "Charset": "UTF-8"}
},
}
},
)
print(f"MessageId: {response['MessageId']}")
Sendy is a self-hosted PHP newsletter application that uses SES as its SMTP backend. This section assumes you have Sendy installed on a server and want to configure it to send through your freshly provisioned SES setup.
In Sendy's settings (/includes/config.php or via the web admin):
// SMTP settings
define('SMTP_HOST', 'email-smtp.eu-north-1.amazonaws.com'); // your SES region's endpoint
define('SMTP_PORT', 587);
define('SMTP_AUTH', true);
define('SMTP_SECURE', 'tls');
define('SMTP_USERNAME', 'AKIA...'); // IAM Access Key ID
define('SMTP_PASSWORD', 'BLBM...'); // derived SMTP password (not IAM secret!)
define('SMTP_FROM_ADDRESS', 'hello@mydomain.com');
define('SMTP_FROM_NAME', 'My Newsletter');
Common Sendy SES pitfalls:
A Sendy newsletter is MARKETING mail type. Even if the content is "transactional" in your mind (e.g., a product update), AWS SES classifies bulk/app-mass sends as MARKETING. Classify correctly or your production access request will be denied.
Sendy exposes a simple HTTP API for managing campaigns and subscribers.
curl -X POST https://sendy.yourdomain.com/api/campaigns/create.php \
-d "api_key=YOUR_SENDY_API_KEY" \
-d "brand_id=1" \
-d "list_ids=1,2" \
-d "title=Weekly Digest" \
-d "subject=Your Weekly Update" \
-d "from_name=My Newsletter" \
-d "from_email=hello@mydomain.com" \
-d "reply_to=hello@mydomain.com" \
-d "plain_text=Fallback plain text version" \
-d "html_text=<h1>Hello!</h1><p>This is the newsletter.</p>" \
-d "send_campaign=1" # 0 = draft, 1 = send immediately
curl -X POST https://sendy.yourdomain.com/subscribe \
-d "api_key=YOUR_SENDY_API_KEY" \
-d "list=LIST_ID" \
-d "email=subscriber@example.com" \
-d "name=John Doe" \
-d "boolean=true" # true to send confirmation email (double opt-in)
| Endpoint | Purpose |
|---|---|
/subscribe | Add subscriber (with optional double opt-in) |
/unsubscribe | Remove subscriber |
/api/subscribers/active-subscriber-count.php | Get list count |
/api/subscribers/subscription-status.php | Check if email is subscribed |
A typical Sendy setup with 6,000 subscribers and one weekly newsletter:
In your AWS support case, you'd state: MARKETING, 6,000 emails/week, weekly newsletter to opt-in subscribers.
SES sends delivery, bounce, and complaint notifications via SNS (Simple Notification Service). You MUST wire these up — AWS will suspend your account if your complaint rate exceeds 0.1% (and even 0.08% should trigger action). Bounce rate matters too: >5% triggers a review, >10% triggers suspension — these are the more common trigger. "We check the dashboard" is not a strategy; it's why production access gets denied.
SES send → Delivery/Bounce/Complaint event
↓
SNS Topic (e.g. ses-events)
↓
SQS / Lambda / HTTP endpoint (subscriber)
↓
Your app: suppress bounces + complaints, log deliveries
Create an SNS topic (standard type, not FIFO):
aws sns create-topic --name ses-events
Configure the SES configuration set to publish to that topic. A configuration set is the bridge between SES sends and SNS:
aws sesv2 create-configuration-set --configuration-set-name my-events
aws sesv2 create-configuration-set-event-destination \
--configuration-set-name my-events \
--event-destination-name all-events \
--event-destination '{
"SnsDestination": {"TopicArn": "arn:aws:sns:<region>:<acct>:ses-events"},
"MatchingEventTypes": ["SEND", "REJECT", "BOUNCE", "COMPLAINT",
"DELIVERY", "OPEN", "CLICK"]
}'
Include the configuration set when sending:
client.send_email(
...
ConfigurationSetName="my-events",
)
Or set a default configuration set on the email identity (simpler — all sends from that domain automatically use it):
aws sesv2 put-email-identity-configuration-set-attributes \
--email-identity mydomain.com \
--configuration-set-name my-events
Subscribe a Lambda or HTTP endpoint to the SNS topic:
aws sns subscribe \
--topic-arn arn:aws:sns:<region>:<acct>:ses-events \
--protocol lambda \
--notification-endpoint arn:aws:lambda:<region>:<acct>:function:ses-handler
| Event type | Action |
|---|---|
| BOUNCE (hard) | Suppress immediately. Hard bounces mean permanent failure (mailbox doesn't exist, domain doesn't exist). |
| BOUNCE (soft) | Track count. Suppress after N consecutive soft bounces (3 is a good default). Soft bounces are transient (mailbox full, server busy). |
| COMPLAINT | Suppress immediately and permanently. Someone clicked "Report spam." Never email this address again. |
| DELIVERY | Optional: log for analytics, reset soft-bounce counters. |
A minimal Lambda handler (Python):
import json
def handler(event, context):
for record in event.get("Records", []):
msg = json.loads(record["Sns"]["Message"])
ntype = msg.get("notificationType", "")
if ntype == "Bounce":
for recipient in msg.get("bounce", {}).get("bouncedRecipients", []):
email = recipient.get("emailAddress")
htype = recipient.get("bounceType") # Permanent vs Transient
if htype == "Permanent":
suppress(email, reason="hard-bounce")
else:
track_soft_bounce(email)
elif ntype == "Complaint":
for recipient in msg.get("complaint", {}).get("complainedRecipients", []):
suppress(recipient.get("emailAddress"), reason="complaint")
return {"statusCode": 200}
The SNS message body is a JSON string (double-encoded). Parse it as:
msg = json.loads(record["Sns"]["Message"])
# msg["notificationType"] → "Bounce" | "Complaint" | "Delivery"
# msg["bounce"]["bouncedRecipients"][0]["emailAddress"]
# msg["complaint"]["complainedRecipients"][0]["emailAddress"]
If you don't specify a configuration set, SES still generates events for bounces and complaints — but they go to the account-level notification settings, not to your topic. Configure these as a fallback:
aws ses set-identity-notification-topic \
--identity mydomain.com \
--notification-type Bounce \
--sns-topic arn:aws:sns:<region>:<acct>:ses-events
aws ses set-identity-notification-topic \
--identity mydomain.com \
--notification-type Complaint \
--sns-topic arn:aws:sns:<region>:<acct>:ses-events
Best practice: Use a configuration set for new sends and set the account-level notification as a safety net for anything that slips through.
If you are sending newsletters or marketing campaigns through SES, a disciplined approach is the difference between a healthy sender reputation and an account under PROBATION.
AWS starts new SES accounts on shared IP pools by default. Shared IPs have established reputation, so your warmup period is shorter than with dedicated IPs. But you still need to ramp gradually:
| Week | % of max list size | Notes |
|---|---|---|
| 1 | 5-10% | Send to your most engaged subscribers first. |
| 2 | 15-25% | Add moderately-engaged addresses. |
| 3 | 30-50% | Broaden to less-engaged, but stop if complaint rate rises. |
| 4 | 70-100% | Full list, assuming complaint rate holds steady. |
SES auto-ramps your sending quota based on reputation. If you send high-quality mail (low bounce, low complaint), your Max24HourSend goes from 50K → 100K → 200K+ automatically. If you trigger complaints, it stays flat or drops.
Send only to people who have explicitly opted in within the last 12 months. Older addresses are stale — people change jobs, abandon accounts, forget they signed up. The single biggest factor in spam complaints is "I don't remember signing up for this."
Remove hard bounces within one send cycle. A bounced address
should never receive another email. Boto3 sesv2 has a suppression
list API — use it or maintain your own. If you hit the same bad
address repeatedly, mailbox providers (Gmail, Yahoo, Outlook) will
treat your domain as a spam source.
Purge addresses that haven't engaged in 6+ months (no opens, no clicks). Inactive addresses become spam traps over time — mailbox providers convert abandoned accounts into honeypots to catch senders who don't clean their lists. Sending to a spam trap is instant reputation damage. Run a re-engagement campaign before purging: send a "We've missed you — click to stay subscribed" email. If no engagement after 2 sends, delete.
Monitor complaint rate obsessively. Run bounce_stats.py daily:
python3 scripts/bounce_stats.py
If the complaint rate crosses 0.05%, investigate immediately. Above 0.1%, AWS may place your account under review.
Use double opt-in. Confirmed opt-in (send a verification link, require a click before any further email) eliminates typos, bot signups, and malicious subscription attempts. It adds friction but produces a vastly cleaner list.
SES enforces a per-account MaxSendRate (emails/second). In sandbox
it's 1/sec; in production it starts at ~14/sec and ramps. If you exceed
it, SES v2 raises TooManyRequestsException — your sending code must
handle this with exponential backoff. (botocore's default retry mode
handles this automatically in most SDKs; the manual loop below is for
clients that don't.)
import time
import boto3
from botocore.exceptions import ClientError
def send_with_retry(client, max_retries=5, **kwargs):
delay = 1.0
for attempt in range(max_retries):
try:
return client.send_email(**kwargs)
except ClientError as e:
if e.response["Error"]["Code"] == "TooManyRequestsException":
if attempt == max_retries - 1:
raise
time.sleep(delay)
delay *= 2 # 1s → 2s → 4s → 8s → 16s
else:
raise
For bulk sends, pace yourself — don't blast your entire list at 14/sec in one loop. Spread sends over time using a queue (SQS) or by sleeping between batches.
Request dedicated IPs through an AWS Support case when you send >500K emails/month consistently. Dedicated IPs give you full control over reputation but require deliberate warmup (start at 50 emails/day per IP, double weekly). For most users, shared IPs are better — AWS manages the reputation so you don't have to.
Checklist:
DKIM + SPF must PASS. Run verify_dns.py. If either fails, email
providers cannot verify that you are authorized to send from that
domain. No DKIM + no SPF = spam folder essentially guaranteed.
python3 scripts/verify_dns.py mydomain.com
DMARC record. Add a DMARC policy at _dmarc.mydomain.com:
Type: TXT
Name: _dmarc.mydomain.com
Value: "v=DMARC1; p=none; rua=mailto:dmarc@mydomain.com"
Start with p=none (monitor), move to p=quarantine, then
p=reject once you trust your setup.
Custom MAIL FROM domain. Without it, SES uses a default
amazonses.com MAIL FROM, which causes SPF alignment to fail
(DMARC depends on it). Set a custom MAIL FROM:
aws sesv2 put-email-identity-mail-from-attributes \
--email-identity mydomain.com \
--mail-from-domain bounce.mydomain.com
Then add an MX record for bounce.mydomain.com (for inbound bounce
processing) and an SPF TXT record.
Content signals. Avoid ALL CAPS subject lines, excessive exclamation marks, image-only emails with no text, URL shorteners, and "click here" as your only link text. These are spam-trigger signals for Bayesian filters.
Warm up the domain. A brand-new domain sending to 10K addresses on day 1 looks like a spam run. Ramp over 2-4 weeks (see §10a).
Check blacklists. Use mxtoolbox.com or multirbl.valli.org to
check if your IP/domain is on any DNS blacklists.
DNS hasn't propagated: Wait. Up to 72 hours for some providers,
though Cloudflare/Route 53 are typically sub-minute. Check with dig:
dig +short <token>._domainkey.mydomain.com CNAME
If dig returns the right target, your DNS is fine — SES's periodic
verification scanner hasn't picked it up yet. Manually trigger
re-verification from the SES console (Verified identities → select
domain → "Retry" or "Verify" button). This forces an immediate check
rather than waiting for the automated poller.
CNAME record has wrong target: Compare with the SES console. The three DKIM tokens are unique to your domain/region. If you re-created the identity, old CNAME records may point to stale tokens.
Trailing-dot issues (Hetzner, some registrars): See §4d — verify
with dig +short, not the DNS provider's UI.
Identity created in the wrong region: SES identities are regional.
If you created the identity in eu-north-1 but are checking in
us-east-1, it won't exist. Always specify --region.
"TooManyRequestsException: Maximum sending rate exceeded." SES is rate-limiting you. Actions:
Immediate: add exponential backoff (see §10c).
Check current limits:
python3 scripts/check_ses_status.py
Look at MaxSendRate. If you need more throughput than the default
(14/sec in production), you can request a limit increase through
the AWS SES console (Sending Statistics → Request increase).
Increases are typically granted if your account has good reputation.
Pacing: don't spin-loop all sends at MaxSendRate. Use a queue or sleep between batches.
If EnforcementStatus is GRACEFUL or PROBATION instead of
HEALTHY, your account has delivery issues. This means:
Recovery:
bounce_stats.py to diagnose.Prevention: Wire up SNS bounce/complaint handling (§9) before you ever send a newsletter. Real-time suppression prevents reputation damage.
SES pricing is simple and cheap compared to dedicated email services.
| Component | Price | Notes |
|---|---|---|
| Outbound email | $0.10 / 1,000 emails | Standard rate. |
| Attachments | $0.12 / GB | Charged per GB of attachment data, not just the attachment size — encoding adds ~33% overhead. A 1 MB PDF attached to 1,000 emails = ~1 GB = $0.12. |
| Inbound email | $0.10 / 1,000 received | If you use SES for receiving mail too. |
| EC2 to SES | $0.10 / 1,000 emails | AWS retired the 62,000/month free tier in Aug 2023; replaced with a 3,000 messages/month charge-back for 12 months. Re-check current pricing. |
| Lightsail to SES | Free | Emails sent from Lightsail instances: first 1,000/month free. |
| Scenario | Monthly cost |
|---|---|
| 5,000 emails/month (small SaaS, transactional) | $0.50 |
| 25,000 emails/month (weekly newsletter, 6K subs) | $2.50 |
| 100,000 emails/month (daily marketing, 5K subs) | $10.00 |
| 1,000,000 emails/month (large newsletter) | $100.00 |
| 10,000,000 emails/month (enterprise) | $1,000.00 |
| Service | Price / 1,000 | Notes |
|---|---|---|
| AWS SES | $0.10 | Cheapest by far. Requires your own integration. |
| SendGrid (Twilio) | $0.54–$0.82 | Managed UI + API. Free tier: 100/day. |
| Mailgun | $0.40–$0.80 | Developer-friendly API. Free tier: 100/day. |
| Postmark | $1.25+ | Transactional only, no marketing. |
| Mailchimp (Mandrill) | ~$0.40 | Marketing focused. |
| ConvertKit | $29+/month flat | For creators; includes full CRM. |
For self-hosted apps and newsletters (Sendy + SES): SES is the winner on cost. You pay ~$0.10/1,000 for sending vs $30-100+/month for the equivalent on a managed platform.
Marketing classified as TRANSACTIONAL → instant denial. Newsletters, campaigns, drip sequences, product updates = MARKETING. Only system-triggered personal messages = TRANSACTIONAL.
API resubmit after DENIED is blocked.
You'll get ConflictException. Reply to the existing support case instead.
Opening a new case will be closed as duplicate.
SMTP password ≠ IAM secret.
The SMTP password is derived via HMAC-SHA256. Using the raw IAM secret as
the SMTP password will fail authentication. Use gen_smtp_password.py.
Sandbox: only verified recipients.
In sandbox you can only send TO verified email addresses/domains. Trying to
send to an unverified address returns MessageRejected: Email address is not verified.
DKIM trailing-dot / relative-name issues in Hetzner and similar DNS
providers.
The DNS console may warn about "target inside this zone" or silently append
the zone name to CNAME values. Always verify with dig +short from the CLI,
not the DNS provider's UI.
SPF propagation lag after adding include:amazonses.com.
SPF TXT records can take longer to propagate than CNAME records on some
providers. If DKIM is verified but SPF checks still fail, wait 15–30 minutes
and re-check.
Never echo keys in shell commands.
AWS_SECRET_ACCESS_KEY and derived SMTP passwords should never appear in
shell history or log output. Use .env files and source them; let scripts
read from the environment.
After completing all steps, confirm every item:
VerifiedForSendingStatus: true (check via check_ses_status.py)include:amazonses.com (check via verify_dns.py)_dmarc.mydomain.com (start with p=none)setup_domain.py --mail-from)send_test_email.py --dry-run first, then livebounce_stats.py weekly)| Script | Purpose |
|---|---|
scripts/check_ses_status.py | Full account health: sandbox/prod, quota, domain verification, DKIM, review status |
scripts/gen_smtp_password.py | Derive SES SMTP password from IAM secret key |
scripts/verify_dns.py | Check DKIM CNAMEs and SPF record via dig +short |
scripts/send_test_email.py | Send a test email via SES v2 API with --dry-run preview |
scripts/setup_domain.py | Create/verify a domain identity and print DKIM + SPF DNS records |
scripts/bounce_stats.py | Fetch bounce/complaint/suppression list counts and complaint rate |
Run each from the skill root:
# 1. Check account status
python3 scripts/check_ses_status.py mydomain.com
# 2. Verify DNS records
python3 scripts/verify_dns.py mydomain.com
# 3. Generate SMTP credentials
python3 scripts/gen_smtp_password.py
# 4. Set up a domain identity + print DNS records
python3 scripts/setup_domain.py mydomain.com --dry-run # preview first
python3 scripts/setup_domain.py mydomain.com
# 5. Send a test email
python3 scripts/send_test_email.py \
--from hello@mydomain.com --to me@example.com \
--subject "Test" --body "Hello from SES" --dry-run # preview first
python3 scripts/send_test_email.py \
--from hello@mydomain.com --to me@example.com \
--subject "Test" --body "Hello from SES"
# 6. Monitor bounce & complaint health
python3 scripts/bounce_stats.py
All scripts read AWS credentials from the environment (boto3 default credential chain). No hardcoded keys anywhere.