SES
After this you can answer: Why can SES accept an email that the customer never receives, and what must be in place before real customers get our booking emails?
Picture it
Section titled “Picture it”Where it sits
Section titled “Where it sits”SES is a regional AWS service, reached over HTTPS. No VPC, no security group: the worker role’s IAM policy is the only gate on our side.
flowchart TB queue["minitickets-dev-jobs SQS"] -->|"batch of up to 10 - event source mapping, worker role sqs:ReceiveMessage"| worker["Worker Lambda"] worker -->|"SendEmail - worker role ses:SendEmail, ses:FromAddress = no-reply@domain"| ses["SES us-east-1"] ses -->|"verifies identity - 3 DKIM CNAMEs, proxy off"| dns["Cloudflare DNS (managed by hand)"] ses -->|"SMTP delivery, DKIM-signed"| inbox["Recipient mail server"] queue -->|"moves after 3 receives - redrive policy maxReceiveCount 3"| dlq["minitickets-dev-jobs-dlq"] class ses service class worker app class queue,dlq aws class dns,inbox awsLegend: highlighted = SES; app = our code; AWS = managed service or external system. Everything here exists in code; the Cloudflare records are a manual step.
Open SES in the map →How it works
Section titled “How it works”Follow one confirmation email.
- The worker gets a
booking.confirmedmessage from the jobs queue. It checks the processed-jobs table: ifbooking.confirmed:<bookingId>is recorded, it skips. - It calls
SendEmail(SES API v2) withFromEmailAddress=no-reply@<domain>, oneToaddress, and a plain-text subject and body. The worker role’s policy allowsses:SendEmailonly on our identity ARNs and only whenses:FromAddressequals that sender. - SES checks the account: is the sender a verified identity? In the sandbox, is the recipient verified too? Is the account under its daily quota and per-second rate? Is the address on the suppression list? Any “no” is either an error now or a silent drop later.
- SES accepts and returns a
MessageId. The worker logs it with the recipient’s domain only, then records the job as done. - SES signs the message with DKIM, a signature in a header that proves our domain sent it and nobody changed it. It hands the message to the recipient’s mail server over SMTP.
- The receiving server checks the DKIM signature against the public key in our DNS, then decides: inbox, spam, or reject. A reject or “no such user” comes back to SES as a bounce. A “this is spam” click may come back as a complaint.
sequenceDiagram participant W as Worker Lambda box rgb(237,233,254) Amazon SES participant S as SES end participant D as Cloudflare DNS participant M as Recipient mail server W->>S: SendEmail from no-reply@domain, worker role S->>S: Identity, sandbox, quota, suppression S-->>W: MessageId (accepted, not delivered) W->>W: Record booking.confirmed job as done S->>M: SMTP, DKIM-signed M->>D: Look up DKIM public key D-->>M: CNAME to amazonses.com key M-->>S: Delivered, or bounce, or later complaintLegend: arrows show order. Everything after “MessageId” happens after the worker has finished; our code never sees it today.
Under the hood
Section titled “Under the hood”Identities. An identity is a domain or an email address that SES knows you control. A domain identity covers every address at that domain: no-reply@ needs no mailbox. You verify a domain with DNS records; you verify an address by clicking a link SES emails to it. Our code creates both: the domain from domainName, and in the sandbox, the owner’s own address as a test recipient. Identities are per Region. A domain verified in us-east-1 is not verified in eu-west-1. AWS: identities
DKIM, SPF and DMARC. Receiving servers use three checks to decide whether mail is really from us:
| Check | What it proves | Ours |
|---|---|---|
| DKIM | A signature in the message header matches a public key in the sender domain’s DNS | Easy DKIM: SES makes and rotates 2,048-bit keys; we add 3 CNAMEs |
| SPF | The sending server’s IP is allowed for the envelope sender (the bounce address) domain | Default: the envelope sender is an amazonses.com address, so SPF passes for that domain, not ours |
| DMARC | DKIM or SPF passes and matches the visible From domain; the domain’s policy says what to do if not | DKIM signs with our domain, so it aligns. No DMARC record exists in our code; DNS is set by hand in Cloudflare |
A custom MAIL FROM domain (for example mail.<domain>) makes SPF align too. It needs an MX and a TXT record. Large mailbox providers now expect DMARC from bulk senders; check their current rules before sending at volume. AWS: authentication
The sandbox. A new account can send only to verified addresses, at most 200 messages per 24 hours and 1 per second. Production access is a request with your use case: what you send, to whom, and how you handle bounces and complaints. A person at AWS reviews it. After approval you get a quota for your use case, and it can grow. The sandbox is per Region. AWS: production access
Bounces and complaints. A hard bounce is permanent (“no such user”). A soft bounce is temporary (mailbox full); SES retries soft bounces for a while. A complaint is a recipient’s “this is spam” that the mailbox provider reports back through a feedback loop. Gmail does not send complaints to SES. AWS watches your rates on a representative volume, not a fixed period:
| Rate | Under review | May pause sending |
|---|---|---|
| Bounce (hard bounces to unverified domains) | 5% | 10% |
| Complaint (feedback-loop complaints) | 0.1% | 0.5% |
AWS advises keeping bounces under 2%. A pause stops sending for the whole account in that Region: every booking email fails. AWS: enforcement FAQ
Suppression list. When an address hard-bounces or complains, SES can add it to your account-level suppression list. Later sends to it are accepted but not delivered, and they still count toward your quota. Accounts that started using SES after 25 November 2019 have this on for both reasons by default. We have not read our account’s setting. AWS: suppression list
Configuration sets. A configuration set is a named group of rules you attach to a send, or set as an identity’s default. Its event destinations publish what happened to each message: send, reject, delivery, bounce, complaint, delivery delay, open, click, rendering failure. Destinations include CloudWatch, Data Firehose, SNS and EventBridge. Without one, your code only ever knows “accepted”. We have no configuration set. AWS: configuration sets
Limits and numbers
Section titled “Limits and numbers”At the time of writing; check the AWS docs. AWS values read on 2026-10-19 from the SES quotas page. Quotas count recipients, not API calls, and are per Region.
| Item | Value | Note |
|---|---|---|
| Sandbox daily quota | 200 messages per 24 hours | Rolling window |
| Sandbox sending rate | 1 message per second | Our worker can exceed it (below) |
| Production quotas | Set per account from your use case; adjustable | Read with get-account |
| Message size (API v2 or SMTP) | 40 MB after encoding | Over 10 MB is bandwidth-throttled |
| Recipients per message | 50 (To + CC + BCC) | We send to one |
| Verified identities per Region | 10,000 | |
| Event destinations per configuration set | 10, fixed | |
| Other SES API actions | 1 request per second, fixed | Identity and config calls, not sends |
| Our sandbox reading | ProductionAccessEnabled false, 200 per 24 h, 1 per second | Recorded in the infra/src/jobs/email.ts comment from an earlier get-account; not re-read now |
| Our worker | Batch 10, maximumConcurrency 5, timeout 10 s, 3 tries then DLQ | queue-trigger.ts, worker-lambda.ts, queues.ts |
In MiniTickets
Section titled “In MiniTickets”Built in code. No live SES call was made for this page.
infra/src/jobs/email.ts → createEmail creates the domain identity with Easy DKIM (RSA_2048_BIT), outputs the three DKIM CNAME records for Cloudflare (proxy must be off), sets the sender to no-reply@<domain>, and in the sandbox creates a verified test-recipient identity from a secret config value. SES is not behind a cost switch: identities are free, and the DNS records stay valid only while the identity exists.
apps/worker/src/ses-mailer.ts → SesMailer sends a simple UTF-8 text email with SendEmailCommand and logs the SES MessageId and the recipient’s domain, never the address. Locally the worker uses a log-only mailer, so a laptop never emails anyone.
The worker role (infra/src/jobs/worker-iam.ts) allows ses:SendEmail on the identity ARNs with the condition ses:FromAddress = our sender. Both identity ARNs are listed because, in the sandbox, SES also checks permission on the recipient’s identity. The code comment says the team is fairly sure of this from sandbox behavior; treat it as observed, not documented.
What this means today: a booking by the verified test address gets its email. A booking by anyone else fails at SES with “Email address is not verified”, is retried, and lands in the DLQ after 3 tries. That is the sandbox, working as designed, not a bug. See the DLQ growing case.
What is missing before real customers: production access, a configuration set with bounce and complaint events, a DMARC record, and a plan for the suppression list. None of these exist yet.
Connections
Section titled “Connections”| From → to | What happens | What allows it |
|---|---|---|
| SES email identities → Public DNS | receiving mail servers check the DKIM signature against our DNS |
|
| Worker Lambda → SES email identitiesonly when computeEnabled = true | SendEmail from no-reply@minitickets.theversehub.in |
|
| Connection | What allows it | Boundary to keep |
|---|---|---|
| Worker → SES | Worker role ses:SendEmail on our identity ARNs, condition ses:FromAddress | Cannot send as any other address, even with stolen credentials |
| SES → recipient | Verified domain identity; in the sandbox, a verified recipient | Production access removes the recipient check, not the identity check |
| Recipient server → our DNS | Public DKIM CNAMEs <token>._domainkey.<domain> → <token>.dkim.amazonses.com | A proxied (orange-cloud) CNAME hides the key: DKIM fails |
| SES → bounce or complaint events (not built) | Configuration set event destination, plus an SNS topic policy or EventBridge rule | Consumer must be idempotent: events can repeat |
When it fails
Section titled “When it fails”flowchart TB send["Worker calls SendEmail"] --> ok{"SES accepted?"} ok -->|"no: MessageRejected, throttled"| retry["Message fails, retried"] retry -->|"3 tries"| dlq["jobs DLQ"] ok -->|"yes"| after{"Delivered to inbox?"} after -->|"bounce, spam, suppressed"| silent["Customer gets nothing, no error in our logs"] after -->|"yes"| inbox["Email arrives"] class send app class ok,after service class retry,dlq,silent failure class inbox awsLegend: red = failure paths. The left path is loud (errors, DLQ). The right path is silent without a configuration set.
| What you see | Why | Fix |
|---|---|---|
MessageRejected: Email address is not verified | Sandbox: the recipient is not a verified identity | Expected in the sandbox. For real customers, request production access |
AccessDenied naming an identity ARN | Worker role lacks ses:SendEmail on that identity, or the From address differs from the condition | Compare MAIL_FROM with the policy condition and the identity list |
Maximum sending rate exceeded or throttling | Up to 5 parallel batches can call SES at once; the sandbox allows 1 per second | Cap the worker (reserved concurrency) or raise the quota; retries absorb short bursts |
| Identity stuck “Pending”, every send fails | DKIM CNAMEs missing, mistyped, or proxied in Cloudflare | Add the 3 records as DNS only; SES re-checks for up to 72 hours |
| Logs say “SES accepted”, customer says nothing arrived | Bounce, spam folder or suppression, after acceptance | Search the MessageId; add a configuration set; check the suppression list |
The last row is the important one in interviews. Our logs prove acceptance only. Without event publishing, a hard bounce or a suppressed address looks exactly like success. AWS: SES events
At 10x / 100x
Section titled “At 10x / 100x”At 10x, the sending rate matters before the daily quota. A sale day makes many confirmations in a short time. With a production rate of, say, 14 per second, five parallel batches can still burst over it. Throttled sends fail, are retried by SQS and finish later, which is fine for an email. Set the worker’s concurrency from the SES rate on purpose, so retries do not push good messages into the DLQ.
At 100x, reputation is the real limit. One bad import of addresses can push bounces past 5% and put the account under review, or past 10% and pause every booking email. Defend with the suppression list, bounce and complaint events, an alarm on Reputation.BounceRate and Reputation.ComplaintRate, and only emailing addresses that signed up. Separate marketing from transactional email with different configuration sets, or a different subdomain, so marketing complaints cannot stop ticket confirmations.
Dedicated IP addresses and managed IP pools exist for very large senders. They need warming up and steady volume; shared IPs are the right default for us.
Common interview traps
Section titled “Common interview traps”- “SendEmail succeeded, so the email was delivered.” It was accepted. Delivery, bounce and complaint come later, and only events tell you.
- “Production access means we can email anyone.” It removes the recipient check. Bounce and complaint thresholds still apply, and a pause stops all sending.
- “SPF is enough.” With the default MAIL FROM, SPF passes for
amazonses.com, not your domain. DKIM is what aligns for DMARC here. - “Verify once, send from every Region.” Identities, sandbox status and quotas are per Region.
- “The worker’s retries make email exactly once.” We send first and record second. A crash in between sends twice. See the duplicate emails case.
Design question
Section titled “Design question”Prepare MiniTickets’ booking emails for real customers.
Request production access with a clear use case: one transactional email per booking, only to the account owner’s address. Add a configuration set as the domain’s default, with bounce, complaint, reject and delivery events to an SNS topic or EventBridge. A small consumer records each event against the MessageId the worker already logs. Keep account-level suppression on for bounces and complaints. Add a DMARC record, starting in monitoring mode. Alarm on reputation rates well below AWS’s review thresholds. Set the worker’s concurrency from the granted sending rate.
flowchart TB worker["Worker Lambda (exists)"] --> ses["SES with default configuration set (designed, not built)"] ses --> inbox["Recipient mail server"] inbox -->|"bounce or complaint"| ses ses -->|"event destination"| topic["SES events SNS (designed, not built)"] topic --> rec["Event recorder (designed, not built)"] rec --> alarm["Reputation alarm (designed, not built)"] class worker app class ses service class ses designed class inbox aws class topic aws class topic designed class rec app class rec designed class alarm security class alarm designedLegend: dashed = designed, not built; the worker and SES identity exist today.
Do not retry a hard bounce. Do not remove addresses from the suppression list to “fix” a customer report without checking why they were added.
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”How do you find out an email bounced?
What happens if the account gets paused?
Why the ses:FromAddress condition?
Why did booking emails from strangers end up in the DLQ?
Transactional and marketing email from one domain?
Remember it
Section titled “Remember it”- SES sends only from verified identities; ours is the domain, via 3 DKIM CNAMEs.
- Sandbox: verified recipients only, 200 per 24 hours, 1 per second, per Region.
- Accepted is not delivered; configuration-set events tell you what happened.
- Bounce 5% / complaint 0.1% means review; 10% / 0.5% can pause all sending.
- Suppressed addresses are accepted, not sent, and still count toward quota.
Advanced
Section titled “Advanced”Advanced: Console, read-only CLI and the mailbox simulator
Console: SES → Account dashboard (sandbox status, quotas, enforcement status), Configuration → Identities → the domain (DKIM status, the 3 records), Configuration → Suppression list, Configuration → Configuration sets.
aws sesv2 get-account --region us-east-1aws sesv2 list-email-identities --region us-east-1aws sesv2 get-email-identity --email-identity "$DOMAIN" --region us-east-1 --query '{Verified:VerifiedForSendingStatus,Dkim:DkimAttributes.Status}'aws sesv2 list-configuration-sets --region us-east-1aws sesv2 list-suppressed-destinations --region us-east-1These only read. get-account shows ProductionAccessEnabled, SendQuota and EnforcementStatus. The suppression list holds customer addresses: do not paste its output into tickets or chat.
Mailbox simulator. SES provides test addresses such as success@simulator.amazonses.com and bounce@simulator.amazonses.com that produce a delivery or a hard bounce without hurting your reputation or needing a real mailbox. They work in the sandbox. Use them to test bounce handling. AWS: mailbox simulator
SES v1 versus v2. Our worker uses the v2 API (@aws-sdk/client-sesv2, SendEmail with Content.Simple). v2 allows 40 MB messages and has the account-level suppression APIs. IAM actions are named ses: for both.
Virtual Deliverability Manager is an optional paid SES feature that summarizes deliverability by mailbox provider and suggests fixes. Useful once real volume exists.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- What exactly does a successful
SendEmailcall prove? - Why does a booking by an unverified customer end in our DLQ today?
- Which DNS records prove our domain to receiving servers, and what breaks if they are proxied?
- At what bounce and complaint rates does AWS review or pause an account?
- What would you add first so that a bounced confirmation is visible to us?
Redraw it from memory: the jobs queue, worker Lambda, SES, Cloudflare DKIM records and the recipient server, with the permission on each arrow
Try this: open infra/src/jobs/worker-iam.ts and find the SendBookingEmails statement; explain why it names two identity ARNs and one condition
Read this book online: https://book.theversehub.in/