SES/SNS のバウンス通知が、TopicArn のセキュリティ強化後に EmailLog と一致しなくなった

最新のアップグレード後、ARN設定が不足しているというプロンプトが表示されたため、AWS SESからのバウンス通知を設定しようとしていました。トピックを設定しARNを追加しましたが、テスト用のバウンスメールが表示されませんでした。ただし、AWSでトピックに届いたことは確認しています。以下は、Codexによる分析と提案されたパッチです(未テスト)。

サマリー

SNS経由で配信されるAmazon SESのバウンス通知は /webhooks/aws によって受け入れられますが、現在のDiscourseでは、SESがSMTP経由で使用されている場合、対応する EmailLog がバウンスとしてマークされません。

Jobs::ProcessSnsNotificationmail.messageId を使ってログを検索します。AWSのドキュメントによると、これはSESによって割り当てられた識別子であり、Email::SenderEmailLog.message_id にDiscourseの元のRFC Message-ID を保存します。これらの2つの識別子は異なります。

これは、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 で利用可能です。

現在のリクエスト仕様では、mail.messageIdmail.headers のMessage-ID、mail.commonHeaders.messageId、および EmailLog.message_id に同じフィクスチャ値が与えられているため、実際のSESの動作を再現していません。

この不一致に関する直接のプロジェクト履歴があります: PR #7284 は2019年に、SNSのIDが EmailLog のIDと等しくなかったため、厳密な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)

参考