Обработка отказов через VERP и парсинг NDR: hard/soft bounce и чистка списка
Bounce-обработка ломается не на парсинге тела письма, а на маршрутизации отказов. Разбираем рабочую схему: VERP + BATV на отправке, выделенный transport в Postfix, классификация по enhanced status codes RFC 3463 вместо текстовых фраз, и политика чистки с промоцией soft в hard.
EvilMail Team13 июля 2026 г.13 мин чтения
Вы отправили рассылку на 40 000 адресов, поставили Return-Path: [email protected], и через час на этот ящик падает несколько тысяч отказов. Дальше начинается боль: NDR приходят в пяти разных форматах, половина не содержит оригинального получателя в теле, а те что содержат — обрезаны до заголовков. Вы пытаетесь вытащить адрес регуляркой из text/plain, ломаетесь на первом же сервере с локализованным текстом на японском, и в итоге не можете надёжно ответить на единственный важный вопрос: какое письмо какому получателю не дошло и почему.
Проблема не в парсере. Проблема в том, что вы свалили все отказы в один конверт. Ниже — схема, которая работает в проде: закодировать получателя прямо в envelope-from (VERP), защититься от backscatter (BATV/prvs), завернуть весь поток отказов в выделенный transport Postfix и классифицировать не по строкам, а по машиночитаемым кодам RFC 3463.
Почему один Return-Path не масштабируется
Ключевой факт, на котором всё держится: DSN (delivery status notification) всегда уходит на envelope-from, то есть на `MAIL FROM`, а не на заголовок `From:`. Принимающий MTA не читает ваш красивый From: [email protected]
VERP и парсинг NDR: автоматическая обработка bounce и чистка списка рассылки — EvilMail Blog
— он берёт адрес из конверта SMTP-сессии и на него шлёт отказ.
Если у всей кампании один envelope-from [email protected], каждый NDR приходит обезличенным. Чтобы понять, кому именно не дошло, нужно распарсить вложенный message/rfc822 внутри отчёта и достать оригинальный To:. Но по RFC вложение оригинала опционально: многие MTA отдают только text/rfc822-headers, а некоторые (Exchange в отдельных конфигурациях, старые Qmail) обрезают тело или шлют нестандартный отчёт без структуры multipart/report. Часть привязок вы теряете гарантированно.
Плюс вторая беда — backscatter. Спамеры подделывают ваш домен в MAIL FROM, и отказы на их рассылки летят вам. На общий bounces@ это сплошной мусор, неотличимый от настоящих отказов.
Как устроен VERP: кодируем получателя в конверт
VERP (Variable Envelope Return Path) решает идентификацию на уровне конверта. Вместо статичного адреса приложение выставляет уникальный `MAIL FROM` на каждое письмо, куда закодированы кампания, пользователь и домен:
Здесь + — recipient delimiter, 7c1a — id кампании, ivan и example.com — разобранный адрес получателя (символ @ заменён на =, потому что второй @ в локальной части недопустим). Когда придёт отказ, тело вскрывать не нужно: получатель уже лежит в envelope-to самого NDR. DSN самоидентифицируется ещё до открытия отчёта.
Нюанс, на котором спотыкаются: не полагайтесь на `sendmail -V` или Postfix XVERP для bulk-рассылок. Эти механизмы расширяют одно письмо на нескольких получателей внутри одной SMTP-транзакции. В рассылке вы и так шлёте по одному письму на получателя — просто формируйте уникальный MAIL FROM на стороне приложения, это и есть VERP.
Ограничение, о котором забывают: локальная часть адреса по RFC 5321 — не более 64 октетов. Длинный адрес вроде very.long.department.name+tag@really-long-corporate-domain.example в закодированном виде вылезет за лимит, и ваш же исходящий MTA его зарежет. Решение: кодировать не сырой адрес, а короткий токен — например, первые байты HMAC(secret, recipient_id) — и держать соответствие токен→получатель в БД:
Разбираем тег 1053a3f9d2c7: первая цифра 1 — селектор ключа, 053 — счётчик дня (номер дня по модулю 1000), a3f9d2c7 — усечённый HMAC(secret, address + day). На приёме отказа вы пересчитываете хэш по адресу из envelope-to и сверяете. Настоящий DSN на ваше письмо подпись пройдёт; поддельный отказ на подделанный спамером адрес — нет, потому что у спамера нет секрета. Протухшие теги (старше 7 дней) режем сразу: настоящий отказ за неделю не задержится, а старый тег — почти наверняка backscatter или replay.
На практике prvs и VERP комбинируются: часть тега несёт подпись, часть — закодированного получателя. Валидация подписи идёт первым шагом в парсере, до всякого разбора тела.
Маршрутизация отказов в Postfix
Чтобы весь поток отказов на поддомен уходил в парсер, а не в почтовый ящик, нужны три вещи: DNS, transport и pipe-демон.
DNS — отдельный MX и SPF на bounce-поддомене:
dns
bounces.evilmail.pro. IN MX 10 mail.evilmail.pro.
bounces.evilmail.pro. IN TXT "v=spf1 mx -all"
main.cf — включаем разделитель и направляем поддомен в кастомный transport:
master.cf — сам pipe-transport, который зовёт скрипт с закодированным адресом:
ini
bounceparse unix - n n - - pipe
flags=Rq user=bounce argv=/opt/evilmail/parse_ndr.py ${recipient}
Флаг R добавляет Return-Path, q экранирует спецсимволы в аргументах. Через ${recipient} в скрипт прилетает уже раскодированный VERP-адрес, а тело NDR — на stdin.
Альтернатива для тех, кто не готов к pipe: catch-all мейлбокс на bounce-поддомене плюс IMAP-поллер, который раз в минуту забирает новые письма. Trade-off честный — задержка обработки и лишняя точка отказа (поллер упал — отказы копятся), зато не нужно трогать master.cf и давать процессу права на запуск скрипта из MTA. Для небольших объёмов это нормально; на десятках тысяч писем в час берите pipe.
Анатомия NDR: что внутри multipart/report
Стандартный отказ по RFC 3464 — это multipart/report; report-type=delivery-status из трёх частей:
`text/plain` — человекочитаемый текст («Sorry, we were unable to deliver…»). Локализованный, нестандартный, парсить его нельзя.
`message/delivery-status` — машиночитаемый блок. Вот отсюда и читаем.
`message/rfc822` или `text/rfc822-headers` — копия оригинала (опционально).
Per-recipient блок в message/delivery-status выглядит так:
Читаем Status (enhanced status code по RFC 3463), а Diagnostic-Code держим как tiebreaker. text/plain игнорируем полностью.
Классификация: hard vs soft по enhanced status codes
Правило первой цифры Status:
`5.y.z` — permanent (hard). Адрес мёртв, повторять бессмысленно.
`4.y.z` — transient (soft). Временно, ретраить.
`2.y.z` — success. Встречается в DSN об успехе/relay, действий не требует.
Частые коды и что с ними делать:
Status
Смысл
Verdict
Действие
5.1.1
mailbox does not exist
HARD
suppress сразу
5.1.2
bad destination system
HARD
suppress
5.1.10
null MX (RFC 7505)
HARD
suppress
5.2.1
mailbox disabled
HARD
suppress
5.2.2 / 4.2.2
mailbox full
SOFT
retry, счётчик
5.4.4
unable to route / DNS
HARD-ish
suppress после 2 попыток
4.4.1
no answer from host
SOFT
retry
4.7.1 / 4.7.28
greylist / rate limit
SOFT
backoff, не чистить
5.7.1
blocked / policy (spam)
НЕ suppress
тормозить, чинить репутацию
Два подводных камня. Первый: MTA врут. Некоторые отдают 5.2.2 (mailbox full) вместо честного 4.2.2 — то есть маркируют временную ошибку как постоянную. Здесь и нужен Diagnostic-Code: если SMTP-код в нём начинается с 4xx, доверяйте ему, а не enhanced-коду. Второй: `5.7.x` нельзя тупо suppress'ить. Это не мёртвый ящик, а отказ по политике — вас заблокировали за репутацию или контент. Убрав такой адрес, вы прячете симптом; чинить надо отправку.
Что делать, когда message/delivery-status вообще нет (кривой MTA прислал plain-text отказ)? Фолбэк — эвристика по SMTP-коду из текста или библиотека вроде flufl.bounce, которая переваривает нестандартные форматы.
Парсинг на практике
Ядро парсера — обойти MIME-дерево, выбрать message/delivery-status, прочитать per-recipient блоки и нормализовать Status. Никаких регулярок по тексту:
python
import email
def classify(raw: bytes):
msg = email.message_from_bytes(raw)
for part in msg.walk():
if part.get_content_type() != 'message/delivery-status':
continue
for block in part.get_payload():
action = block.get('Action', '').lower()
status = (block.get('Status') or '').strip()
rcpt = block.get('Final-Recipient', '; ').split(';')[-1].strip()
if action != 'failed' or not status:
continue
cls = status[0]
verdict = 'hard' if cls == '5' else 'soft' if cls == '4' else 'ok'
yield rcpt, verdict, status
Для отчётов без структуры multipart/report подключаем фолбэк — flufl.bounce разбирает десятки нестандартных форматов и отдаёт два множества адресов:
python
from flufl.bounce import all_failures
temporary, permanent = all_failures(msg)
Политика чистки: пороги, таймауты, промоция soft→hard
Правила, которые держат bounce rate под контролем:
Hard — в suppression немедленно и навсегда. Записываем статус, исключаем из всех будущих кампаний. Никаких «попробуем ещё разок».
Soft — инкремент счётчика с TTL. Промоция в hard при 5 последовательных soft за 7 дней или непрерывном 4.x.x дольше 72 часов. Адрес, который неделю не принимает почту, для рассылки мёртв.
Spam-block (5.7.1) — адрес не трогаем. Тормозим отправку на этот ISP, греем IP, разбираемся с контентом и аутентификацией.
Идемпотентность — один DSN не должен инкрементить счётчик дважды. Дедуп по Message-ID отчёта: провайдер иногда доставляет одну нотификацию повторно.
Метрика-ориентир: держите hard bounce rate ниже 2%. Gmail и Microsoft начинают троттлить и блокировать при устойчивых значениях выше ~5% — а на temp-email и высокооборотных доменах репутация утекает за часы, не за дни. Чистка hard-адресов в тот же момент, когда пришёл отказ, — это не гигиена, это условие выживания домена.
FBL и жалобы — это не bounce
Отдельный поток, который нельзя мешать с отказами. ARF (RFC 5965) — это feedback loop от ISP: получатель нажал «спам». Формат похож — multipart/report; report-type=feedback-report — но внутри блок Feedback-Type: abuse. Обрабатывается иначе: мгновенный unsubscribe, а не инкремент bounce-счётчика. Свяжите это с List-Unsubscribe и one-click отпиской по RFC 8058. Спутать жалобу с soft bounce — значит оставить в списке человека, который вас пометил спамом, и убить репутацию своими руками.
Чеклист внедрения
VERP-кодирование получателя (или HMAC-токена при длинных адресах) в MAIL FROM на отправке.
BATV/prvs-подпись, валидация на приёме, отбрасывание тегов старше 7 дней.
Отдельный MX + SPF для bounce-поддомена.
Pipe-transport в Postfix (master.cf + transport_maps) → парсер.
Классификация по Status из message/delivery-status, а не по тексту; Diagnostic-Code как tiebreaker.
Hard → suppression немедленно; soft → счётчик с промоцией (5/7дн или >72ч).
5.7.x не чистим — тормозим отправку и чиним репутацию.
Отдельный ARF-пайплайн: жалоба = мгновенный unsubscribe.
Дедуп DSN по Message-ID отчёта.
Мониторинг hard bounce rate по каждой кампании, порог тревоги на 2%.