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

1 „Gefällt mir“

Reproduziert auf einer Self-Hosted-Installation mit v2026.7.3 (release/2026.7), SES SMTP in us-east-1 und eingebettetem PostgreSQL 18.

Setup

  • aws_sns_topic_arn_allowlist enthält die exakte Topic-ARN (im SNS-Konsolenscreen verifiziert).
  • Die HTTPS-Abonnierung von SNS für /webhooks/aws wurde bestätigt; dasselbe Abo verarbeitete Bounces korrekt von März bis Juni 2026 auf release/2026.4 und 2026.5.0.

Beobachtung
Zwei Testnachrichten an bounce@simulator.amazonses.com. Jede erzeugte einen einzelnen SNS-POST, der den Container erreichte:

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

Nichts unter /logs, nichts unter /admin/email-logs/bounced. Da WebhooksController#aws bei einem Allowlist- oder Signaturfehler 406 zurückgibt, bestätigt der 200-Status, dass beide Prüfungen bestanden werden und Jobs::ProcessSnsNotification in die Warteschlange gestellt wird; der Job bricht dann bei next if email_log.nil? ab, da mail.messageId (von SES zugewiesen) niemals mit EmailLog.message_id (die eigene Message-ID von Discourse, gesetzt in Email::Sender bei email_log.message_id = @message.message_id) übereinstimmt.

Die Git-Historie stimmt mit der obigen Analyse überein: #7284 (2019) entfernte die ID-Abgleichslogik aus genau diesem Grund, und 61f12e1 (Juni 2026) stellte sie unter Verwendung der von SES zugewiesenen ID wieder her. Auf release/2026.7 ist der Job seit diesem Commit unverändert.

Nettoeffekt für SES-Self-Hoster auf der aktuellen ESR: Die Bounce-Verarbeitung wird stillschweigend deaktiviert, und die neue Dashboard-Mitteilung lenkt Admins in eine Konfiguration, die dann nichts bewirkt. Ein Backport auf release/2026.7 wäre willkommen, sobald ein Fix vorliegt.

2 „Gefällt mir“