Ложные срабатывания антиспама: как читать заголовки, а не двигать порог
Клиент жалуется, что письмо ушло в спам. Не трогайте глобальный reject — откройте X-Spamd-Result и прочитайте его посимвольно. Разбираем диагностику по заголовкам и логам Rspamd, whitelist по DKIM через multimap, точечную правку весов и переобучение Bayes.
EvilMail Team22 июля 2026 г.11 мин чтения
Клиент пишет: «Ваша рассылка ушла ко мне в спам, почините фильтр». Первый инстинкт джуниора — открыть actions.conf и поднять reject с 15 до 20, «чтобы наверняка доходило». Это худшее, что можно сделать. Вы ослабляете защиту для всех тысяч ящиков разом и при этом не чините причину — потому что причина не в пороге.
Вердикт по письму — это не мнение фильтра и не чёрный ящик. Это арифметика: сумма весов сработавших символов, записанная в заголовке письма открытым текстом. Её можно прочитать за 30 секунд. На evilmail.pro перед Dovecot стоит Rspamd 3.x, через который проходят временные ящики доменов evilmail.pro и evilmail.cloud, и цена ошибки высока в обе стороны: пропустить фишинг во временный ящик так же плохо, как зарезать легитимный OTP от банка. Поэтому правило простое — сначала читаем данные, потом трогаем конфиг. А глобальный порог reject не трогаем почти никогда.
Где на самом деле живёт вердикт: разбор заголовков
Откройте исходник проблемного письма (в webmail это «Показать оригинал», в maildir — файл из /var/mail/vhosts/<domain>/<user>/cur/). В шапке лежит всё, что нужно для диагностики:
text
Ложные срабатывания антиспама в Rspamd: диагностика по заголовкам и whitelist по DKIM — EvilMail Blog
Читаем X-Spamd-Result слева направо. default: True означает, что политика по умолчанию сработала. [11.20 / 15.00] — это набранный балл / порог reject. То есть письмо набрало 11.20 при пороге отсечения 15 — до reject оно не дотянуло, но наверняка перевалило порог add_header (6) и упало в Junk. Дальше — посимвольная разбивка с весами в скобках.
Ключевая мысль: суммарный балл вторичен. Не смотрите на 11.20 как на приговор. Найдите один-два символа, которые дают основной вес. Здесь это BAYES_SPAM(4.50) — около 40% всего балла набрано статистическим классификатором. Всё остальное (FORGED_SENDER, MIME_HTML_ONLY) — мелочь по 0.2–0.3, которую трогать бессмысленно. Виновник ровно один, и это Bayes.
Строку Authentication-Results читаем отдельно: dkim=pass header.d=newsletter.example.com — подпись валидна и привязана к домену newsletter.example.com. Это наш будущий кандидат в whitelist. Обратите внимание, что spf=pass относится к smtp.mailfrom=bounce.example.com — то есть к домену конверта (Return-Path), а не к тому, что видит получатель в From. Именно поэтому доверять по SPF нельзя, но об этом ниже.
Для гибридных установок с SpamAssassin ищите X-Spam-Status: Yes, score=7.2 required=6.0 tests=BAYES_99,HTML_MESSAGE,DKIM_VALID — логика та же, score против required.
Диагностика на живом сервере: rspamc, история, логи
Заголовок показывает, что было при доставке. Чтобы проверить, что будет при текущем конфиге (например, после правки), прогоните письмо через боевой сканер:
Вывод даст точный расклад символов и весов ровно тем конфигом, что сейчас загружен в память. Это ваш основной инструмент проверки гипотез: поменяли карту whitelist — перечитали конфиг — прогнали то же письмо — увидели новый балл.
Второй инструмент — веб-интерфейс Rspamd на 127.0.0.1:11334, вкладка History. Там есть фильтр по получателю и отправителю, видно вердикт и полный список символов без всякого grep. Удобно, когда клиент не сохранил исходник, а транзакция была вчера.
Третий — логи. Свяжите SMTP-транзакцию с вердиктом через Queue-Id:
Здесь же различаем три принципиально разных сценария, которые клиент валит в одну кучу «письмо не дошло»:
reject на MTA — Rspamd вернул reject, Postfix ответил 5xx, письмо вообще не принято. Отправитель получил bounce.
add_header — балл между 6 и 15, письмо принято и лежит в папке Junk. «Не дошло» = «не посмотрел в спам».
greylist — балл около 4, временный 4xx, отправляющий сервер повторит через несколько минут. Никакой потери нет, есть задержка, которую приняли за пропажу.
Диагноз меняет решение полностью. Лечить whitelist'ом greylisting бессмысленно.
Whitelist по DKIM — единственный устойчивый способ доверия
Когда символ-виновник найден и это легитимный отправитель, возникает соблазн внести его «в белый список». Вопрос — по какому признаку. По IP нельзя: у любого крупного ESP пул адресов плавает и меняется без предупреждения. По SPF-домену нельзя: SPF проверяет smtp.mailfrom, то есть Return-Path конверта, который к видимому отправителю отношения не имеет и легко подставляется. Единственный признак, криптографически привязывающий письмо к домену, — это DKIM-подпись с её d=. Подделать её без приватного ключа отправителя невозможно.
В Rspamd whitelist по DKIM делается через модуль multimap. В /etc/rspamd/local.d/multimap.conf:
Карта /etc/rspamd/maps.d/dkim_whitelist.inc — просто список доменов d=, по одному в строку:
text
newsletter.example.com
mailings.yourbank.com
Вес -6.0 подобран осознанно: он утаскивает балл ниже add_header (6), то есть письмо гарантированно минует Junk, но не загоняет его в бесконечный минус. Это доверие, а не слепота — если у отправителя внезапно сработает FUZZY_DENIED или URL из чёрного списка, письмо всё равно может набрать достаточно, чтобы вы это заметили. Не ставьте -20.
Критическая тонкость: символ WHITELIST_DKIM сработает только при `dkim=pass`. Если кто-то подставит newsletter.example.com в поле From без валидной подписи, dkim:d не совпадёт, и whitelist не применится. Именно это и отличает доверие по DKIM от дыры в обороне.
Перечитать конфиг после правки:
bash
rspamadm configtest && systemctl reload rspamd
Для гибрида с SpamAssassin эквивалент в local.cf — whitelist_from_dkim *@newsletter.example.com. Никогда не используйте whitelist_from — он работает по адресу конверта и подделывается тривиально.
Корректировка порогов: точечно, а не глобально
Если whitelist по DKIM неприменим (нет валидной подписи, разовый кейс), правьте по иерархии от безопасного к опасному.
Уровень 1 — вес одного символа. Легитимные рассылки часто ловят FORGED_SENDER, потому что From и envelope-from разные. Понизить вес конкретного символа можно через override в /etc/rspamd/local.d/groups.conf:
hcl
symbols {
"FORGED_SENDER" {
weight = 0.10;
}
}
Уровень 2 — per-user / per-domain settings. Поднять порог только для конкретного получателя, не трогая остальных, — через /etc/rspamd/local.d/settings.conf:
Уровень 3 — глобальный `actions.conf`. Дефолты: greylist = 4, add header = 6, rewrite subject = 8 (часто выключен), reject = 15. Двигать их — последнее средство. Если всё же двигаете, оставьте комментарий с датой и причиной прямо в файле, иначе через полгода никто не вспомнит, почему reject стоит на 20 и почему в ящики просачивается больше мусора.
Bayes: тихий виновник большинства ложняков
Вернёмся к нашему разбору. BAYES_SPAM(4.50) — красный флаг номер один. Такой вес означает, что статистический классификатор с вероятностью около 99% считает легитимное письмо спамом. Значит, статистика отравлена или недообучена, и никакой whitelist эту причину не устраняет — он её маскирует.
Свежий сервер с пустым Bayes шумит первые недели: примерно до 200 ham + 200 spam вердикты хаотичны, потому что классификатору не на чем строить вероятности. На evilmail.pro Bayes хранится в Redis, обучение идёт автоматически по перекладыванию писем в Junk и обратно через IMAP. Проверить состояние:
bash
rspamc -h 127.0.0.1:11334 stat
Ручное переобучение на конкретном ложняке:
bash
rspamc learn_ham < /path/to/good.eml
# массово из папки с легитимными письмами:
for f in /var/mail/vhosts/example.com/user/cur/*; do rspamc learn_ham < "$f"; done
Если статистика безнадёжно отравлена (кто-то месяцами кидал в Junk нормальную почту), иногда честнее обнулить и переобучить с чистого листа через rspamadm statconvert или аккуратную очистку Redis-ключей Bayes — но сначала бэкап, потерять всё разом легко.
Чеклист диагностики ложного срабатывания
1.Достал оригинал письма (webmail «Показать оригинал» или файл из cur/).
2.Прочитал X-Spamd-Result — записал набранный балл, порог и топ-2 символа по весу.
3.Проверил Authentication-Results: что с dkim, spf, dmarc.
4.Определил сценарий по логу и Queue-Id: reject / add_header / greylist. Это разные болезни.
5.Прогнал письмо через rspamc symbols — убедился, что текущий конфиг даёт тот же расклад.
6.Выбрал лечение по причине: BAYES_* → переобучение; валидный dkim=pass → whitelist по DKIM через multimap; шумный мелкий символ → правка веса; особый получатель → per-user settings.
7.Убедился, что не тронул глобальный reject без документированной причины.
8.Перепроверил вторым тестовым письмом и глянул History в веб-интерфейсе.
Порог reject — это последняя ручка, к которой прикасаются, а не первая. На evilmail.pro она стоит на 15 и держится там: за каждым «ложным срабатыванием» всегда находится конкретный символ в конкретном заголовке, и чинится именно он.