Las notificaciones de rebot de SES/SNS ya no coinciden con EmailLog tras el endurecimiento de seguridad de TopicArn

Estaba intentando configurar las notificaciones de rebote desde AWS SES después de recibir un aviso sobre una configuración de ARN faltante para ello tras la última actualización. Configuré el tema y agregué el ARN, pero un correo de prueba de rebote nunca apareció. Sin embargo, confirmé que llegó al tema en AWS. A continuación, se presenta un análisis y un parche propuesto por Codex (sin probar).

Resumen

Las notificaciones de rebote de Amazon SES entregadas a través de SNS son aceptadas por /webhooks/aws, pero la versión actual de Discourse no marca el EmailLog correspondiente como rebotado cuando SES se utiliza a través de SMTP.

Jobs::ProcessSnsNotification busca el registro utilizando mail.messageId. AWS documenta que este es un identificador asignado por SES, mientras que Email::Sender almacena el Message-ID RFC original de Discourse en EmailLog.message_id. Los dos identificadores son diferentes.

Esto parece ser una regresión introducida por el endurecimiento de seguridad de junio de 2026 en el commit 61f12e13aa1b760f81d5ff60f12e3a7e77434b94. La lista blanca de temas, la verificación de firma, la vinculación del destinatario y la protección contra duplicados deben permanecer intactos; solo el identificador utilizado para la búsqueda necesita cambiar.

Entorno

  • Commit de Discourse: 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • Envío de Amazon SES SMTP
  • Tema de notificación de rebote de SES entregado a través de SNS a /webhooks/aws
  • aws_sns_topic_arn_allowlist contiene el ARN exacto del tema SNS
  • La suscripción a SNS está confirmada

Pasos para reproducir

  1. Configura Discourse para enviar a través del punto de finalización SMTP de Amazon SES.
  2. Configura un tema de notificación de rebote de SES, con los encabezados originales incluidos.
  3. Suscríbe https://<discourse-host>/webhooks/aws al tema.
  4. Agrega el ARN de ese tema a aws_sns_topic_arn_allowlist.
  5. Envía un correo de Discourse a bounce@simulator.amazonses.com.
  6. Confirma que SES publique el rebote y que SNS informe una entrega HTTPS exitosa.
  7. Abre /admin/email-logs/bounced.

Resultado actual

El webhook devuelve éxito y SNS no informa ninguna entrega fallida, pero el correo no aparece en el registro de correos rebotados y su estado de rebote en Discourse no se actualiza.

Resultado esperado

Discourse debería coincidir el rebote de SES con el EmailLog enviado utilizando el Message-ID RFC original y el destinatario rebotado, y luego actualizar el estado de rebote y la puntuación.

Causa

Discourse almacena el ID de mensaje antes de la entrega:

email_log.message_id = @message.message_id

El trabajo de SNS actualmente utiliza el ID asignado por SES:

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

AWS distingue estos campos:

  • mail.messageId es asignado por SES.
  • El Message-ID del correo original está disponible en mail.headers y mail.commonHeaders cuando los encabezados originales están habilitados.

La especificación de solicitud actual da a mail.messageId, el Message-ID de mail.headers, mail.commonHeaders.messageId y EmailLog.message_id el mismo valor de fixture, por lo que no reproduce el comportamiento real de SES.

Hay un historial directo del proyecto para esta discrepancia: la PR #7284 eliminó la coincidencia estricta de ID en 2019 porque el ID de SNS no era igual al ID en EmailLog. La corrección de seguridad de junio de 2026 restauró la coincidencia estricta, pero utilizó el ID asignado por SES.

Solución propuesta

Preferir mail.commonHeaders.messageId, normalizarlo con Email::MessageIdService.message_id_clean, y usar mail.messageId como respaldo para compatibilidad cuando los encabezados originales no estén disponibles. Mantener la búsqueda existente (message_id, to_address), la lista blanca de TopicArn, la verificación de firma de SNS y el manejo de duplicados.

El parche adjunto también cambia el fixture de solicitud para que el ID asignado por SES difiera del Message-ID RFC original.

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)

Referencias