# SES/SNS 退信通知在 TopicArn 安全加固后与 EmailLog 不再匹配

**URL:** <https://meta.discourse.org/t/ses-sns-bounce-notifications-no-longer-match-emaillog-after-topicarn-security-hardening/411536>\
**Category:** Bug\
**Tags:** email\
**Created:** [2026年九月2日 23:13 UTC](https://meta.discourse.org/t/ses-sns-bounce-notifications-no-longer-match-emaillog-after-topicarn-security-hardening/411536 "2026-09-02T23:13:42Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![BryanV](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/bryanv/32/226479_2.png) [@BryanV](https://meta.discourse.org/u/BryanV)\
**Post date:** [2026年九月2日 23:13 UTC](https://meta.discourse.org/t/ses-sns-bounce-notifications-no-longer-match-emaillog-after-topicarn-security-hardening/411536/1 "2026-09-02T23:13:42Z")

</div>

我在尝试设置来自 AWS SES 的退信通知。在最新升级后，系统提示缺少相关的 ARN 配置。我配置了主题（Topic）并添加了 ARN，但测试退信邮件始终未出现。不过，我已确认该消息确实到达了 AWS 上的主题。以下是 Codex 提供的分析和补丁建议（尚未经过测试）。

# 摘要

通过 SNS 传递的 Amazon SES 退信通知被 `/webhooks/aws` 接受，但当前的 Discourse 在使用 SES 通过 SMTP 发送时，不会将对应的 `EmailLog` 标记为退信状态。

`Jobs::ProcessSnsNotification` 使用 `mail.messageId` 查找日志。AWS 文档指出，这是 SES 分配的标识符，而 `Email::Sender` 在 `EmailLog.message_id` 中存储的是 Discourse 原始的 RFC `Message-ID`。这两个标识符是不同的。

这似乎是由 2026 年 6 月的安全加固提交 `61f12e13aa1b760f81d5ff60f12e3a7e77434b94` 引入的回归问题。主题允许列表、签名验证、收件人绑定和重复保护应保持完整；只需更改用于查找的标识符即可。

## 环境

- Discourse 提交：`2239124ce41df4ea23a21686a78342adb5f6b3b5`
- 使用 Amazon SES SMTP 发送
- SES 退信通知主题通过 SNS 传递到 `/webhooks/aws`
- `aws_sns_topic_arn_allowlist` 包含确切的 SNS 主题 ARN
- 已确认 SNS 订阅

## 复现步骤

1. 配置 Discourse 通过 Amazon SES SMTP 端点发送邮件。
2. 配置 SES 退信通知主题，并包含原始标头。
3. 将 `https://<discourse-host>/webhooks/aws` 订阅到该主题。
4. 将该主题 ARN 添加到 `aws_sns_topic_arn_allowlist`。
5. 向 `bounce@simulator.amazonses.com` 发送一封 Discourse 邮件。
6. 确认 SES 发布了退信，并且 SNS 报告 HTTPS 传递成功。
7. 打开 `/admin/email-logs/bounced`。

## 实际结果

Webhook 返回成功，SNS 报告没有传递失败，但该邮件未出现在退信日志中，且其 Discourse 退信状态未更新。

## 预期结果

Discourse 应使用原始 RFC `Message-ID` 和退信收件人将 SES 退信与已发送的 `EmailLog` 匹配，然后更新退信状态和分数。

## 原因

Discourse 存储的是投递前的消息 ID：

```ruby
email_log.message_id = @message.message_id

```

SNS 任务当前使用的是 SES 分配的 ID：

```ruby
message_id = message.dig("mail", "messageId")

```

AWS 区分这些字段：

- `mail.messageId` 是由 SES 分配的。
- 当启用原始标头时，原始邮件的 `Message-ID` 可以在 `mail.headers` 和 `mail.commonHeaders` 中找到。

当前的请求规范（request spec）将 `mail.messageId`、`mail.headers` 中的 Message-ID、`mail.commonHeaders.messageId` 和 `EmailLog.message_id` 设置为相同的测试数据值，因此无法重现实际的 SES 行为。

项目历史中直接存在这种不匹配的记录：PR #7284 在 2019 年移除了严格的 ID 匹配，因为 SNS ID 不等于 `EmailLog` 中的 ID。2026 年 6 月的安全修复恢复了严格匹配，但使用了 SES 分配的 ID。

## 建议的修复方案

优先使用 `mail.commonHeaders.messageId`，使用 `Email::MessageIdService.message_id_clean` 对其进行规范化，并在原始标头不可用时回退到 `mail.messageId` 以兼容旧行为。保留现有的 `(message_id, to_address)` 查找、TopicArn 允许列表、SNS 签名验证和重复处理。

附带的补丁还修改了请求测试数据，使 SES 分配的 ID 与原始 RFC Message-ID 不同。

```diff
diff --git a/app/jobs/regular/process_sns_notification.rb b/app/jobs/regular/process_sns_notification.rb
index 2785887d..21ebb1a2 100644
--- a/app/jobs/regular/process_sns_notification.rb
+++ b/app/jobs/regular/process_sns_notification.rb
@@ -17,7 +17,13 @@ module Jobs
         end
 
       return unless message && message["notificationType"] == "Bounce"
- return unless message_id = message.dig("mail", "messageId").presence
+ message_id =
+ message.dig("mail", "commonHeaders", "messageId").presence ||
+ message.dig("mail", "messageId").presence
+ return unless message_id
+
+ message_id = Email::MessageIdService.message_id_clean(message_id.strip)
+
       return unless bounce_type = message.dig("bounce", "bounceType").presence
 
       return if !Email::Sns.allowed_topic_arn?(json["TopicArn"])
diff --git a/spec/requests/webhooks_controller_spec.rb b/spec/requests/webhooks_controller_spec.rb
index eb7f5b44..8c77b337 100644
--- a/spec/requests/webhooks_controller_spec.rb
+++ b/spec/requests/webhooks_controller_spec.rb
@@ -794,6 +794,7 @@ RSpec.describe WebhooksController do
     let(:topic_arn) { "arn:aws:sns:us-east-1:123456789012:discourse-bounces" }
     let(:other_topic_arn) { "arn:aws:sns:us-east-1:999999999999:attacker-topic" }
     let(:bounce_status) { "5.1.1" }
+ let(:ses_message_id) { "000001378603177f-7a5433e7-8edb-42ae-af10-f0181f34d6ee-000000" }
     let(:payload) do
       {
         "Type" => "Notification",
@@ -823,7 +824,7 @@ RSpec.describe WebhooksController do
             "sourceIp" => "127.0.3.0",
             "sendingAccountId" => "123456789012",
             "callerIdentity" => "IAM_user_or_role_name",
- "messageId" => message_id,
+ "messageId" => ses_message_id,
             "destination" => [email, "jane@example.com", "mary@example.com", "richard@example.com"],
             "headersTruncated" => false,
             "headers" => [
@@ -833,7 +834,7 @@ RSpec.describe WebhooksController do
                 "value" =>
                   "\"Test\" <#{email}>, \"Jane Doe\" <jane@example.com>, \"Mary Doe\" <mary@example.com>, \"Richard Doe\" <richard@example.com>",
               },
- { "name" => "Message-ID", "value" => message_id },
+ { "name" => "Message-ID", "value" => "<#{message_id}>" },
               { "name" => "Subject", "value" => "Hello" },
               { "name" => "Content-Type", "value" => "text/plain; charset=\"UTF-8\"" },
               { "name" => "Content-Transfer-Encoding", "value" => "base64" },
@@ -845,7 +846,7 @@ RSpec.describe WebhooksController do
               "to" => [
                 "\"Test\" <#{email}>, Jane Doe <jane@example.com>, Mary Doe <mary@example.com>, Richard Doe <richard@example.com>",
               ],
- "messageId" => message_id,
+ "messageId" => "<#{message_id}>",
               "subject" => "Hello",
             },
           },
@@ -870,7 +871,7 @@ RSpec.describe WebhooksController do
       SiteSetting.aws_sns_topic_arn_allowlist = topic_arn
     end
 
- it "hard bounces" do
+ it "hard bounces using the original message ID" do
       user = Fabricate(:user, email: email)
       email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email)
 
@@ -883,7 +884,7 @@ RSpec.describe WebhooksController do
       expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score)
     end
 
- it "does not bounce an email log with a different SES message id" do
+ it "does not bounce an email log with a different original message ID" do
       user = Fabricate(:user, email: email)
       email_log =
         Fabricate(:email_log, user: user, message_id: "other-message-id", to_address: email)

```

## 参考

- AWS 通知字段定义：[Amazon SNS notification contents for Amazon SES - Amazon Simple Email Service](https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html)
- 原始 Discourse 修复，PR #7284：[FIX: Detect SNS notifications for SES correctly - Pull Request #7284 - discourse/discourse - GitHub](https://github.com/discourse/discourse/pull/7284)
- 安全加固提交：[SECURITY: Prevent any signed AWS SNS TopicARN from being accepted via… · discourse/discourse@61f12e1 · GitHub](https://github.com/discourse/discourse/commit/61f12e13aa1b760f81d5ff60f12e3a7e77434b94)
- 现有的 Meta 报告：[Bounced e-mails from Amazon SES/SNS not working](https://meta.discourse.org/t/bounced-e-mails-from-amazon-ses-sns-not-working/200209)

---

<div class="post-metadata">

**Author:** ![Sailor](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/sailor/32/579029_2.png) [@Sailor](https://meta.discourse.org/u/Sailor)\
**Post date:** [2026年九月22日 19:53 UTC](https://meta.discourse.org/t/ses-sns-bounce-notifications-no-longer-match-emaillog-after-topicarn-security-hardening/411536/2 "2026-09-22T19:53:53Z")

</div>

在自托管环境中复现了该问题，运行版本为 v2026.7.3（release/2026.7），SES SMTP 位于 us-east-1，使用嵌入式 PostgreSQL 18。

**配置详情**

- `aws_sns_topic_arn_allowlist` 包含准确的 Topic ARN（已与 SNS 控制台核对）。
- 已确认 SNS HTTPS 订阅指向 `/webhooks/aws`；同一订阅在 2026 年 3 月至 6 月期间，在 release/2026.4 和 2026.5.0 版本上均能正确处理退信（bounces）。

**观察结果**  
向 `bounce@simulator.amazonses.com` 发送了两条测试消息。每条消息都生成了一次到达容器的 SNS POST 请求：

```plaintext
"POST /webhooks/aws HTTP/1.1" "Amazon Simple Notification Service Agent" 200 402

```

`/logs` 中没有任何记录，`/admin/email-logs/bounced` 下也没有内容。由于 `WebhooksController#aws` 在白名单或签名验证失败时会返回 406，因此返回 200 表明这两项检查均已通过，且 `Jobs::ProcessSnsNotification` 已被入队；随后该任务在 `next if email_log.nil?` 处退出，因为 `mail.messageId`（由 SES 分配）从未等于 `EmailLog.message_id`（Discourse 自身的 `Message-ID`，在 `Email::Sender` 中通过 `email_log.message_id = @message.message_id` 设置）。

Git 历史记录也印证了上述分析：#7284（2019 年）正是出于此原因移除了 ID 匹配逻辑，而 61f12e1（2026 年 6 月）又使用 SES 分配的 ID 重新引入了该逻辑。在 release/2026.7 中，自该提交以来任务代码未再变更。

对于当前 ESR 版本上的 SES 自托管用户而言，其净效应是：退信处理被静默禁用，而新的仪表板提示反而将管理员引导至一个最终毫无作用的配置。一旦修复方案落地，欢迎将其回移（backport）至 release/2026.7。
