إشعارات الارتداد لـ SES/SNS لم تعد تتطابق مع EmailLog بعد تعزيز الأمان لـ TopicArn

كنت أحاول إعداد إشعارات الارتداد (bounce) من AWS SES بعد ظهور تنبيه حول إعداد ARN المفقود لها بعد آخر ترقية. قمت بتكوين الموضوع (topic) وإضافة ARN، لكن بريد الاختبار الخاص بالارتداد لم يظهر أبداً. ومع ذلك، تأكدت من وصوله إلى الموضوع (topic) على AWS. فيما يلي تحليل وصلح مقترح من Codex (لم يتم اختباره).

ملخص

تُقبل إشعارات الارتداد من Amazon SES المُرسلة عبر SNS بواسطة /webhooks/aws، لكن Discourse الحالي لا يحدد سجل EmailLog المقابل كمرتد عند استخدام SES عبر SMTP.

يبحث Jobs::ProcessSnsNotification عن السجل باستخدام mail.messageId. توثّق AWS أن هذا معرّف مُسنَد من SES، بينما يخزّن Email::Sender معرّف Message-ID الأصلي من RFC الخاص بـ Discourse في EmailLog.message_id. يختلف هذان المعرّفان.

يبدو أن هذا هو تراجع (regression) سبّبه تعزيز الأمان في يونيو 2026 في التأكيد 61f12e13aa1b760f81d5ff60f12e3a7e77434b94. يجب أن تبقى قائمة السماح بالموضوعات، والتحقق من التوقيع، وربط المستلم، والحماية من التكرار سليمة؛ فقط المعرّف المستخدم للبحث يحتاج إلى التغيير.

البيئة

  • تأكيد Discourse: 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • الإرسال عبر SMTP من Amazon SES
  • موضوع إشعار الارتداد من SES يُسلَّم عبر SNS إلى /webhooks/aws
  • يحتوي aws_sns_topic_arn_allowlist على ARN الموضوع (SNS topic) الدقيق
  • تم تأكيد اشتراك 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.

النتيجة الفعلية

يعيد webhook النجاح ولا يبلغ SNS عن فشل في التسليم، لكن البريد غير موجود في سجل بريد الارتداد ولم يتم تحديث حالة الارتداد الخاصة بـ Discourse.

النتيجة المتوقعة

يجب على Discourse مطابقة ارتداد SES مع EmailLog المُرسَل باستخدام معرّف Message-ID الأصلي من RFC والمستلم المرتد، ثم تحديث حالة الارتداد والنقاط (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 عند تمكين الترويسات الأصلية.

يمنح مواصفة الطلب الحالية قيم fixture متطابقة لـ mail.messageId، و Message-ID في mail.headers، و mail.commonHeaders.messageId، و EmailLog.message_id، لذا لا تعيد إنتاج سلوك SES الفعلي.

يوجد تاريخ مباشر للمشروع لهذا عدم التطابق: أزال PR #7284 المطابقة الصارمة للمعرّف في عام 2019 لأن معرّف SNS لم يكن يساوي المعرّف في EmailLog. أعادت إصلاحات الأمان في يونيو 2026 المطابقة الصارمة لكنها استخدمت المعرّف المُسنَد من SES.

الإصلاح المقترح

يفضّل mail.commonHeaders.messageId، ويُطبعه باستخدام Email::MessageIdService.message_id_clean، ويلجأ إلى mail.messageId للتوافق عند عدم توفر الترويسات الأصلية. احتفظ بالبحث الموجود (message_id, to_address)، وقائمة السماح لـ TopicArn، والتحقق من توقيع SNS، ومعالجة التكرار.

يغيّر الصلح المرفق أيضاً fixture الطلب بحيث يختلف المعرّف المُسنَد من SES عن معرّف Message-ID الأصلي من 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)

المراجع