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

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

摘要

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

Jobs::ProcessSnsNotification 使用 mail.messageId 查找日志。AWS 文档指出,这是 SES 分配的标识符,而 Email::SenderEmailLog.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:

email_log.message_id = @message.message_id

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

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

AWS 区分这些字段:

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

当前的请求规范(request spec)将 mail.messageIdmail.headers 中的 Message-ID、mail.commonHeaders.messageIdEmailLog.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 --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)

参考