入站邮件 · 2026 年 9 月 22 日
如何使用 Amazon SES 接收邮件:S3 与 Lambda
使用 Amazon SES、S3 和 Lambda 构建可用于生产环境的入站邮件管道,涵盖 MIME 安全、租户路由、幂等、重试和会话归并。
最简短且可靠的 Amazon SES 入站路径是:验证域名,将 MX 记录指向 SES 收件端点,把每封被接受的邮件保存到 S3,然后异步调用 Lambda 进行解析和持久化。S3 操作要放在 Lambda 操作之前。将 SES 邮件 ID 作为幂等键,使用 SMTP 信封收件人进行路由,并隔离不安全的内容,而不是信任邮件头或附件。
要构建的架构
除非需要让 SES 接收根域名下的所有邮件,否则请使用 inbound.example.com 这样的专用子域名。这样可以把应用邮件与员工邮箱分开,回滚也只需修改 DNS,而不必做邮件迁移。
生产环境中的流程如下:
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 收件规则会按顺序执行其中的操作。当代码需要读取邮件正文时,AWS 明确推荐“先 S3、后 Lambda”的模式。直接的 Lambda 操作只能收到元数据和部分邮件头,而拿不到完整正文。正文以原始 MIME 对象的形式保存在 S3 中(AWS SES 收件概念)。
这种分离很有用。面向 SMTP 的步骤可以快速保存原始邮件,而解析、索引、通知和业务逻辑都在受理之后进行。数据库的临时故障不应该迫使发件方的邮件服务器重做 SMTP 事务。
1. 选择受支持的区域并验证域名
SES 收件功能仅在部分 AWS 区域可用。请从当前的 SES 收件端点列表中选择一个区域,并将 SES、Lambda、SNS 和 KMS 资源都放在该区域,除非相关的 AWS 文档明确允许其他做法。
为将要收件的根域名或子域名创建一个完全对应的 SES 域名身份。域名验证需要发布 SES 提供的 DNS 记录。用于收件的验证只证明您控制该域名;它与配置将入站流量导向 SES 的 MX 路由是两回事(AWS 域名验证指南)。
对于专用子域名,DNS 记录在概念上是这样的:
inbound.example.com. MX 10 inbound-smtp.us-east-1.amazonaws.com.
将 us-east-1 替换为您选择的区域。AWS 文档给出的 MX 值为 10 inbound-smtp.<region>.amazonaws.com(AWS MX 记录指南)。如果员工仍通过 Google Workspace、Microsoft 365 或其他邮箱服务商在根域名上收信,请不要把根域名指向 SES。
测试之前,请从多个解析器验证已发布的记录:
dig MX inbound.example.com +short
DNS 可见只能证明路由已经发布。在宣布配置完成之前,请向测试地址发送一封受控的邮件,并确认 SES 已将其保存。
2. 先保存原始邮件,再进行处理
创建一个私有 S3 存储桶,阻止公共访问,配置生命周期策略,并授予尽可能小的 IAM 权限。然后创建一条 SES 收件规则,其收件人条件匹配您的入站域名或特定地址。
第一个操作应将原始邮件投递到 S3。使用 inbound/ 这样的对象前缀,可以更方便地限定保留规则和访问策略的范围。SES 存储的是原始、未经修改的 MIME 内容。AWS 目前的文档写明,邮件保存到 S3 时默认上限为 40 MB,而包含完整邮件的 SNS 操作上限要小得多,只有 150 KB(AWS S3 收件操作)。正因为这一大小差异,对于真实场景中的回复和附件,S3 是更安全的默认选择。
如果您在收件操作中启用了可选的 SES KMS 设置,请仔细阅读加密细节。该功能中 SES 使用的是客户端加密,而不是普通的 S3 服务端加密,因此读取方必须使用兼容的客户端来解密对象。不要随手开启它,然后在事故中才发现您的 Node 解析器读不了存储的字节。
只授予 SES 写入目标存储桶和前缀的权限。只为 Lambda 授予同一位置的 s3:GetObject 权限。该函数不需要存储桶管理权限。
3. 添加异步 Lambda 操作
在收件规则中,将 Lambda 操作放在 S3 操作之后,并使用异步调用,除非该函数必须决定 SES 是否继续评估规则。AWS 建议常规处理使用异步执行,同步执行只留给需要决定邮件流向的场景(AWS Lambda 收件操作)。
在未配置前缀时,SES 分配的 mail.messageId 同时也是 S3 对象键;配置了前缀时,在其前面加上前缀即可。下面的 Node.js 框架代码会获取原始邮件并进行解析。请将 @aws-sdk/client-s3 和一个持续维护的 MIME 解析器(如 mailparser)随部署产物一起打包,并锁定版本。
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;
}
}
}
这些占位函数代表特定于应用的存储逻辑,但它们的约定很重要。claimOnce 必须针对服务商邮件 ID 使用数据库唯一约束或条件写入。先读后插入会产生竞态。请保存处理状态,让运维人员能够区分 processing、complete、quarantined 和 failed。
按信封收件人路由,而不是 To 邮件头
可见的 To 和 Cc 字段是由发件方提供的邮件内容。由于 BCC、转发或蓄意篡改,它们可能并不包含真正的目标地址。SES 收件条件使用的是 SMTP 信封收件人,AWS 也要求下游处理程序在判断邮件投递到哪里时,使用 SES 通知中的收件人(AWS 收件概念)。
这一区分可以防止跨租户的 bug。如果 reply+tenant-a@inbound.example.com 收到一封可见 To 显示为 tenant-b@example.com 的邮件,请根据信封地址在应用中经过验证的映射关系来路由,绝不要依据显示的邮件头。
当某个地址用于标识客户或会话时,请使用随机、不可猜测的回复令牌。存储时对令牌做哈希,在适当时让其过期,并拒绝无法映射到活跃工作区的地址。像 ticket-42 这样可预测的本地部分,等于邀请别人向其他用户的会话中注入邮件。
把 MIME 当作恶意输入来解析
邮件是一种层层嵌套、已有数十年历史的输入格式。RFC 5322 定义了邮件头和正文,MIME 则增加了多部分内容和传输编码(RFC 5322、RFC 2045)。请使用持续维护的解析器,而不要自己按空行或边界符去切分。
在将内容提供给产品使用之前,先施加以下限制:
- 限制解码后的总字节数、附件数量、单个附件大小、MIME 嵌套深度和解析时间。
- 以私有方式存储附件,并使用生成的对象名称。绝不要把发件方提供的文件名当作路径。
- 声明的内容类型和文件名只能作为参考。尽可能根据内容本身检测类型。
- 绝不要执行附件内容。在下载之前先扫描或隔离附件。
- 使用严格的白名单清理 HTML,默认阻止远程图片,并在隔离的上下文中渲染。自动化分析优先使用纯文本。
- 不要把原始正文、地址、令牌或附件内容写入普通的应用日志。
SES 可以报告 SPF、DKIM、DMARC、垃圾邮件和病毒检测结果,但 AWS 指出,SES 只是提供这些结果,并不会自动应用您的业务策略。您需要决定失败的邮件是拒收、隔离,还是带警告显示。身份验证通过只说明在某种特定机制下识别出了一个域名,并不代表内容是安全的,也不能证明它出自真人之手。
让重试变得平淡无奇
异步 Lambda 调用会对失败的函数进行重试,而且 AWS 警告说,即使函数没有返回错误,也可能发生重复投递。请配置失败目标或死信队列,并为处理失败设置告警(AWS Lambda 重试行为)。
幂等应覆盖每一个下游副作用:
- 在唯一约束下插入 SES 邮件 ID。
- 尽可能在一个事务中持久化解析后的内容和会话关联。
- 将通知、工单创建或智能体任务放入一个 outbox,以邮件 ID 加操作类型作为键。
- 只有在持久化写入成功之后,才将记录标记为完成。
- 从原始 S3 对象重新处理,而不是从有信息损失的日志条目重新处理。
如果您是通过 S3 通知而不是 SES Lambda 操作来触发处理,同样的规则也适用。Amazon S3 通知按至少一次投递设计,且不保证按顺序到达(AWS S3 事件通知)。
不依赖主题进行会话归并
使用解析出的 Message-ID、In-Reply-To 和 References 字段来提出会话匹配建议。不要仅凭以 Re: 开头的主题来归并会话。在建立任何关联之前,还要确认信封地址或回复令牌属于同一个工作区和会话。
自动回复需要单独的策略。检测 Auto-Submitted 等信号,避免产生回复循环。RFC 3834 建议自动回复应清晰标识并采取保守的行为(RFC 3834)。如果由 AI 智能体起草回复,请将发送保持为一个显式、幂等的副作用。对于意料之外的收件人、敏感内容,或超出原有支持或产品流程的操作,要求用户审批。收到一封邮件,并不代表对方同意接收无关的营销邮件。
生产环境检查清单
- 收信区域支持 SES 入站邮件。
- 域名身份已验证,且 MX 记录解析正确。
- 接收规则的收件人条件范围很窄,且预期规则集处于活动状态。
- S3 操作在异步 Lambda 处理之前运行。
- 存储桶为私有,访问遵循最小权限原则,且保留策略已记录。
- SES 邮件 ID 具有数据库唯一性约束。
- 路由使用信封收件人,而非可见的
To或Cc邮件头。 - MIME、HTML、链接和附件均视为不受信任的输入。
- 失败事件会到达受监控的目标,且可以重放。
- 会话匹配会强制执行工作区归属。
- 自动回复具备循环防护、同意边界和发送幂等性。
- 受控测试涵盖纯文本、HTML、BCC、重复投递、大型附件、格式错误的 MIME 以及解析器失败。
如果您的团队希望获得 AWS 原生的控制能力,并准备好自行负责 DNS、IAM、MIME 解析、租户隔离、数据保留、重试处理和运维告警,那么直接使用 SES 是不错的选择。如果您希望通过一个更精简的邮件 API 来使用这些应用级能力,SendHQ 在提供出站事务性邮件的同时,还提供入站地址、邮件留存、会话和按工作区限定的访问权限。无论选择哪种方式,都要确保原始邮件可以恢复,并让每一个下游操作都能安全重放。