Amazon SES Operational Runbook¶
Operational setup for Vayzo's Amazon SES transactional email. See
Production Communications for the application-level design this
runbook operates. <VAYZO_DOMAIN> is a placeholder throughout - the real production domain is not
guessed or invented here; substitute it when this runbook is executed for real.
1. Domain verification¶
- In the target AWS account/region (recommend
ap-south-1, Mumbai, for an India-first launch - lowest latency to Indian recipients; SES v2 does not require a dedicated IP for initial low volume), open SES > Identities > Create identity > Domain. - Enter
<VAYZO_DOMAIN>. SES issues a set of DNS records to add at the domain's DNS provider. - Verification is domain-level, not just the sender address -
noreply@<VAYZO_DOMAIN>andsupport@<VAYZO_DOMAIN>(or any other@<VAYZO_DOMAIN>address) all become sendable once the domain itself verifies.
2. DKIM¶
- Prefer Easy DKIM (SES-managed, auto-rotating 2048-bit RSA keys) over "Bring your own DKIM" unless there is a specific reason to reuse an existing DKIM key across providers.
- SES gives 3 CNAME records to add at the DNS provider - propagation typically completes within minutes to a few hours; verification status is visible on the identity's SES console page.
- Without DKIM passing, mail is far more likely to land in spam or be rejected outright by major
inbox providers (Gmail/Outlook) - do not proceed to production sending before this is
Verified.
3. SPF¶
- Add (or extend, if
<VAYZO_DOMAIN>already has an SPF record for another sender) a TXT record:v=spf1 include:amazonses.com ~all(or-allonce confident nothing else legitimately sends from this domain -~allis the safer starting point). - If
<VAYZO_DOMAIN>already sends mail through another provider (e.g. a Google Workspace mailbox for human email, kept separate from this application per the tranche's own decision), merge into one SPF record - a domain can have only one; two SPF TXT records is itself an SPF failure.
4. DMARC¶
- Add a TXT record at
_dmarc.<VAYZO_DOMAIN>: start withv=DMARC1; p=none; rua=mailto:dmarc-reports@<VAYZO_DOMAIN>; pct=100(monitor-only) rather than jumping straight top=reject-p=nonestill gives visibility (aggregate reports) without any risk of legitimate mail being rejected while SPF/DKIM alignment is still being validated in practice. - After a monitoring period showing DKIM+SPF consistently pass and align with the
From:domain, progressively tighten top=quarantineand eventuallyp=reject.
5. SES sandbox -> production access¶
- New SES accounts start in the sandbox: mail can only be sent to verified individual recipient addresses, and a low daily quota applies - this is why "do not attempt real SES sending" in this tranche was correct even if credentials existed; sandbox sending to arbitrary customer addresses would fail outright.
- Request production access via SES console > Account dashboard > "Request production access" (or AWS Support case). AWS asks for: use case description (transactional order/return/shipment emails for an e-commerce marketplace), expected sending volume, how bounces/complaints are handled (see §7), and confirmation that recipient consent/legitimacy is not in question (transactional mail to a customer's own order is generally low-risk from AWS's review perspective compared to bulk/marketing mail).
- This step requires a real AWS account and cannot be completed in this tranche.
6. IAM least privilege¶
The application's IAM identity (an IAM role via IRSA on EKS, an instance profile, or an IAM user
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY pair injected only via the platform's secret mechanism -
never via appsettings*.json or source control, see §9) needs only:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ses:SendEmail"],
"Resource": "arn:aws:ses:ap-south-1:<ACCOUNT_ID>:identity/<VAYZO_DOMAIN>"
}
]
}
Nothing broader (no ses:*, no identity management, no suppression-list management) is required
for SesEmailSender as implemented - it only ever calls SendEmail. If bounce/complaint
suppression-list inspection is added later (§7), extend with ses:GetSuppressedDestination/
ses:ListSuppressedDestinations scoped the same way, not a wildcard.
7. Bounce/complaint handling¶
SES requires senders to have a plan for bounces/complaints as a condition of production access:
- Create an SES configuration set and set
Marketplace:Notifications:Ses:ConfigurationSetNameto its name -SesEmailSenderalready includes it on every send request when configured. - Attach an SNS topic to the configuration set's Bounce/Complaint/Delivery event destinations.
- Not implemented in this tranche: nothing yet consumes that SNS topic. Before enabling SES in any real environment, add a subscriber (an SQS queue behind a small consumer, or a Lambda) that:
- marks the recipient
User(or a dedicated suppression record) as bounced/complained so future sends to that address are skipped rather than repeatedly bouncing (repeated hard bounces risk SES account-level sending-quality reputation, which can suspend the whole account's ability to send - this is not merely cosmetic). - SES also maintains its own account-level suppression list automatically for hard bounces/ complaints (on by default) - a useful safety net even before the application-level consumer above exists, but do not rely on it alone long-term (it doesn't give the application visibility into why a specific customer stopped receiving mail).
8. Staging vs production¶
- Staging:
Marketplace:Notifications:EmailEnabled=falseandSes:Enabled=falseby default (matches the Helm chart's existing "C4 launch policy" default foremailEnabled). If staging needs to exercise real sending, use a separate SES identity in sandbox mode with the engineering team's own verified addresses as recipients - never point staging at the production SES identity, and never send real customer traffic through a sandboxed identity. - Production:
EmailEnabled=true/Ses:Enabled=trueonly after §1-§7 are complete and verified, perProgram.ValidateRuntimeConfiguration's fail-closed startup check (this tranche's addition) - starting withSes:Enabled=truebut a missing/placeholderSenderAddressorRegionthrows at startup rather than degrading to silent no-op sends.
9. Required runtime/Jenkins/K8s values¶
No AWS access key/secret appears in this list - see "Credentials" in
Production Communications. The
application resolves credentials only from the AWS SDK's default provider chain (environment
variables set by the platform's secret injection, or an attached IAM role - IRSA on EKS,
instance profile on EC2). If the deployment platform uses AWS_ACCESS_KEY_ID/
AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN environment variables instead of an attached role, those
must come from the platform's secret store (Kubernetes Secret referenced via envFrom/
secretKeyRef, Jenkins credentials binding, etc.) - never a Helm values.yaml literal, never a
committed file.
Non-secret configuration (safe in values.yaml/Jenkins parameters):
Marketplace__Notifications__EmailEnabled = false (staging/production default; true only after §1-§8)
Marketplace__Notifications__Ses__Enabled = false (same)
Marketplace__Notifications__Ses__Region = ap-south-1
Marketplace__Notifications__Ses__SenderAddress = noreply@<VAYZO_DOMAIN>
Marketplace__Notifications__Ses__ReplyToAddress = support@<VAYZO_DOMAIN>
Marketplace__Notifications__Ses__ConfigurationSetName = vayzo-transactional (once §7's config set exists)
Marketplace__Notifications__Ses__FrontendBaseUrl = https://<VAYZO_DOMAIN>
10. No-secrets-in-git rule¶
- Never commit an AWS access key/secret to
appsettings*.json, Helmvalues*.yaml, or any file in this repository, staging or production.SesOptions(Infrastructure/Services/SesEmailSender.cs) structurally has no field for one - see the testSesOptions_NeverExposesAnAwsCredentialField. - If a key is ever accidentally committed (any commit, any branch, even later force-pushed away): treat it as compromised immediately - rotate/deactivate it in IAM, it is not sufficient to simply remove it from a future commit since it remains in git history.