---
title: Receive Email with Amazon SES, S3, and Lambda | SendHQ
description: Build an Amazon SES inbound email pipeline with S3 and Lambda. Handle MX routing, MIME parsing, tenant isolation, retries, attachments, and threading safely.
canonical: https://sendhq.cc/blog/receive-email-amazon-ses-s3-lambda
last-updated: 2026-09-22
---
# How to Receive Email with Amazon SES: S3 and Lambda

Build a production-ready inbound email pipeline with Amazon SES, S3, and Lambda, including MIME safety, tenant routing, idempotency, retries, and threading.

The shortest reliable Amazon SES inbound path is: verify a domain, point an MX record at an SES receiving endpoint, save every accepted message to S3, then invoke Lambda asynchronously to parse and persist it. Put the S3 action before the Lambda action. Treat the SES message ID as an idempotency key, use the SMTP envelope recipient for routing, and quarantine unsafe content instead of trusting headers or attachments.

## The architecture to build

Use a dedicated subdomain such as `inbound.example.com` unless SES should receive all mail for your root domain. That keeps application mail separate from employee inboxes and makes rollback a DNS change instead of a mail migration.

The production flow is:

`sender -> SES inbound SMTP endpoint -> active SES receipt rule -> S3 raw-message object -> asynchronous Lambda action -> MIME parser and policy checks -> application database and private attachment storage`

Amazon SES receipt rules execute their actions in order. AWS specifically documents the S3-first, Lambda-second pattern when code needs the message body. A direct Lambda action receives metadata and selected headers, not the complete body. The body remains the raw MIME object in S3 ([AWS SES receiving concepts](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-concepts.html)).

This separation is useful. The SMTP-facing step stores the original message quickly, while parsing, indexing, notifications, and business logic happen after acceptance. A temporary database failure should not require a sender's mail server to repeat the SMTP transaction.

## 1. Choose a supported Region and verify the domain

