Я пытался настроить уведомления о возврате писем (bounce) от AWS SES после того, как после последнего обновления появилось предупреждение об отсутствии конфигурации ARN для этой функции. Я настроил топик и добавил ARN, но тестовое письмо о возврате так и не появилось. При этом я подтвердил, что оно попало в топик на стороне AWS. Ниже представлен анализ и предложенный патч от Codex (не проверен).
Краткое описание
Уведомления о возврате писем от Amazon SES, доставляемые через SNS, принимаются эндпоинтом /webhooks/aws, однако текущая версия Discourse не помечает соответствующую запись EmailLog как возвращённую, когда SES используется через SMTP.
Jobs::ProcessSnsNotification выполняет поиск записи с использованием mail.messageId. Согласно документации AWS, это идентификатор, присваиваемый SES, тогда как Email::Sender сохраняет исходный RFC-идентификатор Message-ID от Discourse в поле EmailLog.message_id. Эти два идентификатора различаются.
Похоже, это регрессия, возникшая из-за усиления безопасности в июне 2026 года в коммите 61f12e13aa1b760f81d5ff60f12e3a7e77434b94. Белый список топиков, проверка подписи, привязка получателя и защита от дубликатов должны остаться неизменными; необходимо изменить только идентификатор, используемый для поиска.
Окружение
- Коммит Discourse:
2239124ce41df4ea23a21686a78342adb5f6b3b5 - Отправка через SMTP Amazon SES
- Топик уведомлений о возврате SES доставляется через SNS на
/webhooks/aws - В
aws_sns_topic_arn_allowlistуказан точный ARN топика SNS - Подписка на SNS подтверждена
Шаги для воспроизведения
- Настройте Discourse на отправку через SMTP-эндпоинт Amazon SES.
- Настройте топик уведомлений о возврате SES, включив исходные заголовки.
- Подпишите
https://<discourse-host>/webhooks/awsна этот топик. - Добавьте ARN этого топика в
aws_sns_topic_arn_allowlist. - Отправьте письмо от Discourse на адрес
bounce@simulator.amazonses.com. - Убедитесь, что SES публикует уведомление о возврате, а SNS сообщает об успешной доставке по HTTPS.
- Откройте
/admin/email-logs/bounced.
Фактический результат
Вебхук возвращает успех, и SNS не сообщает об ошибках доставки, но письмо отсутствует в журнале возвращённых писем, а состояние возврата в Discourse не обновляется.
Ожидаемый результат
Discourse должен сопоставить возврат от SES с отправленным EmailLog, используя исходный RFC-идентификатор Message-ID и получателя, на которого пришёл возврат, а затем обновить состояние возврата и счёт (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, если включены исходные заголовки.
В текущей спецификации запроса значения mail.messageId, Message-ID в mail.headers, mail.commonHeaders.messageId и EmailLog.message_id имеют одинаковое фикстурированное значение, поэтому реальное поведение SES не воспроизводится.
В истории проекта есть прямой прецедент этого несоответствия: PR #7284 в 2019 году убрал строгое сопоставление ID, потому что ID из SNS не совпадал с ID в EmailLog. Исправление безопасности в июне 2026 года вернуло строгое сопоставление, но использовало идентификатор, присвоенный SES.
Предложенное исправление
В качестве приоритетного использовать mail.commonHeaders.messageId, нормализовать его с помощью Email::MessageIdService.message_id_clean, а в качестве резервного варианта использовать mail.messageId для совместимости, если исходные заголовки недоступны. Сохранить существующий поиск по (message_id, to_address), белый список TopicArn, проверку подписи SNS и обработку дубликатов.
Приложенный патч также изменяет фикстуру запроса так, чтобы идентификатор, присвоенный SES, отличался от исходного 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)
Ссылки
- Определения полей уведомлений AWS: Amazon SNS notification contents for Amazon SES - Amazon Simple Email Service
- Первоначальное исправление в Discourse, PR #7284: FIX: Detect SNS notifications for SES correctly - Pull Request #7284 - discourse/discourse - GitHub
- Коммит по усилению безопасности: SECURITY: Prevent any signed AWS SNS TopicARN from being accepted via… · discourse/discourse@61f12e1 · GitHub
- Существующий отчёт на Meta: Bounced e-mails from Amazon SES/SNS not working