Обробка bounce'ів: soft проти hard, VERP і автоматичне видалення мертвих адрес
Bounce — це не помилка, а сигнал. Розбираємо, чим 4xx відрізняється від 5xx на рівні DSN-кодів RFC 3463, навіщо потрібен VERP, щоб дізнатися ЧИЯ адреса відбилася, і як побудувати suppression-пайплайн, який не дасть вашій репутації повільно померти.
EvilMail Team12 липня 2026 р.12 хв читання
Розсилка пішла, лічильник показує «відправлено 40 000», і здається, що роботу зроблено. За добу в скриньку bounce@ падає 1 800 листів від різних MAILER-DAEMON — і ось тут починається справжня інженерія. Бо кожен із цих листів не сміття, а машиночитаний вердикт чужого MTA про те, що сталося з вашим повідомленням. Хто читає ці вердикти правильно — тримає bounce rate під 2% і потрапляє в inbox. Хто ігнорує — за два тижні опиняється в Spamhaus і не розуміє, чому.
Обробка bounce'ів — не гігієна «на потім». Для інфраструктури на кшталт evilmail.pro, де тимчасові адреси генерують щільний потік hard-відмов, це питання виживання домену. Розберемо, як відрізнити транзієнтну відмову від перманентної не «на відчуття», а за кодами, і як замкнути петлю ідентифікації через VERP.
Що таке bounce насправді: DSN, а не «лист не дійшов»
Bounce — не абстрактне «повідомлення не доставилося». Це конкретний лист класу Delivery Status Notification (DSN), формат якого визначає RFC 3464. Він приходить не на ваш From:, а на envelope-sender — адресу з команди MAIL FROM під час SMTP-сесії. Це критична деталь: From: у заголовку і MAIL FROM
у конверті — різні речі, і саме друга керує тим, куди повертаються відмови.
Правильний DSN має характерний Content-Type: multipart/report; report-type=delivery-status і складається з трьох частин:
людський текст — «Sorry, we were unable to deliver your message...». Його читає людина, і тільки людина;
`message/delivery-status` — машиночитаний блок із полями Reporting-MTA, Final-Recipient, Action:, Status: та Diagnostic-Code:. Це те, що має читати ваш код;
`message/rfc822` (або text/rfc822-headers) — оригінал листа чи його заголовки для звірки.
Головний антипатерн індустрії — парсити людський текст регулярками на кшталт «no such user» чи «mailbox unavailable». Кожен MTA формулює це по-своєму, тексти локалізовані й змінюються між версіями. Натомість поле Status: 5.1.1 стандартизоване й однозначне. Читайте Status:, а не прозу.
Soft vs hard: код рулить, а не назва
Розмежування soft/hard тримається на двох стандартах. SMTP-код відповіді (RFC 5321) дає перший грубий поділ:
4xx — transient, тимчасова відмова. Спробуйте пізніше. Це soft bounce.
5xx — permanent, остаточна відмова. Більше не намагайтеся. Це hard bounce.
Enhanced status code (RFC 3463) формату X.Y.Z уточнює причину. Перший октет — клас: 2 — успіх, 4 — транзієнтна помилка, 5 — перманентна. Другий і третій октети кажуть, що саме пішло не так. Ключові коди, які треба знати напам'ять:
`5.1.1` — поштової скриньки не існує. Класичний hard. Видаляти негайно.
`5.1.2` — некоректний або неіснуючий домен одержувача. Hard.
`5.2.2` — скринька переповнена. Формально 5.x.x, але по суті може розсмоктатися — трактуйте обережно, ближче до soft.
`5.7.1` — доставку заблоковано політикою. Часто це репутація, а не адреса. Розбирайтеся окремо, не викидайте адресу автоматично.
`4.7.0` — rate-limit. Google любить відповідати 421 4.7.0 ... try again later під час сплесків.
`5.4.4` — неможливо змаршрутизувати. Hard.
`5.7.26` — провал автентифікації SPF/DKIM/DMARC. Це проблема ВАШОГО налаштування, а не адреси.
Окремо стоїть «сіра зона». Greylisting повертає 4.2.0 або 4.7.1 — сервер навмисно відкидає першу спробу, очікуючи, що легітимний MTA повторить. Тимчасові DNS-фейли теж дають 4.x.x. Це нормальна поведінка, і ваша черга має її переживати без паніки.
Політику повторів у Postfix задають так:
ini
# main.cf
minimal_backoff_time = 300s # перший повтор через 5 хв
maximal_backoff_time = 4000s # стеля між повторами
maximal_queue_lifetime = 5d # скільки тримати лист у черзі
bounce_queue_lifetime = 5d # скільки ретраїти самі bounce'и
Ключова думка: 4xx — не привід одразу видаляти адресу, але й не привід ретраїти вічно. Через maximal_queue_lifetime (типово 5 діб) Postfix сам перетворить безнадійний soft на остаточний bounce. А ось лічильник послідовних soft'ів по конкретній адресі — вже ваша відповідальність, і без VERP ви його не побудуєте.
VERP: як дізнатися, ЧИЯ адреса відбилася
Ось де ламається наївний підхід. Ви розсилаєте з одним MAIL FROM: [email protected]. Bounce приходить — але в частині message/rfc822 оригінальний одержувач може бути обрізаний, а деякі MTA взагалі повертають лише заголовки або порожній звіт. Ви бачите, що ЩОСЬ відбилося, але не знаєте ЩО саме викреслювати зі списку.
VERP (Variable Envelope Return Path) розв'язує це радикально: адресу одержувача кодують у сам envelope-sender. Замість спільного [email protected] кожен лист відправляється з унікального конверта:
Коли лист відбивається, DSN приходить на [email protected]. Одержувач закодований прямо в адресі повернення — навіть якщо тіло bounce'а порожнє, ви точно знаєте, що мертва адреса — це [email protected]. Петля ідентифікації замкнулася.
Роздільники + і = мають узгоджуватися з вашим recipient_delimiter на приймальному боці, щоб bounce на bounce+user=example.com@ коректно ліг у скриньку bounce через sub-addressing. Sendmail і більшість поштових бібліотек (наприклад, розсильники на базі Rails Action Mailer чи Symfony Mailer) вміють генерувати VERP або дозволяють задати Return-Path довільно.
Є нюанс: VERP розкриває реальний email одержувача прямо в конверті, а сама адреса повернення стає довгою. Якщо це небажано — використовуйте опаковий токен на повідомлення: [email protected], де хеш мапиться в БД на пару (одержувач, кампанія). Так ви ховаєте email і тримаєте envelope коротким, ціною одного SELECT під час обробки bounce'а.
Пайплайн: від MAILER-DAEMON до suppression-листа
Наскрізний потік виглядає так. Bounce падає у виділений транспорт → парсер витягує VERP-адресу з To: (або Delivered-To:), читає Status: з message/delivery-status → приймає рішення:
hard (5.x.x, крім «сірих» 5.2.2/5.7.1) — одразу в suppression;
soft (4.x.x) — інкремент лічильника з timestamp; якщо N послідовних soft'ів за M днів — промоушн у hard;
скарга з Feedback Loop — теж у suppression, але з окремим reason.
Мінімальна структура таблиці suppression:
sql
CREATE TABLE suppression (
email TEXT PRIMARY KEY,
reason TEXT NOT NULL, -- hard_bounce | soft_threshold | complaint
dsn_status TEXT, -- '5.1.1'
first_seen TIMESTAMPTZ NOT NULL,
bounce_count INT NOT NULL DEFAULT 1,
suppressed_at TIMESTAMPTZ
);
Найважливіше правило всього пайплайна: перевіряйте suppression ПЕРЕД кожною відправкою. Повторна спроба достукатися до мертвої 5.1.1-адреси — це прямий сигнал спам-фільтрам, що ви не чистите список. Кілька таких на 1 000 листів — і Gmail починає різати вашу доставку.
Feedback Loops (FBL) — окреме джерело сигналів. Коли користувач тисне «Спам», провайдер (за наявності підписки) надсилає вам звіт у форматі ARF (RFC 5965). Gmail не має класичного FBL — сигнали приходять через Postmaster Tools; у Microsoft є SNDS і JMRP. Скарга серйозніша за hard bounce: адреса жива, але людина не хоче ваших листів. У suppression її, з reason complaint.
Практика: Postfix-транспорт і парсер DSN
Направимо весь bounce-потік у виділений транспорт. У master.cf:
# master.cf — bounce'и йдуть у скрипт-обробник
bouncehandler unix - n n - - pipe
flags=Rq user=bounceproc argv=/opt/mail/handle_bounce.py ${sender} ${recipient}
І прив'язка через transport_maps, щоб усе на [email protected] та його VERP-варіанти йшло сюди. Сам парсер розбирає стандартний multipart/report штатною бібліотекою, а не регулярками по всьому тілу:
python
import email
import re
from email import policy
DSN_STATUS = re.compile(rb'Status:\s*([245]\.\d+\.\d+)')
VERP = re.compile(r'bounce\+(?P<local>[^=]+)=(?P<domain>[^@]+)@')
def parse_bounce(raw: bytes):
msg = email.message_from_bytes(raw, policy=policy.default)
# 1) хто відбився — з VERP у конверті/To
verp = VERP.search(msg.get('To', '') or msg.get('Delivered-To', ''))
recipient = f"{verp['local']}@{verp['domain']}" if verp else None
# 2) вердикт — з message/delivery-status
status = None
for part in msg.walk():
if part.get_content_type() == 'message/delivery-status':
m = DSN_STATUS.search(part.get_payload(decode=True) or b'')
if m:
status = m.group(1).decode()
break
return recipient, status
def classify(status: str) -> str:
if status.startswith('5'):
# 5.2.2 / 5.7.1 — сіра зона, не викидаємо автоматом
return 'gray' if status in ('5.2.2', '5.7.1') else 'hard'
return 'soft' # 4.x.x
Далі hard → INSERT ... ON CONFLICT у suppression, soft → bounce_count += 1 з перевіркою порога, gray → у чергу на ручний перегляд.
У логах Postfix soft і hard видно неозброєним оком:
status=deferred (host mx.example.com said: 421 4.7.0 try again later)
status=bounced (host gmail-smtp-in.l.google.com said:
550-5.1.1 The email account that you tried to
reach does not exist)
status=deferred — це soft у польоті, лист ще в черзі. status=bounced — фінал. Найдешевший спосіб протестувати весь ланцюг — відправити на завідомо мертву адресу і зловити DSN:
За кілька секунд у bounce@ ляже DSN зі Status: 5.1.1, а ваш парсер має витягти [email protected] і покласти в suppression. Якщо витяг спрацював — пайплайн живий.
Чек-лист гігієни bounce-обробки
Окремий bounce-домен або субдомен, ізольований від основного трафіку.
VERP увімкнено: default_verp_delimiters = +=, узгоджено з recipient_delimiter.
Класифікація за Status: (RFC 3463), а не за людським текстом bounce'а.
Поріг soft→hard заданий явно: типово 3–5 послідовних невдач за 3–7 днів.
Suppression перевіряється ПЕРЕД кожною відправкою — це неперемовна умова.
Hard (5.1.1, 5.1.2, 5.4.4) видаляється негайно; «сірі» 5.2.2/5.7.1 — на ручний огляд.
FBL підключені: Postmaster Tools для Gmail, SNDS/JMRP для Microsoft; ARF-звіти ведуть у suppression з reason complaint.
Моніторинг bounce rate: <2% — здорово, >5% — тривога і стоп-розсилка до з'ясування.
Логи bounce-транспорту пишуться окремо від загального mail.log для швидкого аудиту.
Регулярний аудит suppression: періодично можна «воскрешати» адреси, що потрапили туди через тимчасовий 5.7.1, але ніколи — hard 5.1.1.
Bounce-обробка — замкнений контур: VERP каже ЧИЯ адреса, DSN-код каже ЩО з нею, suppression каже НЕ ЧІПАЙ. Приберіть будь-яку ланку — і контур розривається, а репутація домену починає стікати краплями, яких ви не побачите в дашборді, поки не стане пізно.