Skip to content

SES

11 min read · main part only; folded sections are optional

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?

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 aws

Legend: 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 →

Follow one confirmation email.

  1. The worker gets a booking.confirmed message from the jobs queue. It checks the processed-jobs table: if booking.confirmed:<bookingId> is recorded, it skips.
  2. It calls SendEmail (SES API v2) with FromEmailAddress = no-reply@<domain>, one To address, and a plain-text subject and body. The worker role’s policy allows ses:SendEmail only on our identity ARNs and only when ses:FromAddress equals that sender.
  3. 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.
  4. SES accepts and returns a MessageId. The worker logs it with the recipient’s domain only, then records the job as done.
  5. 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.
  6. 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 complaint

Legend: arrows show order. Everything after “MessageId” happens after the worker has finished; our code never sees it today.

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:

CheckWhat it provesOurs
DKIMA signature in the message header matches a public key in the sender domain’s DNSEasy DKIM: SES makes and rotates 2,048-bit keys; we add 3 CNAMEs
SPFThe sending server’s IP is allowed for the envelope sender (the bounce address) domainDefault: the envelope sender is an amazonses.com address, so SPF passes for that domain, not ours
DMARCDKIM or SPF passes and matches the visible From domain; the domain’s policy says what to do if notDKIM 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:

RateUnder reviewMay 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

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.

ItemValueNote
Sandbox daily quota200 messages per 24 hoursRolling window
Sandbox sending rate1 message per secondOur worker can exceed it (below)
Production quotasSet per account from your use case; adjustableRead with get-account
Message size (API v2 or SMTP)40 MB after encodingOver 10 MB is bandwidth-throttled
Recipients per message50 (To + CC + BCC)We send to one
Verified identities per Region10,000
Event destinations per configuration set10, fixed
Other SES API actions1 request per second, fixedIdentity and config calls, not sends
Our sandbox readingProductionAccessEnabled false, 200 per 24 h, 1 per secondRecorded in the infra/src/jobs/email.ts comment from an earlier get-account; not re-read now
Our workerBatch 10, maximumConcurrency 5, timeout 10 s, 3 tries then DLQqueue-trigger.ts, worker-lambda.ts, queues.ts

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.

From → toWhat happensWhat allows it
SES email identities → Public DNSreceiving mail servers check the DKIM signature against our DNS
  • dns 3 CNAMEs <token>._domainkey.minitickets.theversehub.in → <token>.dkim.amazonses.com (stack output sesDkimRecords)createEmail
Worker Lambda → SES email identitiesonly when computeEnabled = trueSendEmail from no-reply@minitickets.theversehub.in
  • iam worker role SendBookingEmails: ses:SendEmail on our identity ARNs, condition ses:FromAddress = no-reply@minitickets.theversehub.increateWorkerRole
  • config env MAILER ses and MAIL_FROM no-reply@minitickets.theversehub.increateWorkerLambda
ConnectionWhat allows itBoundary to keep
Worker → SESWorker role ses:SendEmail on our identity ARNs, condition ses:FromAddressCannot send as any other address, even with stolen credentials
SES → recipientVerified domain identity; in the sandbox, a verified recipientProduction access removes the recipient check, not the identity check
Recipient server → our DNSPublic DKIM CNAMEs <token>._domainkey.<domain> → <token>.dkim.amazonses.comA 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 ruleConsumer must be idempotent: events can repeat
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 aws

Legend: red = failure paths. The left path is loud (errors, DLQ). The right path is silent without a configuration set.

What you seeWhyFix
MessageRejected: Email address is not verifiedSandbox: the recipient is not a verified identityExpected in the sandbox. For real customers, request production access
AccessDenied naming an identity ARNWorker role lacks ses:SendEmail on that identity, or the From address differs from the conditionCompare MAIL_FROM with the policy condition and the identity list
Maximum sending rate exceeded or throttlingUp to 5 parallel batches can call SES at once; the sandbox allows 1 per secondCap the worker (reserved concurrency) or raise the quota; retries absorb short bursts
Identity stuck “Pending”, every send failsDKIM CNAMEs missing, mistyped, or proxied in CloudflareAdd the 3 records as DNS only; SES re-checks for up to 72 hours
Logs say “SES accepted”, customer says nothing arrivedBounce, spam folder or suppression, after acceptanceSearch 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, 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.

  • “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.

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 designed

Legend: 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.

How do you find out an email bounced?
Attach a configuration set with a bounce event destination (SNS, EventBridge, Firehose or CloudWatch) and match events to the MessageId you logged. Without it you only see acceptance.
What happens if the account gets paused?
Every send in that Region fails. Check the account’s enforcement status with get-account, fix the cause, and reply to the AWS support case. There is no EventBridge event for an account-level pause.
Why the ses:FromAddress condition?
Stolen worker credentials could otherwise send phishing from any address on our domain. The condition pins the From address to one value.
Why did booking emails from strangers end up in the DLQ?
Sandbox: SES rejects unverified recipients with MessageRejected. The worker treats that as a failure, so after 3 tries the message moves to the DLQ.
Transactional and marketing email from one domain?
Separate them with different configuration sets and ideally a subdomain, so complaints about newsletters cannot pause ticket confirmations.
  • 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: 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.

Terminal window
aws sesv2 get-account --region us-east-1
aws sesv2 list-email-identities --region us-east-1
aws 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-1
aws sesv2 list-suppressed-destinations --region us-east-1

These 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.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What exactly does a successful SendEmail call prove?
  2. Why does a booking by an unverified customer end in our DLQ today?
  3. Which DNS records prove our domain to receiving servers, and what breaks if they are proxied?
  4. At what bounce and complaint rates does AWS review or pause an account?
  5. 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/