Автоматическая обработка bounce-писем: webhook, логи и скрипты
Каждый bounce, оставшийся без внимания, работает против вас. Почтовые провайдеры считают отказы, и если их процент поднимается выше 2-3%, рассылки начинают тонуть в спаме. Ручная обработка при объёмах больше пары тысяч адресов нереальна. Нужна автоматика: webhook от ESP, парсинг SMTP-логов, скрипты, которые сами вычищают мёртвые адреса из базы.
Что происходит, когда письмо отскакивает
SMTP-сервер получателя возвращает код ошибки. 550 5.1.1 - ящик не существует. 552 5.2.2 - ящик переполнен. 421 - сервер временно недоступен. Ваш MTA (Postfix, Exim, какой угодно) получает этот ответ и формирует bounce notification. Если вы используете ESP вроде Mailgun, SendGrid или Amazon SES, они перехватывают этот момент и могут отправить данные вам через webhook.
Hard bounce (5xx) означает, что адрес мёртв навсегда. Soft bounce (4xx) - временная проблема: переполненный ящик, недоступный сервер, graylisting. Soft bounce может стать hard bounce, если повторяется 3-5 раз подряд.
Главная проблема не в самих bounce-ах. Проблема в том, что большинство команд узнают о них из отчёта ESP через сутки после рассылки. К этому моменту ущерб репутации уже нанесён. Автоматизация сокращает эту задержку до секунд.
Три способа ловить bounce-ы
1. Webhook от ESP. Самый удобный путь. SendGrid, Mailgun, Postmark, Amazon SES умеют отправлять HTTP POST на ваш endpoint при каждом bounce. Вы получаете JSON с адресом, типом ошибки, SMTP-кодом и таймстемпом. Остаётся написать обработчик.
2. Парсинг SMTP-логов. Если вы отправляете через собственный MTA (Postfix, Exim), bounce-данные оседают в логах. Postfix пишет в /var/log/mail.log, Exim - в /var/log/exim/mainlog. Нужен скрипт, который вытягивает оттуда адреса с кодами 5xx и 4xx.
3. Обработка bounce-писем (Return-Path). Когда сервер получателя не может доставить письмо, он отправляет NDR (Non-Delivery Report) на адрес из Return-Path. Можно настроить отдельный ящик типа bounces@yourdomain.com, подключиться к нему по IMAP и парсить входящие. Метод старый, но рабочий, особенно если ESP не поддерживает webhook.
На практике большинство проектов комбинируют первый и второй подходы. Webhook покрывает основной поток, а логи служат страховкой и источником деталей, которые ESP иногда не передаёт.
Настройка webhook: получаем bounce в реальном времени
Webhook-endpoint - это обычный HTTP-сервер, который принимает POST-запросы от ESP. У разных провайдеров формат payload отличается, но суть одна: JSON с информацией о bounce. Вот минимальный обработчик на Python (Flask) и Node.js (Express).
Python (Flask)
import hmac, hashlib, json, os
from flask import Flask, request, jsonify
from datetime import datetime
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["BOUNCE_WEBHOOK_SECRET"]
def verify_signature(payload: bytes, signature: str) -> bool:
expected = hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route("/webhooks/bounce", methods=["POST"])
def handle_bounce():
sig = request.headers.get("X-Signature", "")
if not verify_signature(request.data, sig):
return jsonify({"error": "bad signature"}), 403
events = request.json if isinstance(request.json, list) else [request.json]
for event in events:
email = event.get("email") or event.get("recipient", "")
bounce_type = event.get("type", "hard") # hard | soft
code = event.get("code", "550")
reason = event.get("reason", "")
ts = event.get("timestamp", datetime.utcnow().isoformat())
if bounce_type == "hard":
suppress_email(email, code, reason, ts)
else:
record_soft_bounce(email, code, reason, ts)
return jsonify({"status": "ok"}), 200
def suppress_email(email, code, reason, ts):
"""Добавляет адрес в suppression list и удаляет из активной базы."""
# INSERT INTO suppression_list (email, code, reason, bounced_at) ...
# DELETE FROM subscribers WHERE email = ...
print(f"[HARD] {email} | {code} | {reason}")
def record_soft_bounce(email, code, reason, ts):
"""Считает soft bounce. После 3-го подряд переводит в suppression."""
# UPDATE subscribers SET soft_bounce_count = soft_bounce_count + 1 ...
# IF soft_bounce_count >= 3: suppress_email(...)
print(f"[SOFT] {email} | {code} | {reason}")Node.js (Express)
const express = require("express");
const crypto = require("crypto");
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.BOUNCE_WEBHOOK_SECRET;
function verifySignature(payload, signature) {
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(JSON.stringify(payload))
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
app.post("/webhooks/bounce", (req, res) => {
const sig = req.headers["x-signature"] || "";
if (!verifySignature(req.body, sig)) {
return res.status(403).json({ error: "bad signature" });
}
const events = Array.isArray(req.body) ? req.body : [req.body];
for (const event of events) {
const email = event.email || event.recipient || "";
const bounceType = event.type || "hard";
const code = event.code || "550";
const reason = event.reason || "";
if (bounceType === "hard") {
suppressEmail(email, code, reason);
} else {
recordSoftBounce(email, code, reason);
}
}
res.json({ status: "ok" });
});
function suppressEmail(email, code, reason) {
// INSERT INTO suppression_list ...
// DELETE FROM subscribers WHERE email = ...
console.log(`[HARD] ${email} | ${code} | ${reason}`);
}
function recordSoftBounce(email, code, reason) {
// UPDATE subscribers SET soft_bounce_count = soft_bounce_count + 1 ...
// IF soft_bounce_count >= 3 -> suppressEmail(...)
console.log(`[SOFT] ${email} | ${code} | ${reason}`);
}
app.listen(3000, () => console.log("Bounce webhook on :3000"));Несколько моментов, которые часто упускают. Первый: всегда проверяйте подпись запроса. Без этого кто угодно может отправить POST на ваш endpoint и удалить живые адреса из базы. Второй: обрабатывайте массивы событий. SendGrid, например, отправляет пакеты по 10-100 событий за один запрос. Третий: возвращайте 200 быстро, а тяжёлую работу (запросы к БД, уведомления) выполняйте асинхронно через очередь. Если ваш endpoint отвечает медленно, ESP начнёт повторять запросы, и вы получите дубликаты.
Payload: что присылают разные ESP
Каждый провайдер форматирует webhook по-своему. Вот ключевые поля, на которые нужно ориентироваться.
SendGrid отправляет массив объектов. Тип события - поле event (значение bounce), адрес - email, причина - reason, код SMTP - status (строка вида «550 5.1.1»).
Mailgun присылает form-urlencoded (не JSON по умолчанию!). Адрес в поле recipient, код - code, описание ошибки - error. Подпись проверяется через HMAC SHA256 с вашим API-ключом.
Amazon SES работает через SNS. Bounce notification приходит как SNS Message c JSON внутри. Тип bounce - в поле bounceType (Permanent / Transient), адреса - в массиве bouncedRecipients. Перед обработкой нужно подтвердить подписку SNS, иначе уведомления не придут.
# Пример payload от Amazon SES (через SNS)
{
"notificationType": "Bounce",
"bounce": {
"bounceType": "Permanent",
"bounceSubType": "General",
"bouncedRecipients": [
{
"emailAddress": "dead@example.com",
"action": "failed",
"status": "5.1.1",
"diagnosticCode": "smtp; 550 5.1.1 user unknown"
}
],
"timestamp": "2026-03-20T14:22:01.000Z"
}
}Парсинг SMTP-логов: для тех, кто отправляет через свой сервер
Если рассылка идёт через Postfix или Exim, bounce-данные живут в логах. Postfix записывает строку с status=bounced и оригинальным SMTP-ответом. Вот скрипт на Python, который вытаскивает эти записи и формирует список для suppression.
#!/usr/bin/env python3
"""
parse_postfix_bounces.py
Парсит /var/log/mail.log и выгружает bounce-адреса в CSV.
"""
import re, csv, sys
from collections import defaultdict
from datetime import datetime
LOG_PATH = "/var/log/mail.log"
OUTPUT = "bounced_emails.csv"
# Postfix bounce строка:
# Mar 20 14:22:01 mail postfix/bounce[12345]: ABC123: sender non-delivery notification: DEF456
# или
# Mar 20 14:22:01 mail postfix/smtp[12345]: ABC123: to=<user@example.com>,
# relay=mx.example.com[1.2.3.4]:25, delay=1.2, status=bounced
# (host mx.example.com said: 550 5.1.1 user unknown)
BOUNCE_RE = re.compile(
r"to=<(?P<email>[^>]+)>.*status=bounced.*said:s*(?P<code>d{3})s+(?P<detail>[^)]+)",
re.IGNORECASE,
)
def parse_log(path: str) -> list[dict]:
results = []
with open(path) as f:
for line in f:
m = BOUNCE_RE.search(line)
if m:
results.append({
"email": m.group("email"),
"code": m.group("code"),
"detail": m.group("detail").strip(),
"line": line.strip()[:200],
})
return results
def main():
bounces = parse_log(LOG_PATH)
if not bounces:
print("Bounce-записей не найдено.")
sys.exit(0)
# Дедупликация: один адрес может bounce-ить несколько раз
seen = {}
for b in bounces:
key = b["email"].lower()
if key not in seen:
seen[key] = b
with open(OUTPUT, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["email", "code", "detail"])
w.writeheader()
for b in seen.values():
w.writerow({k: b[k] for k in ["email", "code", "detail"]})
print(f"Найдено {len(seen)} уникальных bounce-адресов -> {OUTPUT}")
if __name__ == "__main__":
main()Для Exim логика аналогична, но формат строки другой. Ищите == (доставка) и ** (bounce). Строка с bounce в Exim выглядит так: 2026-03-20 14:22:01 1abc23-000456-AB ** user@example.com R=remote_smtp T=remote_smtp: SMTP error from remote mail server ... 550 5.1.1 user unknown.
Запускайте парсер по cron раз в час или чаще. Результат - CSV с адресами, который скрипт-синхронизатор загружает в suppression list вашей БД.
Скрипт suppression: автоматическое удаление из базы
И webhook, и лог-парсер заканчиваются одним действием: адрес нужно убрать из рассылки. Вот скрипт, который работает с PostgreSQL и ведёт suppression list.
#!/usr/bin/env python3
"""
suppress_bounced.py
Читает CSV с bounce-адресами и обновляет БД:
1. Добавляет в suppression_list
2. Помечает подписчиков как неактивных
"""
import csv, os
import psycopg2
DB_URL = os.environ["DATABASE_URL"]
CSV_PATH = "bounced_emails.csv"
def run():
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
with open(CSV_PATH) as f:
reader = csv.DictReader(f)
count = 0
for row in reader:
email = row["email"].lower().strip()
code = row.get("code", "550")
detail = row.get("detail", "")
# Upsert в suppression_list
cur.execute("""
INSERT INTO suppression_list (email, bounce_code, reason, created_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (email) DO UPDATE SET
bounce_code = EXCLUDED.bounce_code,
reason = EXCLUDED.reason,
updated_at = NOW()
""", (email, code, detail))
# Деактивация подписчика
cur.execute("""
UPDATE subscribers
SET status = 'bounced', updated_at = NOW()
WHERE LOWER(email) = %s AND status = 'active'
""", (email,))
count += 1
conn.commit()
cur.close()
conn.close()
print(f"Suppressed {count} адресов")
if __name__ == "__main__":
run()Обратите внимание на ON CONFLICT. Один и тот же адрес может bounce-ить из разных рассылок. Без upsert скрипт упадёт на дубликате. Также не делайте DELETE из таблицы подписчиков. Используйте статус (bounced, suppressed) - так вы сохраните историю и сможете анализировать причины отказов.
То же самое на Node.js
// suppress_bounced.js
const { Pool } = require("pg");
const fs = require("fs");
const { parse } = require("csv-parse/sync");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function run() {
const raw = fs.readFileSync("bounced_emails.csv", "utf-8");
const rows = parse(raw, { columns: true, skip_empty_lines: true });
const client = await pool.connect();
let count = 0;
try {
await client.query("BEGIN");
for (const row of rows) {
const email = (row.email || "").toLowerCase().trim();
const code = row.code || "550";
const detail = row.detail || "";
await client.query(
`INSERT INTO suppression_list (email, bounce_code, reason, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (email) DO UPDATE SET
bounce_code = EXCLUDED.bounce_code,
reason = EXCLUDED.reason,
updated_at = NOW()`,
[email, code, detail]
);
await client.query(
`UPDATE subscribers SET status = 'bounced', updated_at = NOW()
WHERE LOWER(email) = $1 AND status = 'active'`,
[email]
);
count++;
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
console.log(`Suppressed ${count} emails`);
}
run().then(() => pool.end());Soft bounce: считаем, а не игнорируем
С hard bounce всё просто: удалили и забыли. С soft bounce сложнее. Ящик переполнен сегодня - завтра владелец почистит. Сервер упал на час - потом заработает. Удалять после первого soft bounce - потерять живых подписчиков.
Рабочая схема: ведите счётчик. Первый soft bounce - записали, ничего не делаем. Второй подряд - помечаем адрес как at_risk. Третий подряд - переводим в suppression. Если между bounce-ами была успешная доставка, счётчик сбрасывается.
-- Схема таблицы для отслеживания soft bounce
CREATE TABLE bounce_tracker (
email TEXT PRIMARY KEY,
consecutive INT DEFAULT 0,
last_bounce_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
status TEXT DEFAULT 'active'
CHECK (status IN ('active', 'at_risk', 'suppressed'))
);
-- При soft bounce:
UPDATE bounce_tracker
SET consecutive = consecutive + 1,
last_bounce_at = NOW(),
status = CASE
WHEN consecutive + 1 >= 3 THEN 'suppressed'
WHEN consecutive + 1 >= 2 THEN 'at_risk'
ELSE status
END
WHERE email = 'user@example.com';
-- При успешной доставке:
UPDATE bounce_tracker
SET consecutive = 0,
last_success_at = NOW(),
status = 'active'
WHERE email = 'user@example.com';Три - разумный порог для большинства случаев. Некоторые ESP (Mailchimp, например) используют порог 5, но это слишком мягко. Пока вы ждёте пятого bounce, адрес уже тянет вашу репутацию вниз.
Cron, очереди и мониторинг
Webhook работает в реальном времени, парсер логов - по расписанию. Для парсера нужен cron. Минимальная настройка:
# crontab -e
# Парсить логи каждый час
0 * * * * /usr/bin/python3 /opt/scripts/parse_postfix_bounces.py >> /var/log/bounce-parser.log 2>&1
# Синхронизировать suppression list каждый час (через 10 мин после парсера)
10 * * * * /usr/bin/python3 /opt/scripts/suppress_bounced.py >> /var/log/bounce-suppress.log 2>&1
# Еженедельный отчёт: сколько адресов заблокировано за неделю
0 9 * * 1 /usr/bin/python3 /opt/scripts/bounce_report.py | mail -s "Bounce report" ops@yourdomain.comДля webhook-обработчика важнее другое: мониторинг доступности. Если ваш endpoint лёг, ESP будет повторять запросы какое-то время (SendGrid - 72 часа, Mailgun - 24), а потом перестанет. Bounce-ы за это время потеряются. Поставьте uptime-чек на endpoint и алерт в Slack/Telegram, если он не отвечает.
Если нагрузка высокая (десятки тысяч писем в день), не обрабатывайте bounce-ы синхронно в webhook-хэндлере. Запишите событие в очередь (Redis, RabbitMQ, SQS) и обработайте отдельным воркером. Так endpoint отвечает за миллисекунды, а воркер разбирается с базой данных в своём темпе.
Пять ошибок, которые ломают автоматику
Нет дедупликации. ESP может отправить одно и то же событие дважды (retry после таймаута). Без idempotency key или upsert вы получите дублирующие записи в suppression list и искажённую статистику.
DELETE вместо UPDATE. Физическое удаление подписчика уничтожает историю. Через месяц этот адрес может попасть в базу снова через форму регистрации, и вы начнёте отправлять на мёртвый ящик заново. Suppression list должен быть постоянным. Даже если адрес «ожил», проверьте его перед реактивацией.
Игнорирование soft bounce. «Это же временная ошибка, пройдёт само.» Не пройдёт, если ящик заброшен. Soft bounce без трекинга превращается в тихий урон репутации.
Нет проверки подписи webhook. Открытый endpoint без верификации - это приглашение для атаки. Злоумышленник может отправить поддельные bounce-ы и выбить живые адреса из вашей базы.
Ручной перезапуск вместо автоматики. Скрипт завершился с ошибкой - и никто не заметил две недели. За это время bounce rate подрос с 1.5% до 4%, а Gmail уже сдвинул ваши письма в «Промоакции». Автоматический алерт при сбое - не опция, а необходимость.
Превентивная валидация: уменьшить bounce до отправки
Обработка bounce - это реактивная мера. Вы уже отправили письмо, уже получили отказ, уже потеряли часть репутации. Лучшая стратегия - не допустить bounce вообще. Проверяйте адреса до отправки.
Валидация на этапе подписки (real-time check по API) убирает мёртвые адреса ещё до того, как они попали в базу. Bulk-валидация перед каждой крупной рассылкой вычищает адреса, которые умерли с последней проверки. По нашим данным, ежеквартальная валидация снижает bounce rate в 3-5 раз по сравнению с одной только автоматической обработкой bounce.
Идеальный pipeline выглядит так: валидация на входе (API-проверка при регистрации) + регулярная bulk-валидация + автоматическая обработка bounce через webhook и логи. Три слоя защиты. Каждый ловит то, что пропустил предыдущий.
Пример: полный pipeline на Python
Вот собранный воедино скрипт, который можно использовать как отправную точку. Он принимает webhook, парсит логи по расписанию и обновляет suppression list через единую функцию.
# bounce_pipeline.py - единая точка обработки bounce
import os, re, csv, json, logging
from datetime import datetime, timedelta
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("bounce")
class BounceProcessor:
def __init__(self, db_conn):
self.db = db_conn
def handle_hard(self, email: str, code: str, reason: str):
"""Hard bounce -> немедленная suppression."""
email = email.lower().strip()
cur = self.db.cursor()
cur.execute("""
INSERT INTO suppression_list (email, bounce_code, reason, created_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (email) DO UPDATE SET
bounce_code = EXCLUDED.bounce_code,
updated_at = NOW()
""", (email, code, reason))
cur.execute("""
UPDATE subscribers SET status = 'bounced', updated_at = NOW()
WHERE LOWER(email) = %s AND status = 'active'
""", (email,))
self.db.commit()
log.info(f"HARD suppress: {email} ({code})")
def handle_soft(self, email: str, code: str, reason: str):
"""Soft bounce -> инкремент счётчика, suppression после 3-го."""
email = email.lower().strip()
cur = self.db.cursor()
cur.execute("""
INSERT INTO bounce_tracker (email, consecutive, last_bounce_at)
VALUES (%s, 1, NOW())
ON CONFLICT (email) DO UPDATE SET
consecutive = bounce_tracker.consecutive + 1,
last_bounce_at = NOW(),
status = CASE
WHEN bounce_tracker.consecutive + 1 >= 3 THEN 'suppressed'
WHEN bounce_tracker.consecutive + 1 >= 2 THEN 'at_risk'
ELSE bounce_tracker.status
END
""", (email,))
# Проверяем, не пора ли suppress
cur.execute(
"SELECT consecutive FROM bounce_tracker WHERE email = %s",
(email,)
)
row = cur.fetchone()
if row and row[0] >= 3:
self.handle_hard(email, code, f"soft bounce x{row[0]}: {reason}")
else:
self.db.commit()
log.info(f"SOFT bounce #{row[0] if row else 1}: {email}")
def process_webhook_event(self, event: dict):
"""Обработка одного события из webhook."""
email = event.get("email") or event.get("recipient", "")
bounce_type = event.get("type", "hard")
code = str(event.get("code", event.get("status", "550")))
reason = event.get("reason", event.get("error", ""))
is_hard = bounce_type == "hard" or code.startswith("5")
if is_hard:
self.handle_hard(email, code, reason)
else:
self.handle_soft(email, code, reason)
def parse_postfix_log(self, path="/var/log/mail.log"):
"""Парсит лог Postfix и обрабатывает найденные bounce."""
pattern = re.compile(
r"to=<(?P<email>[^>]+)>.*status=bounced.*said:\s*"
r"(?P<code>\d{3})\s+(?P<detail>[^)]+)",
re.IGNORECASE,
)
count = 0
with open(path) as f:
for line in f:
m = pattern.search(line)
if m:
code = m.group("code")
if code.startswith("5"):
self.handle_hard(
m.group("email"), code, m.group("detail")
)
else:
self.handle_soft(
m.group("email"), code, m.group("detail")
)
count += 1
log.info(f"Parsed {count} bounce events from {path}")Что отслеживать после запуска
Автоматика работает, bounce обрабатываются. Какие метрики показывают, что система здорова?
Bounce rate по рассылкам. Должен быть ниже 2%. Если после внедрения автоматики он не упал - проблема не в обработке, а в источниках адресов.
Размер suppression list. Растёт слишком быстро - значит, в базу попадает много мусора. Растёт медленно - система работает, и источники чистые.
Задержка обработки. Время от bounce до suppression. Для webhook - секунды. Для лог-парсера - не больше часа. Если задержка растёт, проверьте очереди и cron.
Ложные срабатывания. Подписчики, попавшие в suppression по ошибке. Если кто-то жалуется, что перестал получать рассылку - проверьте логи. Ложные срабатывания случаются при временных сбоях DNS, когда soft bounce засчитывается как hard. Поэтому проверка подписи и корректный парсинг кодов - критичны.
Хотите сократить bounce rate до минимума? Проверьте свою базу перед следующей рассылкой в uChecker. Валидация на входе + регулярная очистка + автоматическая обработка bounce = bounce rate ниже 0.5%.
