Уведомления SES/SNS о возвратах больше не совпадают с EmailLog после усиления безопасности TopicArn

Я пытался настроить уведомления о возврате писем (bounce) от AWS SES после того, как после последнего обновления появилось предупреждение об отсутствии конфигурации ARN для этой функции. Я настроил топик и добавил ARN, но тестовое письмо о возврате так и не появилось. При этом я подтвердил, что оно попало в топик на стороне AWS. Ниже представлен анализ и предложенный патч от Codex (не проверен).

Краткое описание

Уведомления о возврате писем от Amazon SES, доставляемые через SNS, принимаются эндпоинтом /webhooks/aws, однако текущая версия Discourse не помечает соответствующую запись EmailLog как возвращённую, когда SES используется через SMTP.

Jobs::ProcessSnsNotification выполняет поиск записи с использованием mail.messageId. Согласно документации AWS, это идентификатор, присваиваемый SES, тогда как Email::Sender сохраняет исходный RFC-идентификатор Message-ID от Discourse в поле EmailLog.message_id. Эти два идентификатора различаются.

Похоже, это регрессия, возникшая из-за усиления безопасности в июне 2026 года в коммите 61f12e13aa1b760f81d5ff60f12e3a7e77434b94. Белый список топиков, проверка подписи, привязка получателя и защита от дубликатов должны остаться неизменными; необходимо изменить только идентификатор, используемый для поиска.

Окружение

  • Коммит Discourse: 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • Отправка через SMTP Amazon SES
  • Топик уведомлений о возврате SES доставляется через SNS на /webhooks/aws
  • В aws_sns_topic_arn_allowlist указан точный ARN топика SNS
  • Подписка на SNS подтверждена

Шаги для воспроизведения

  1. Настройте Discourse на отправку через SMTP-эндпоинт Amazon SES.
  2. Настройте топик уведомлений о возврате SES, включив исходные заголовки.
  3. Подпишите https://<discourse-host>/webhooks/aws на этот топик.
  4. Добавьте ARN этого топика в aws_sns_topic_arn_allowlist.
  5. Отправьте письмо от Discourse на адрес bounce@simulator.amazonses.com.
  6. Убедитесь, что SES публикует уведомление о возврате, а SNS сообщает об успешной доставке по HTTPS.
  7. Откройте /admin/email-logs/bounced.

Фактический результат

Вебхук возвращает успех, и SNS не сообщает об ошибках доставки, но письмо отсутствует в журнале возвращённых писем, а состояние возврата в Discourse не обновляется.

Ожидаемый результат

Discourse должен сопоставить возврат от SES с отправленным EmailLog, используя исходный RFC-идентификатор Message-ID и получателя, на которого пришёл возврат, а затем обновить состояние возврата и счёт (score).

Причина

Discourse сохраняет идентификатор сообщения до доставки:

email_log.message_id = @message.message_id

Задача SNS в настоящее время использует идентификатор, присвоенный SES:

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

AWS различает эти поля:

  • mail.messageId присваивается SES.
  • Исходный Message-ID письма доступен в mail.headers и mail.commonHeaders, если включены исходные заголовки.

В текущей спецификации запроса значения mail.messageId, Message-ID в mail.headers, mail.commonHeaders.messageId и EmailLog.message_id имеют одинаковое фикстурированное значение, поэтому реальное поведение SES не воспроизводится.

В истории проекта есть прямой прецедент этого несоответствия: PR #7284 в 2019 году убрал строгое сопоставление ID, потому что ID из SNS не совпадал с ID в EmailLog. Исправление безопасности в июне 2026 года вернуло строгое сопоставление, но использовало идентификатор, присвоенный SES.

Предложенное исправление

В качестве приоритетного использовать mail.commonHeaders.messageId, нормализовать его с помощью Email::MessageIdService.message_id_clean, а в качестве резервного варианта использовать mail.messageId для совместимости, если исходные заголовки недоступны. Сохранить существующий поиск по (message_id, to_address), белый список TopicArn, проверку подписи SNS и обработку дубликатов.

Приложенный патч также изменяет фикстуру запроса так, чтобы идентификатор, присвоенный SES, отличался от исходного 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)

Ссылки