Les notifications de rebond SES/SNS ne correspondent plus à EmailLog après le durcissement de la sécurité de TopicArn

J’essayais de configurer les notifications de rebond (bounce) depuis AWS SES après avoir reçu une alerte concernant une configuration ARN manquante à ce sujet suite à la dernière mise à jour. J’ai configuré le sujet et ajouté l’ARN, mais un e-mail de test de rebond n’est jamais arrivé. J’ai toutefois confirmé qu’il a bien atteint le sujet sur AWS. Voici une analyse et un correctif proposé par Codex (non testé).

Résumé

Les notifications de rebond Amazon SES livrées via SNS sont acceptées par /webhooks/aws, mais Discourse actuel ne marque pas le EmailLog correspondant comme rebondi lorsque SES est utilisé via SMTP.

Jobs::ProcessSnsNotification recherche le journal en utilisant mail.messageId. AWS documente que ceci est un identifiant attribué par SES, tandis que Email::Sender stocke l’identifiant Message-ID RFC original de Discourse dans EmailLog.message_id. Les deux identifiants diffèrent.

Cela semble être une régression introduite par le durcissement de sécurité de juin 2026 dans l’engagement 61f12e13aa1b760f81d5ff60f12e3a7e77434b94. La liste blanche des sujets, la vérification de la signature, la liaison du destinataire et la protection contre les doublons doivent rester intacts ; seul l’identifiant utilisé pour la recherche doit changer.

Environnement

  • Commit Discourse : 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • Envoi SMTP Amazon SES
  • Sujet de notification de rebond SES livré via SNS vers /webhooks/aws
  • aws_sns_topic_arn_allowlist contient l’ARN exact du sujet SNS
  • L’abonnement SNS est confirmé

Étapes pour reproduire

  1. Configurer Discourse pour envoyer via le point d’accès SMTP Amazon SES.
  2. Configurer un sujet de notification de rebond SES, avec les en-têtes d’origine inclus.
  3. Abonner https://<discourse-host>/webhooks/aws au sujet.
  4. Ajouter cet ARN de sujet à aws_sns_topic_arn_allowlist.
  5. Envoyer un e-mail Discourse à bounce@simulator.amazonses.com.
  6. Confirmer que SES publie le rebond et que SNS signale une livraison HTTPS réussie.
  7. Ouvrir /admin/email-logs/bounced.

Résultat réel

Le webhook renvoie un succès et SNS ne signale aucune livraison échouée, mais l’e-mail est absent du journal des e-mails rebondis et son état de rebond Discourse n’est pas mis à jour.

Résultat attendu

Discourse devrait associer le rebond SES au EmailLog envoyé en utilisant l’identifiant Message-ID RFC original et le destinataire rebondi, puis mettre à jour l’état et le score de rebond.

Cause

Discourse stocke l’identifiant de message avant la livraison :

email_log.message_id = @message.message_id

Le travail SNS utilise actuellement l’identifiant attribué par SES :

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

AWS distingue ces champs :

  • mail.messageId est attribué par SES.
  • L’identifiant Message-ID de l’e-mail original est disponible dans mail.headers et mail.commonHeaders lorsque les en-têtes d’origine sont activés.

La spécification de requête actuelle donne à mail.messageId, l’identifiant Message-ID de mail.headers, mail.commonHeaders.messageId et EmailLog.message_id la même valeur de test, de sorte qu’elle ne reproduit pas le comportement réel de SES.

Il y a un historique direct du projet pour ce décalage : la PR #7284 a supprimé l’appariement strict des identifiants en 2019 parce que l’identifiant SNS n’était pas égal à l’identifiant dans EmailLog. La correction de sécurité de juin 2026 a rétabli l’appariement strict mais a utilisé l’identifiant attribué par SES.

Correction proposée

Privilégier mail.commonHeaders.messageId, le normaliser avec Email::MessageIdService.message_id_clean, et revenir à mail.messageId pour la compatibilité lorsque les en-têtes d’origine ne sont pas disponibles. Conserver la recherche existante (message_id, to_address), la liste blanche TopicArn, la vérification de la signature SNS et la gestion des doublons.

Le correctif joint modifie également le test de requête pour que l’identifiant attribué par SES diffère de l’identifiant 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)

Références