SES email receiving is available only in selected AWS Regions. Pick one from the current [SES receiving endpoint list](https://docs.aws.amazon.com/ses/latest/dg/regions.html), then keep SES, Lambda, SNS, and KMS resources in that Region unless the relevant AWS documentation explicitly permits otherwise.

Create an SES domain identity for the exact root or subdomain that will receive mail. Domain verification requires publishing the DNS records SES provides. Verification for receiving proves control of the domain; it is separate from configuring the MX route that sends inbound traffic to SES ([AWS domain verification guide](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-verification.html)).

For a dedicated subdomain, the DNS records look conceptually like this:

`inbound.example.com. MX 10 inbound-smtp.us-east-1.amazonaws.com.`

Replace `us-east-1` with the Region you selected. AWS documents the MX value as `10 inbound-smtp.<region>.amazonaws.com` ([AWS MX record guide](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-mx-record.html)). Do not point your root domain at SES if people still receive mail there through Google Workspace, Microsoft 365, or another mailbox provider.

Verify the published record from more than one resolver before testing:

`dig MX inbound.example.com +short`

DNS visibility proves only that the route is published. Send a controlled message to a test address and confirm that SES stored it before calling the setup complete.

## 2. Store the raw message before processing it

Create a private S3 bucket with public access blocked, a lifecycle policy, and the narrowest practical IAM permissions. Then create an SES receipt rule whose recipient condition matches your inbound domain or specific addresses.

The first action should deliver the raw message to S3. An object prefix such as `inbound/` makes retention rules and access policies easier to scope. SES stores raw, unmodified MIME content. AWS currently documents a default 40 MB maximum when messages are saved to S3, while the SNS action that includes the complete message has a much smaller 150 KB maximum ([AWS S3 receipt action](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-s3.html)). That size difference is why S3 is the safer default for real-world replies and attachments.

If you enable the optional SES KMS setting on the receipt action, read the encryption details carefully. SES uses client-side encryption for that feature, not ordinary S3 server-side encryption, so your reader must decrypt the object with a compatible client. Do not turn it on casually and discover during an incident that your Node parser cannot read the stored bytes.

Give SES permission to write only to the intended bucket and prefix. Give Lambda `s3:GetObject` only for that same location. The function does not need bucket administration permissions.

## 3. Add an asynchronous Lambda action

Place the Lambda action after the S3 action in the receipt rule and use asynchronous invocation unless the function must decide whether SES should continue evaluating the rule. AWS recommends asynchronous execution for normal processing and reserves synchronous execution for mail-flow decisions ([AWS Lambda receipt action](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-lambda.html)).

The SES-assigned `mail.messageId` is also the S3 object key when no prefix is configured. With a prefix, prepend it. The following Node.js skeleton fetches the raw message and parses it. Package `@aws-sdk/client-s3` and a maintained MIME parser such as `mailparser` with the deployment artifact, and pin their versions.

``import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { simpleParser } from "mailparser"; const s3 = new S3Client({}); const bucket = process.env.INBOUND_BUCKET; const prefix = process.env.INBOUND_PREFIX || "inbound/"; export async function handler(event) { for (const record of event.Records || []) { const ses = record.ses; const messageId = ses?.mail?.messageId; const recipients = ses?.receipt?.recipients || []; if (!messageId || !/^[A-Za-z0-9._-]+$/.test(messageId)) { throw new Error("Missing or invalid SES message ID"); } // claimOnce must be an atomic insert with a unique constraint. if (!(await claimOnce(messageId))) continue; try { const object = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: `${prefix}${messageId}`, })); const raw = Buffer.from(await object.Body.transformToByteArray()); const parsed = await simpleParser(raw, { skipHtmlToText: true, skipTextToHtml: true, }); await saveInboundMessage({ providerMessageId: messageId, envelopeRecipients: recipients, envelopeFrom: ses.mail.source, headerMessageId: parsed.messageId || null, inReplyTo: parsed.inReplyTo || null, references: parsed.references || [], subject: parsed.subject || "", text: parsed.text || "", html: parsed.html || null, attachments: parsed.attachments, receivedAt: ses.mail.timestamp, }); await markComplete(messageId); } catch (error) { await releaseOrMarkFailed(messageId, String(error)); throw error; } } }``

The placeholder functions represent application-specific storage, but their contract matters. `claimOnce` must use a database uniqueness constraint or conditional write on the provider message ID. A read followed by an insert is racy. Store processing state so an operator can distinguish `processing`, `complete`, `quarantined`, and `failed`.

## Route with the envelope recipient, not the To header

The visible `To` and `Cc` fields are message content supplied by the sender. They can omit the actual destination because of BCC, forwarding, or deliberate manipulation. SES receipt conditions use SMTP envelope recipients, and AWS tells downstream processors to use the recipients from the SES notification when deciding where the message was delivered ([AWS receiving concepts](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-concepts.html)).

That distinction prevents a cross-tenant bug. If `reply+tenant-a@inbound.example.com` receives a message whose visible `To` says `tenant-b@example.com`, route it using the authenticated application mapping for the envelope address, never the display header.

Use a random, unguessable reply token when an address identifies a customer or conversation. Hash the token at rest, expire it when appropriate, and reject addresses that do not map to an active workspace. A predictable local part such as `ticket-42` is an invitation to inject messages into another user's thread.

## Parse MIME as hostile input

Email is a nested, decades-old input format. RFC 5322 defines message headers and bodies, while MIME adds multipart content and transfer encodings ([RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322), [RFC 2045](https://datatracker.ietf.org/doc/html/rfc2045)). Use a maintained parser instead of splitting on blank lines or boundaries yourself.

Apply limits before making content available to the product:

- Cap total decoded bytes, attachment count, individual attachment size, MIME nesting depth, and parsing time.
- Store attachments privately with generated object names. Never use the sender's filename as a path.
- Treat declared content type and filename as hints. Detect type from content where possible.
- Never execute attachment content. Scan or quarantine attachments before download.
- Sanitize HTML with a strict allowlist, block remote images by default, and render it in an isolated context. Prefer plain text for automated analysis.
- Do not place raw bodies, addresses, tokens, or attachment content in ordinary application logs.

SES can report SPF, DKIM, DMARC, spam, and virus verdicts, but AWS notes that SES exposes these results rather than automatically applying your business policy. Decide whether failures should be rejected, quarantined, or displayed with a warning. An authentication pass identifies a domain under a specific mechanism; it does not make the content safe or prove that a human authored it.

## Make retries boring

Asynchronous Lambda invocation can retry failed functions, and AWS warns that duplicate delivery is possible even when the function does not return an error. Configure an on-failure destination or dead-letter queue and alarm on processing failures ([AWS Lambda retry behavior](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-error-handling.html)).

Idempotency should cover every downstream side effect:

1. Insert the SES message ID under a unique constraint.
2. Persist parsed content and thread links in one transaction where possible.
3. Put notifications, ticket creation, or agent work on an outbox keyed by message ID plus action type.
4. Mark the record complete only after durable writes succeed.
5. Reprocess from the original S3 object, not a lossy log entry.

If you trigger processing from S3 notifications instead of an SES Lambda action, the same rule applies. Amazon S3 notifications are designed for at-least-once delivery and are not guaranteed to arrive in order ([AWS S3 event notifications](https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-how-to-event-types-and-destinations.html)).

## Thread messages without trusting the subject

Use the parsed `Message-ID`, `In-Reply-To`, and `References` fields to propose a thread match. Do not thread solely on a subject beginning with `Re:`. Also confirm that the envelope address or reply token belongs to the same workspace and conversation before linking anything.

Automatic replies need a separate policy. Detect signals such as `Auto-Submitted` and avoid generating reply loops. RFC 3834 recommends clear identification and conservative behavior for automatic responses ([RFC 3834](https://datatracker.ietf.org/doc/html/rfc3834)). If an AI agent drafts a response, keep sending as an explicit, idempotent side effect. Require user approval for unexpected recipients, sensitive content, or actions outside the original support or product workflow. Receiving a message is not blanket consent for unrelated marketing.

## Production checklist

- Receiving Region supports SES inbound email.
- Domain identity is verified and the MX record resolves correctly.
- Receipt rule has a narrow recipient condition and the intended rule set is active.
- S3 action runs before asynchronous Lambda processing.
- Bucket is private, access is least-privilege, and retention is documented.
- SES message ID has a database uniqueness constraint.
- Routing uses envelope recipients, not visible `To` or `Cc` headers.
- MIME, HTML, links, and attachments are treated as untrusted input.
- Failed events reach a monitored destination and can be replayed.
- Thread matches enforce workspace ownership.
- Automated replies have loop prevention, consent boundaries, and send idempotency.
- A controlled test covers plain text, HTML, BCC, duplicate delivery, large attachments, malformed MIME, and parser failure.

Direct SES is a good fit when your team wants AWS-native control and is prepared to own DNS, IAM, MIME parsing, tenant isolation, retention, retry handling, and operational alerts. If you want those application primitives behind a narrower email API, [SendHQ](https://sendhq.cc/index.md) provides inbound addresses, retained messages, threads, and workspace-scoped access alongside outbound transactional email. Either way, keep the raw message recoverable and make every downstream action replay-safe.

## Keep reading

- [Amazon SES Pricing Tiers Explained (July 2026)](https://sendhq.cc/blog/amazon-ses-pricing-tiers-2026): Amazon SES shifted from a simple a la carte model to tiered plans (Essentials, Pro, Enterprise) on July 21, 2026. Here is the cost breakdown Read article →
- [DMARC p=none vs Quarantine vs Reject: An Operator's Guide](https://sendhq.cc/blog/dmarc-policy-p-none-quarantine-reject): Choosing the right DMARC policy is a balancing act between security and deliverability. Learn the safe rollout path from p=none to p=reject Read article →
- [Idempotency Keys for Email APIs](https://sendhq.cc/blog/idempotency-keys-email-api): Prevent duplicate emails during network retries by implementing idempotency keys. Learn how to handle distributed system failures without sp Read article →
