TopicArn 보안 강화 후 SES/SNS 바운스 알림이 EmailLog와 일치하지 않음

최근 업그레이드 후 ARN 구성이 누락되었다는 프롬프트가 표시되어 AWS SES에서 바운스 알림을 설정하려고 했습니다. 토픽을 구성하고 ARN을 추가했지만, 테스트용 바운스 이메일이 표시되지 않았습니다. AWS에서 해당 이메일이 토픽에 도달한 것은 확인했습니다. 아래는 Codex가 분석하고 제안한 패치입니다(미테스트).

요약

SNS를 통해 전달되는 Amazon SES 바운스 알림은 /webhooks/aws에서 수신되지만, SMTP를 통해 SES를 사용하는 경우 현재 Discourse는 해당 EmailLog를 바운스로 표시하지 않습니다.

Jobs::ProcessSnsNotification은 mail.messageId를 사용하여 로그를 조회합니다. AWS 문서에 따르면 이는 SES가 할당하는 식별자이며, Email::Sender는 EmailLog.message_id에 Discourse의 원래 RFC Message-ID를 저장합니다. 두 식별자는 서로 다릅니다.

이는 2026년 6월의 보안 강화 커밋 61f12e13aa1b760f81d5ff60f12e3a7e77434b94로 인해 발생한 회귀(regression)로 보입니다. 토픽 허용 목록, 서명 검증, 수신자 바인딩, 중복 방지 기능은 그대로 유지해야 하며, 조회에 사용되는 식별자만 변경되어야 합니다.

환경

  • Discourse 커밋: 2239124ce41df4ea23a21686a78342adb5f6b3b5
  • Amazon SES SMTP 전송
  • SES 바운스 알림 토픽이 SNS를 통해 /webhooks/aws로 전달
  • aws_sns_topic_arn_allowlist에 해당 SNS 토픽 ARN이 정확히 포함됨
  • SNS 구독 확인됨

재현 단계

  1. Discourse를 Amazon SES SMTP 엔드포인트를 통해 전송하도록 구성합니다.
  2. 원래 헤더가 포함된 SES 바운스 알림 토픽을 구성합니다.
  3. https://<discourse-host>/webhooks/aws를 해당 토픽에 구독합니다.
  4. 해당 토픽 ARN을 aws_sns_topic_arn_allowlist에 추가합니다.
  5. bounce@simulator.amazonses.com으로 Discourse 이메일을 전송합니다.
  6. SES가 바운스를 게시하고 SNS가 HTTPS 전달 성공을 보고하는지 확인합니다.
  7. /admin/email-logs/bounced를 엽니다.

실제 결과

웹훅은 성공을 반환하고 SNS는 실패한 전달이 없음을 보고하지만, 이메일은 바운스된 이메일 로그에 나타나지 않으며 해당 Discourse 바운스 상태도 업데이트되지 않습니다.

기대 결과

Discourse는 원래 RFC Message-ID와 바운스된 수신자를 사용하여 SES 바운스를 전송된 EmailLog와 일치시켜야 하며, 이후 바운스 상태와 점수를 업데이트해야 합니다.

원인

Discourse는 전달 전 메시지 ID를 저장합니다:

email_log.message_id = @message.message_id

SNS 작업은 현재 SES가 할당된 ID를 사용합니다:

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

AWS는 이 필드들을 다음과 같이 구분합니다:

  • mail.messageId는 SES가 할당합니다.
  • 원래 이메일의 Message-ID는 원래 헤더가 활성화된 경우 mail.headers와 mail.commonHeaders에서 사용할 수 있습니다.

현재 요청 스펙은 mail.messageId, mail.headers의 Message-ID, mail.commonHeaders.messageId, 그리고 EmailLog.message_id에 동일한 픽스처 값을 부여하므로, 실제 SES 동작을 재현하지 못합니다.

이 불일치에 대한 직접적인 프로젝트 역사가 있습니다: PR #7284는 2019년에 SNS ID가 EmailLog의 ID와 같지 않았기 때문에 엄격한 ID 매칭을 제거했습니다. 2026년 6월의 보안 수정은 엄격한 매칭을 복원했지만 SES가 할당된 ID를 사용했습니다.

제안된 수정 사항

mail.commonHeaders.messageId를 우선 사용하고, Email::MessageIdService.message_id_clean으로 정규화한 뒤, 원래 헤더가 없는 경우 호환성을 위해 mail.messageId로 폴백합니다. 기존 (message_id, to_address) 조회, TopicArn 허용 목록, SNS 서명 검증, 중복 처리는 유지합니다.

첨부된 패치는 또한 SES가 할당된 ID가 원래 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)

참고 자료

1개의 좋아요

자체 호스팅 설치 환경(v2026.7.3, release/2026.7)에서 재현되었습니다. SES SMTP는 us-east-1에 있으며, 내장된 PostgreSQL 18을 사용하고 있습니다.

설정

  • aws_sns_topic_arn_allowlist에는 정확한 토픽 ARN이 포함되어 있습니다(SNS 콘솔에서 확인됨).
  • /webhooks/aws에 대한 SNS HTTPS 구독이 확인되었습니다. 같은 구독은 release/2026.4 및 2026.5.0에서 2026년 3월부터 6월까지 바운스 메시지를 올바르게 처리했습니다.

관찰 사항
bounce@simazonses.com으로 두 개의 테스트 메시지를 보냈습니다. 각 메시지는 컨테이너에 도달한 하나의 SNS POST를 생성했습니다:

"POST /webhooks/aws HTTP/1.1" "Amazon Simple Notification Service Agent" 200 402

/logs에는 아무것도 기록되지 않았으며, /admin/email-logs/bounced 하위에도 아무것도 없습니다. WebhooksController#aws는 허용 목록 또는 서명 검증 실패 시 406을 반환하므로, 200 응답은 두 가지 검사 모두 통과했음을 확인시켜 주며 Jobs::ProcessSnsNotification이 대기열에 추가되었음을 의미합니다. 이후 이 작업은 mail.messageId(SES가 할당)가 EmailLog.message_id(Discourse의 자체 Message-ID, Email::Sender에서 email_log.message_id = @message.message_id로 설정됨)와 결코 일치하지 않기 때문에 next if email_log.nil?에서 종료됩니다.

Git 히스토리는 위의 분석과 일치합니다: #7284(2019)는 정확히 이 이유로 ID 매칭을 제거했으며, 61f12e1(2026년 6월)은 SES가 할당된 ID를 사용하여 이를 다시 도입했습니다. release/2026.7에서 해당 커밋 이후로 작업은 변경되지 않았습니다.

현재 ESR에서 SES를 사용하는 자체 호스터에게 미치는 순 효과는 바운스 처리가 조용히 비활성화되며, 새로운 대시보드 알림이 관리자가 아무것도 수행하지 않는 구성으로 유도한다는 것입니다. 수정 사항이 적용되면 release/2026.7로의 백포트를 환영합니다.

2개의 좋아요

이건 언제쯤 해결될 수 있을까요? 아주 사소한 수정 사항으로 보이고, 테스트를 포함한 패치도 이미 준비되어 있습니다. 바운스 알림을 포함해 인프라 업데이트를 마무리하고 싶어요.

다음에 따라 수정될 예정입니다:

1개의 좋아요