SES/SNS bounce notifications no longer match EmailLog after TopicArn security hardening

I was trying to set up bounce notifications from AWS SES after getting a prompt about a missing ARN configuration for it after the latest upgrade. I configured the topic and added the ARN but a test bounce email never showed up. Confirmed that it hit the topic on AWS though. Below is an analysis and proposed patch from Codex (untested).

Summary

Amazon SES bounce notifications delivered through SNS are accepted by /webhooks/aws, but current Discourse does not mark the corresponding EmailLog as bounced when SES is used through SMTP.

Jobs::ProcessSnsNotification looks up the log using mail.messageId. AWS documents that this is an SES-assigned identifier, while Email::Sender stores Discourse’s original RFC Message-ID in EmailLog.message_id. The two identifiers differ.

This appears to be a regression introduced by the June 2026 security hardening in commit 61f12e13aa1b760f81d5ff60f12e3a7e77434b94. The topic allowlist, signature verification, recipient binding, and duplicate protection should remain intact; only the identifier used for the lookup needs to change.

Environment

  • Discourse commit: 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • Amazon SES SMTP sending
  • SES bounce notification topic delivered through SNS to /webhooks/aws
  • aws_sns_topic_arn_allowlist contains the exact SNS topic ARN
  • SNS subscription is confirmed

Steps to reproduce

  1. Configure Discourse to send through the Amazon SES SMTP endpoint.
  2. Configure an SES bounce notification topic, with original headers included.
  3. Subscribe https://<discourse-host>/webhooks/aws to the topic.
  4. Add that topic ARN to aws_sns_topic_arn_allowlist.
  5. Send a Discourse email to bounce@simulator.amazonses.com.
  6. Confirm that SES publishes the bounce and SNS reports a successful HTTPS delivery.
  7. Open /admin/email-logs/bounced.

Actual result

The webhook returns success and SNS reports no failed delivery, but the email is absent from the bounced-email log and its Discourse bounce state is not updated.

Expected result

Discourse should match the SES bounce to the sent EmailLog using the original RFC Message-ID and bounced recipient, then update the bounce state and score.

Cause

Discourse stores the pre-delivery message ID:

email_log.message_id = @message.message_id

The SNS job currently uses the SES-assigned ID:

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

AWS distinguishes these fields:

  • mail.messageId is assigned by SES.
  • The original email’s Message-ID is available in mail.headers and mail.commonHeaders when original headers are enabled.

The current request spec gives mail.messageId, mail.headers Message-ID, mail.commonHeaders.messageId, and EmailLog.message_id the same fixture value, so it does not reproduce actual SES behavior.

There is direct project history for this mismatch: PR #7284 removed strict ID matching in 2019 because the SNS ID did not equal the ID in EmailLog. The June 2026 security fix restored strict matching but used the SES-assigned ID.

Proposed fix

Prefer mail.commonHeaders.messageId, normalize it with Email::MessageIdService.message_id_clean, and fall back to mail.messageId for compatibility when original headers are unavailable. Keep the existing (message_id, to_address) lookup, TopicArn allowlist, SNS signature verification, and duplicate handling.

The attached patch also changes the request fixture so the SES-assigned ID differs from the original 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)

References