Le notifiche di rimbalzo SES/SNS non corrispondono più a EmailLog dopo il rafforzamento della sicurezza di TopicArn

Stavo cercando di configurare le notifiche di rimbalzo da AWS SES dopo aver ricevuto un avviso sulla mancata configurazione dell’ARN al termine dell’ultimo aggiornamento. Ho configurato il topic e aggiunto l’ARN, ma una email di test di rimbalzo non è mai arrivata. Tuttavia, ho confermato che è stata ricevuta dal topic su AWS. Di seguito è riportata un’analisi e una patch proposta da Codex (non testata).

Riepilogo

Le notifiche di rimbalzo di Amazon SES consegnate tramite SNS vengono accettate da /webhooks/aws, ma l’attuale versione di Discourse non segna il corrispondente EmailLog come rimbalzato quando SES viene utilizzato tramite SMTP.

Jobs::ProcessSnsNotification cerca il log utilizzando mail.messageId. AWS documenta che questo è un identificatore assegnato da SES, mentre Email::Sender archivia l’originale RFC Message-ID di Discourse in EmailLog.message_id. I due identificatori sono diversi.

Questo sembra essere un regresso introdotto dal rafforzamento della sicurezza di giugno 2026 nel commit 61f12e13aa1b760f81d5ff60f12e3a7e77434b94. La whitelist dei topic, la verifica della firma, il vincolo del destinatario e la protezione dai duplicati dovrebbero rimanere intatti; è necessario modificare solo l’identificatore utilizzato per la ricerca.

Ambiente

  • Commit di Discourse: 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • Invio tramite SMTP di Amazon SES
  • Topic di notifica dei rimbalzi SES consegnato tramite SNS a /webhooks/aws
  • aws_sns_topic_arn_allowlist contiene l’ARN esatto del topic SNS
  • L’abbonamento SNS è confermato

Passaggi per riprodurre

  1. Configura Discourse per inviare tramite l’endpoint SMTP di Amazon SES.
  2. Configura un topic di notifica dei rimbalzi SES, includendo gli header originali.
  3. Abbona https://<discourse-host>/webhooks/aws al topic.
  4. Aggiungi quell’ARN del topic a aws_sns_topic_arn_allowlist.
  5. Invia un’email di Discourse a bounce@simulator.amazonses.com.
  6. Conferma che SES pubblica il rimbalzo e che SNS riporta una consegna HTTPS riuscita.
  7. Apri /admin/email-logs/bounced.

Risultato effettivo

Il webhook restituisce successo e SNS non riporta alcuna consegna fallita, ma l’email è assente dal registro delle email rimbalzate e il suo stato di rimbalzo in Discourse non viene aggiornato.

Risultato atteso

Discourse dovrebbe abbinare il rimbalzo SES all’EmailLog inviato utilizzando l’originale RFC Message-ID e il destinatario rimbalzato, quindi aggiornare lo stato di rimbalzo e il punteggio.

Causa

Discourse archivia l’ID messaggio pre-consegna:

email_log.message_id = @message.message_id

Il job SNS attualmente utilizza l’ID assegnato da SES:

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

AWS distingue questi campi:

  • mail.messageId è assegnato da SES.
  • L’originale Message-ID dell’email è disponibile in mail.headers e mail.commonHeaders quando gli header originali sono abilitati.

Lo spec di richiesta attuale assegna a mail.messageId, Message-ID in mail.headers, mail.commonHeaders.messageId e EmailLog.message_id lo stesso valore di fixture, quindi non riproduce il comportamento reale di SES.

C’è una storia diretta del progetto per questa discrepanza: la PR #7284 ha rimosso l’abbinamento rigoroso degli ID nel 2019 perché l’ID SNS non era uguale all’ID in EmailLog. La modifica di sicurezza di giugno 2026 ha ripristinato l’abbinamento rigoroso ma ha utilizzato l’ID assegnato da SES.

Soluzione proposta

Preferire mail.commonHeaders.messageId, normalizzarlo con Email::MessageIdService.message_id_clean e fare affidamento su mail.messageId per compatibilità quando gli header originali non sono disponibili. Mantenere la ricerca esistente (message_id, to_address), la whitelist TopicArn, la verifica della firma SNS e la gestione dei duplicati.

La patch allegata modifica anche la fixture di richiesta in modo che l’ID assegnato da SES differisca dall’originale 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)

Riferimenti