SES/SNS-Bounce-Benachrichtigungen stimmen nach Security-Hardening von TopicArn nicht mehr mit EmailLog überein

Ich habe versucht, Bounce-Benachrichtigungen von AWS SES einzurichten, nachdem nach dem letzten Upgrade eine Meldung über eine fehlende ARN-Konfiguration für diese Funktion angezeigt wurde. Ich habe das Topic konfiguriert und die ARN hinzugefügt, aber eine Test-Bounce-E-Mail kam nie an. Ich habe jedoch bestätigt, dass sie das Topic auf AWS erreicht hat. Nachfolgend findet sich eine Analyse und ein vorgeschlagener Patch von Codex (ungetestet).

Zusammenfassung

Amazon-SES-Bounce-Benachrichtigungen, die über SNS zugestellt werden, werden von /webhooks/aws akzeptiert, aber das aktuelle Discourse markiert die entsprechende EmailLog nicht als „bounced“, wenn SES über SMTP verwendet wird.

Jobs::ProcessSnsNotification sucht den Eintrag anhand von mail.messageId ab. AWS dokumentiert, dass dies eine von SES zugewiesene Kennung ist, während Email::Sender die ursprüngliche RFC-Message-ID von Discourse in EmailLog.message_id speichert. Die beiden Kennungen unterscheiden sich.

Dies scheint ein Regressionsfehler zu sein, der durch die Sicherheitsverstärkung im Juni 2026 im Commit 61f12e13aa1b760f81d5ff60f12e3a7e77434b94 eingeführt wurde. Die Topic-Allowlist, die Signaturverifizierung, die Empfängerbindung und der Schutz vor Duplikaten sollten unverändert bleiben; nur die für die Suche verwendete Kennung muss geändert werden.

Umgebung

  • Discourse-Commit: 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • Amazon-SES-SMTP-Versand
  • SES-Bounce-Benachrichtigungstopic, das über SNS an /webhooks/aws zugestellt wird
  • aws_sns_topic_arn_allowlist enthält die genaue SNS-Topic-ARN
  • SNS-Abonnementsbestätigung liegt vor

Schritte zur Reproduktion

  1. Konfiguriere Discourse so, dass es über den Amazon-SES-SMTP-Endpunkt sendet.
  2. Konfiguriere ein SES-Bounce-Benachrichtigungstopic, einschließlich der Original-Header.
  3. Abonniere https://<discourse-host>/webhooks/aws für das Topic.
  4. Füge die ARN dieses Topics zu aws_sns_topic_arn_allowlist hinzu.
  5. Sende eine Discourse-E-Mail an bounce@simulator.amazonses.com.
  6. Stelle sicher, dass SES den Bounce veröffentlicht und SNS eine erfolgreiche HTTPS-Zustellung meldet.
  7. Öffne /admin/email-logs/bounced.

Tatsächliches Ergebnis

Der Webhook gibt Erfolg zurück und SNS meldet keine fehlgeschlagene Zustellung, aber die E-Mail fehlt im Protokoll der abgeprallten E-Mails und ihr Discourse-Bounce-Status wird nicht aktualisiert.

Erwartetes Ergebnis

Discourse sollte den SES-Bounce mit dem gesendeten EmailLog anhand der ursprünglichen RFC-Message-ID und des abgeprallten Empfängers abgleichen und anschließend den Bounce-Status und die Punktzahl aktualisieren.

Ursache

Discourse speichert die vor der Zustellung generierte Message-ID:

email_log.message_id = @message.message_id

Der SNS-Job verwendet derzeit die von SES zugewiesene ID:

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

AWS unterscheidet diese Felder:

  • mail.messageId wird von SES zugewiesen.
  • Die Message-ID der ursprünglichen E-Mail ist in mail.headers und mail.commonHeaders verfügbar, wenn Original-Header aktiviert sind.

Die aktuelle Request-Spezifikation weist mail.messageId, die Message-ID in mail.headers, mail.commonHeaders.messageId und EmailLog.message_id denselben Fixture-Wert zu, sodass sie das tatsächliche Verhalten von SES nicht reproduziert.

Es gibt eine direkte Projekthistorie für diese Diskrepanz: PR #7284 entfernte 2019 das strikte ID-Matching, weil die SNS-ID nicht der ID in EmailLog entsprach. Die Sicherheitskorrektur im Juni 2026 stellte das strikte Matching wieder her, verwendete aber die von SES zugewiesene ID.

Vorschlag zur Behebung

Verwende bevorzugt mail.commonHeaders.messageId, normalisiere sie mit Email::MessageIdService.message_id_clean und weiche auf mail.messageId aus, wenn Original-Header nicht verfügbar sind (aus Kompatibilitätsgründen). Behalte die bestehende (message_id, to_address)-Suche, die TopicArn-Allowlist, die SNS-Signaturverifizierung und die Duplikatbehandlung bei.

Der angehängte Patch ändert auch das Request-Fixture, sodass sich die von SES zugewiesene ID von der ursprünglichen RFC-Message-ID unterscheidet.

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)

Referenzen