Notificações de rejeição SES/SNS não correspondem mais ao EmailLog após o endurecimento de segurança do TopicArn

Estava tentando configurar notificações de rejeição (bounce) do AWS SES após receber um aviso sobre a falta de configuração de ARN para isso, depois da atualização mais recente. Configurei o tópico e adicionei o ARN, mas um e-mail de teste de rejeição nunca apareceu. Confirmei, no entanto, que a mensagem chegou ao tópico no AWS. Abaixo está uma análise e um patch proposto pelo Codex (não testado).

Resumo

As notificações de rejeição do Amazon SES entregues por meio do SNS são aceitas por /webhooks/aws, mas o Discourse atual não marca o EmailLog correspondente como rejeitado quando o SES é usado via SMTP.

Jobs::ProcessSnsNotification consulta o log usando mail.messageId. A documentação da AWS indica que este é um identificador atribuído pelo SES, enquanto Email::Sender armazena o Message-ID original da RFC do Discourse em EmailLog.message_id. Os dois identificadores são diferentes.

Isso parece ser uma regressão introduzida pelo endurecimento de segurança de junho de 2026 no commit 61f12e13aa1b760f81d5ff60f12e3a7e77434b94. A lista de permissão de tópicos, a verificação de assinatura, o vínculo do destinatário e a proteção contra duplicatas devem permanecer intactos; apenas o identificador usado para a consulta precisa ser alterado.

Ambiente

  • Commit do Discourse: 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • Envio via SMTP do Amazon SES
  • Tópico de notificação de rejeição do SES entregue por SNS para /webhooks/aws
  • aws_sns_topic_arn_allowlist contém o ARN exato do tópico SNS
  • Assinatura do SNS confirmada

Passos para reproduzir

  1. Configure o Discourse para enviar por meio do endpoint SMTP do Amazon SES.
  2. Configure um tópico de notificação de rejeição do SES, incluindo os cabeçalhos originais.
  3. Assine https://<discourse-host>/webhooks/aws ao tópico.
  4. Adicione o ARN desse tópico a aws_sns_topic_arn_allowlist.
  5. Envie um e-mail do Discourse para bounce@simulator.amazonses.com.
  6. Confirme que o SES publica a rejeição e que o SNS relata uma entrega HTTPS bem-sucedida.
  7. Abra /admin/email-logs/bounced.

Resultado atual

O webhook retorna sucesso e o SNS não relata falha de entrega, mas o e-mail está ausente do log de e-mails rejeitados e seu estado de rejeição no Discourse não é atualizado.

Resultado esperado

O Discourse deve corresponder a rejeição do SES ao EmailLog enviado usando o Message-ID original da RFC e o destinatário rejeitado, e então atualizar o estado de rejeição e a pontuação.

Causa

O Discourse armazena o ID da mensagem antes da entrega:

email_log.message_id = @message.message_id

O trabalho do SNS atualmente usa o ID atribuído pelo SES:

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

A AWS distingue esses campos:

  • mail.messageId é atribuído pelo SES.
  • O Message-ID do e-mail original está disponível em mail.headers e mail.commonHeaders quando os cabeçalhos originais estão habilitados.

A especificação de solicitação atual atribui a mail.messageId, o Message-ID em mail.headers, o mail.commonHeaders.messageId e o EmailLog.message_id o mesmo valor de fixture, portanto, não reproduz o comportamento real do SES.

Há histórico direto do projeto para essa incompatibilidade: a PR #7284 removeu a correspondência estrita de IDs em 2019 porque o ID do SNS não era igual ao ID em EmailLog. A correção de segurança de junho de 2026 restaurou a correspondência estrita, mas usou o ID atribuído pelo SES.

Correção proposta

Dar preferência a mail.commonHeaders.messageId, normalizá-lo com Email::MessageIdService.message_id_clean e usar mail.messageId como alternativa para compatibilidade quando os cabeçalhos originais não estiverem disponíveis. Manter a consulta existente (message_id, to_address), a lista de permissão de TopicArn, a verificação de assinatura do SNS e o tratamento de duplicatas.

O patch anexado também altera a fixture de solicitação para que o ID atribuído pelo SES seja diferente do Message-ID original da RFC.

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)

Referências