Письма уходили в спам только у клиентов Outlook: разбор одного заголовка
Саппорт получил первую жалобу в понедельник: клиент с адресом на outlook.com не видит письмо с подтверждением заказа. Проверили — письмо ушло, в логах статус «доставлено», просто лежало в папке «Спам». Списали на случайность. К четвергу таких жалоб набралось два десятка, и все — от адресов на outlook.com, hotmail.com, live.com и корпоративных доменов на Microsoft 365. Ни один пользователь Gmail, «Яндекс.Почты» или Mail.ru за ту же неделю не пожаловался. При этом SPF, DKIM и DMARC у домена были настроены и проходили проверку везде. Разберём, как нашли причину в одном заголовке письма, который на других почтовых системах никого не интересовал.
Содержание
Что видели снаружи
Сервис отправлял транзакционные письма (подтверждение заказа, сброс пароля, чеки) с собственного домена через VPS с Postfix, письма формировало отдельное Node.js-приложение и передавало готовое MIME-сообщение на локальный Postfix по SMTP на 587-й порт. Объём небольшой — несколько сотен писем в сутки, ничего похожего на рассылку.
Картина по симптомам была на удивление ровной:
- Получатели на Gmail, «Яндекс», Mail.ru, corporate-домены на Google Workspace — письма приходят во «Входящие», жалоб нет.
- Получатели на outlook.com, hotmail.com, live.com и на корпоративных доменах, обслуживаемых Microsoft 365 (Exchange Online Protection) — письмо стабильно, у каждого адресата, оказывается в «Спаме» (или «Нежелательной почте», в зависимости от локализации интерфейса).
- В логах Postfix (
/var/log/mail.log) для этих же писем — никаких отказов, только успешная передача:
postfix/smtp[24188]: 8F3A2C1D45: to=<user@outlook.com>,
relay=outlook-com.olc.protection.outlook.com[104.47.x.x]:25,
delay=1.2, delays=0.1/0/0.6/0.5, dsn=2.0.0, status=sent (250 2.0.0 OK...)
То есть SMTP-сессия завершалась штатным 250 OK — Microsoft принимал письмо без возражений на уровне протокола, а дальше молча раскладывал его по папкам уже после приёма. Это стандартное поведение Exchange Online Protection: фильтрация по содержимому происходит уже после успешного приёма письма, поэтому в логах отправителя инцидент выглядит как полный успех — никаких bounce, никаких NDR, не за что зацепиться со стороны собственной инфраструктуры.
Как разбирали: заголовки и метрики почтового сервера
Первым делом прогнали домен через mail-tester.com — стандартная проверка, которая быстро отсекает грубые ошибки: результат 10/10, SPF pass, DKIM pass, DMARC pass с выравниванием (aligned), PTR на месте, IP не в чёрных списках Spamhaus и Barracuda. По всем формальным критериям аутентификации — идеально. Про то, что вообще проверяют эти три механизма и зачем они нужны, если тема новая, стоит сначала прочитать в SPF, DKIM и DMARC простыми словами.
Дальше попросили одного из клиентов переслать письмо не как обычную пересылку (она меняет заголовки), а прислать полный исходный текст письма — в Outlook.com это делается через «Показать» → «Просмотреть исходное сообщение». Полный разбор того, как вообще читать сырые заголовки письма и что в них искать, — отдельная тема, подробно она разобрана в статье как читать заголовки письма; здесь остановимся на двух конкретных находках.
Первая — заголовки Authentication-Results от Microsoft:
Authentication-Results: spf=pass (sender IP is 203.0.113.45)
smtp.mailfrom=example.com; dkim=pass (signature was verified)
header.d=example.com; dmarc=pass action=none header.from=example.com
Всё зелёное. Аутентификация домена ни при чём — это подтвердило и то, что уже показал mail-tester. Вторая находка — заголовок X-Forefront-Antispam-Report, который EOP добавляет к каждому письму и в котором среди прочего есть параметр SCL (Spam Confidence Level — внутренняя оценка «уверенности», что письмо спам). У писем от того же отправителя, доходивших до других адресов, SCL был низким; у писем, падавших в «Спам» именно на Outlook, — заметно выше. Точных публичных порогов Microsoft не раскрывает, и они могут отличаться по организациям с собственной политикой EOP, но сам факт высокой оценки контентного фильтра при полностью пройденной аутентификации говорил, что дело не в SPF/DKIM/DMARC, а в чём-то, что фильтр считывает из самого письма.
Оставалось сравнить, построчно, заголовки «спамного» письма с письмом, которое благополучно доходило до Gmail. Технически оба генерировались одним и тем же кодом, той же версией приложения, с одного и того же IP. Разница нашлась не в теле, не в теме, не в SPF/DKIM — а в заголовке Message-ID.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые отбросили
Прежде чем дойти до Message-ID, проверили и закрыли несколько версий, которые в подобных ситуациях обычно проверяют в первую очередь:
- IP в чёрных списках. Проверили через Spamhaus, Barracuda Reputation Block List, MXToolbox — везде чисто, IP использовался месяцами без единого инцидента.
- Проблема с SPF/DKIM/DMARC или их выравниванием. Все три проверки проходили и на mail-tester, и по заголовкам
Authentication-Resultsот самого Microsoft — то есть проблема была не в доверии к домену как таковому. - Слишком новый или «холодный» IP, недостаточная репутация. IP отправлял почту не первую неделю, объём стабильный, без резких скачков — типичного сценария «прогрева» IP здесь не было.
- Контент письма — спам-триггерные слова, ссылки, картинки без текста. Отправили тестовое письмо в виде чистого текста без единой ссылки — результат тот же: у Outlook в «Спаме», у Gmail во «Входящих».
- Отсутствующий или неверный PTR. Проверили
dig -x <IP>— обратная запись присутствовала и совпадала с именем, которое сервер отдавал в приветствии EHLO. - Нестабильное TLS-соединение при передаче. В логах Postfix соединение с серверами Microsoft шло по STARTTLS с валидным сертификатом, без ошибок согласования — тут тоже было чисто.
Каждая версия была правдоподобной и стоила проверки, но ни одна не объясняла главного: почему проблема воспроизводилась стопроцентно и только у одного семейства почтовых систем, а не была случайной или общей для всех получателей.
Реальная причина: Message-ID указывал не на домен, а на имя контейнера
Приложение, которое собирало письма, работало в Docker-контейнере и формировало MIME-сообщение вручную перед отправкой на локальный Postfix. При генерации заголовков код брал имя хоста через os.hostname(), чтобы подставить его в Message-ID — распространённая практика, потому что RFC 5322 требует, чтобы правая часть Message-ID была уникальным идентификатором, обычно строящимся на основе имени хоста отправителя. Проблема была в том, что внутри контейнера os.hostname() возвращает не доменное имя почтового сервера, а короткий hex-идентификатор самого контейнера, который Docker генерирует автоматически при каждом создании контейнера.
В коде это выглядело примерно так:
const os = require('os');
function buildMessageId() {
const host = os.hostname(); // внутри контейнера вернёт что-то вроде "4f8a6b2c9e11"
const unique = Date.now().toString(36) + Math.random().toString(36).slice(2);
return `<${unique}@${host}>`;
}
В результате заголовок Message-ID выглядел так:
Message-ID: <m3k9f2a1@4f8a6b2c9e11>
Синтаксически это валидный заголовок — RFC 5322 не требует, чтобы правая часть после @ была реальным резолвящимся доменом, формально подойдёт любая уникальная строка. Но по факту у легитимного письма от компании с собственным доменом правая часть Message-ID почти всегда совпадает с доменом отправителя или с поддоменом почтового сервера — а тут вместо этого стояла строка без единой точки, без похожести на доменное имя вообще, которая к тому же менялась при каждом пересоздании контейнера.
Контентные фильтры Gmail и большинства других почтовых систем на этот заголовок либо не смотрят вовсе, либо не придают ему значимого веса — их модели в первую очередь опираются на репутацию отправителя и поведение получателей. Фильтр Exchange Online Protection у Microsoft, судя по наблюдаемой разнице в SCL, учитывает правдоподобность служебных заголовков вроде Message-ID заметно сильнее — не совпадающий с отправляющим доменом Message-ID у него один из сигналов, повышающих оценку подозрительности. Точного веса этого сигнала Microsoft не публикует, поэтому это гипотеза, подтверждённая практикой на конкретном случае, а не документированный факт от вендора — но она полностью объясняла и стопроцентную воспроизводимость, и то, что проблема касалась только писем, прошедших через конкретный участок кода.
Финальная проверка: заменили генерацию Message-ID вручную на тестовом письме, подставив в правую часть реальный домен вместо hostname контейнера, отправили — письмо ушло во «Входящие» на том же тестовом ящике Outlook, где до этого стабильно падало в «Спам». Больше в письме не поменяли ни строки.
Что изменили
Первое и главное — убрали зависимость Message-ID от имени хоста контейнера и стали строить правую часть на основе домена отправителя, который и так уже был константой в конфиге приложения:
const crypto = require('crypto');
function buildMessageId(senderDomain) {
const unique = crypto.randomUUID();
return `<${unique}@${senderDomain}>`;
}
// buildMessageId('example.com')
// -> "<f47ac10b-58cc-4372-a567-0e02b2c3d479@example.com>"
Второе — там, где письма уходят через локальный Postfix без явно заданного Message-ID (часть служебных уведомлений формировалась старым кодом на bash через sendmail), проверили параметр myhostname в main.cf — если Message-ID не задан явно, Postfix на этапе cleanup подставляет его сам, используя именно это значение:
postconf myhostname
# было: myhostname = 4f8a6b2c9e11 (имя контейнера на момент последнего деплоя)
# стало:
myhostname = mail.example.com
Важный нюанс: myhostname в Postfix влияет не только на генерируемый Message-ID, но и на HELO/EHLO-приветствие, и на заголовок Received. В этом инциденте HELO уже был переопределён отдельно через smtp_helo_name, поэтому визуально с исходящим SMTP всё выглядело нормально — а вот Message-ID, который берётся именно из myhostname, продолжал указывать на случайное имя контейнера. Из-за этого проблема долго не находилась: два параметра, влияющих на разные заголовки, были синхронизированы лишь частично.
Третье — добавили в конфигурацию Docker явное значение hostname контейнера через docker-compose.yml, чтобы os.hostname() внутри приложения в принципе не мог случайно попасть в исходящее письмо, даже если кто-то в будущем коде снова решит взять его как источник для генерации идентификаторов:
services:
mailer:
image: example/mailer:latest
hostname: mail-internal.example.com
environment:
- MAIL_DOMAIN=example.com
Четвёртое — завели тестовый ящик на outlook.com и добавили его в список адресов, куда автоматически уходит копия каждого релиза перед выкладкой на прод, чтобы такие регрессии ловились до жалоб реальных клиентов.
Как проверить у себя, что Message-ID не подставит вас
Проблема с Message-ID не диагностируется через обычные инструменты проверки почтового домена — ни mail-tester, ни dig по SPF/DKIM-записям её не покажут, потому что формально заголовок валиден. Проверять нужно руками, глядя на реальное письмо:
- Отправьте себе тестовое письмо и откройте его исходный код (в Outlook.com — «Просмотреть исходное сообщение», в Gmail — «Показать оригинал»).
- Найдите строку
Message-ID:и посмотрите, что стоит после@. - Сравните с доменом, с которого письмо реально отправлено (адрес в поле
From).
Короткий чек-лист, на что смотреть:
| Что видно в Message-ID | Что это значит |
|---|---|
<uuid@example.com>, домен совпадает с From | Норма, вопросов нет |
<uuid@mail.example.com>, поддомен отправляющего домена | Тоже норма, частая практика для отдельного почтового поддомена |
<id@localhost> или <id@localhost.localdomain> | Явный признак незаданного myhostname или дефолта библиотеки — стоит исправить |
<id@4f8a6b2c9e11> или похожая hex-строка без точек | Скорее всего hostname контейнера — тот самый случай из этого разбора |
<id@192.168.x.x> (IP-адрес) | Внутренний IP вместо домена — тоже стоит исправить |
Если используете готовый почтовый стек вместо самописной генерации MIME — в Postfix и большинстве других MTA этот момент обычно закрыт по умолчанию корректно, но стоит проверить myhostname командой postconf myhostname и убедиться, что там реальный домен, а не автоматически подхваченное системное имя хоста — особенно если сервис поднят из образа, где hostname никто специально не задавал. Если письма периодически проваливаются в «Спам» без видимой причины, а SPF/DKIM/DMARC при этом в порядке — общий разбор типичных причин, начиная с более частых, чем Message-ID, есть в статье Postfix: письма уходят в спам — причины и решение. А если проблема в том, что IP уже засветился в чёрных списках, порядок восстановления описан в материале IP попал в чёрные списки: восстановление репутации.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему именно Outlook, а не Gmail или «Яндекс» реагирует на Message-ID?
Точного ответа от Microsoft нет — компания не публикует полный список сигналов и их веса в антиспам-фильтре Exchange Online Protection. По наблюдаемой разнице в оценке SCL для одинаковых по остальным параметрам писем похоже, что EOP придаёт значение правдоподобности служебных заголовков заметно сильнее, чем другие почтовые системы, которые опираются в основном на репутацию отправителя и поведение получателей. Это наблюдение по конкретному случаю, а не документированное вендором правило.
Если у меня Message-ID указывает на localhost, надо ли срочно всё чинить?
Стоит проверить и исправить, но паниковать необязательно — если писем немного и жалоб от получателей на Outlook нет, эффект может быть малозаметен или перекрываться другими сильными сигналами. Проверка занимает пару минут, а исправление обычно тривиально, так что логичнее сделать это профилактически, не дожидаясь жалоб.
Может ли отсутствие Message-ID вообще (а не просто неправильный) вызвать ту же проблему?
Да, причём для некоторых MTA и клиентских библиотек отсутствие заголовка встречается чаще, чем неправильный домен в нём. Postfix в норме сам подставит заголовок при приёме письма без него, взяв значение из myhostname — если он настроен верно, ситуация закрывается автоматически. Проблема возникает, когда приложение формирует Message-ID само, в обход этой логики, и берёт не тот источник имени.
Как быстро проверить конкретно свой случай, не дожидаясь жалоб пользователей Outlook?
Заведите тестовый ящик на outlook.com и раз в релиз отправляйте туда копию реального письма из продакшена. Если оно стабильно доходит до «Входящих», а не «Спама», можно быть спокойным хотя бы в этой части; полная проверка домена по SPF/DKIM/DMARC при этом всё равно нужна отдельно.
Стоит ли вообще отказываться от собственной генерации Message-ID и полностью доверить это Postfix?
Если приложение не собирает MIME руками, а передаёт тело и заголовки через готовую библиотеку отправки почты (например, Nodemailer без ручной сборки MIME) — корректный Message-ID обычно подставляется автоматически на основе домена отправителя, и вмешиваться не нужно. Ручная генерация оправдана, только когда нужен контроль над форматом (например, для трекинга ответов по Message-ID в системе поддержки) — и тогда просто важно не забыть источник домена.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →