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_allowlistcontains the exact SNS topic ARN- SNS subscription is confirmed
Steps to reproduce
- Configure Discourse to send through the Amazon SES SMTP endpoint.
- Configure an SES bounce notification topic, with original headers included.
- Subscribe
https://<discourse-host>/webhooks/awsto the topic. - Add that topic ARN to
aws_sns_topic_arn_allowlist. - Send a Discourse email to
bounce@simulator.amazonses.com. - Confirm that SES publishes the bounce and SNS reports a successful HTTPS delivery.
- 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.messageIdis assigned by SES.- The original email’s
Message-IDis available inmail.headersandmail.commonHeaderswhen 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
- AWS notification field definitions: Amazon SNS notification contents for Amazon SES - Amazon Simple Email Service
- Original Discourse fix, PR #7284: FIX: Detect SNS notifications for SES correctly - Pull Request #7284 - discourse/discourse - GitHub
- Security hardening commit: SECURITY: Prevent any signed AWS SNS TopicARN from being accepted via… · discourse/discourse@61f12e1 · GitHub
- Existing Meta report: Bounced e-mails from Amazon SES/SNS not working