Приём входящей почты в приложение: маршрутизация письма в webhook через transport в Postfix
IMAP-поллинг раз в минуту — это лаг, дубли и потерянные письма. Разбираем правильную архитектуру: отдельный transport в master.cf, коды sysexits.h как бесплатный ретрай-движок, честный парсинг грязного MIME и почему вложения не место в base64-JSON.
EvilMail Team1 августа 2026 г.12 мин чтения
Почти в каждом SaaS, который «умеет принимать письма», под капотом крутится один и тот же cron: раз в 60 секунд он логинится по IMAP, тянет непрочитанное, ставит флаг \Seen и отдаёт письма воркеру. Это работает ровно до первого прода. Задержка до минуты там, где пользователь ждёт мгновенной реакции. Гонка за \Seen, когда два инстанса поллера расхватывают один UID и шлют вебхук дважды. И тихая потеря письма, если воркер упал между «прочитал» и «обработал»: IMAP-сервер уже считает сообщение доставленным вам, а у вас его нет.
Самое обидное — вы переизобретаете то, что Postfix уже делает лучше вас. Он уже принял письмо на SMTP, уже прогнал проверки, уже положил в очередь и уже умеет ретраить с backoff. Не надо опрашивать ящик. Надо перехватить доставку на нужный адрес и отдать сырое письмо прямо в свой процесс. Ниже — как собрать это так, чтобы очередь MTA стала вашим движком ретраев, а не источником инцидентов.
Маршрутизация: transport_maps и запись в master.cf
Postfix решает судьбу каждого письма через таблицу транспортов. Заводим отдельный поддомен под приём в приложение — inbound.evilmail.pro, чтобы обычная почта и «письма-в-хук» не пересекались. В main.cf:
bash
transport_maps = hash:/etc/postfix/transport
В /etc/postfix/transport одна строка направляет весь домен на наш кастомный транспорт:
inbound.evilmail.pro webhook:
После правки таблицу надо скомпилировать и перечитать конфиг:
bash
postmap /etc/postfix/transport && postfix reload
Сам транспорт webhook описывается в master.cf через pipe(8) — механизм, который запускает внешнюю программу и скармливает ей письмо в stdin:
webhook unix - n n - 10 pipe
flags=DRhu user=mailhook null_sender=
argv=/opt/mailhook/deliver.py ${sender} ${recipient} ${nexthop}
Разберём по колонкам, потому что тут все спотыкаются. unix — тип сокета. Дальше - (private по умолчанию), затем n n — без chroot и без сброса до непривилегированного mail-owner при старте сервиса; реального пользователя доставки мы всё равно задаём через user= ниже. Число 10 — это maxproc, лимит одновременных доставок этим транспортом: при всплеске в 5000 писем у вас не форкнется 5000 процессов, очередь просто придержит остальное. pipe — сам сервис.
flags=DRhu: D добавляет заголовок Delivered-To (ключ к защите от петель, вернёмся к этому), R добавляет Return-Path, h приводит nexthop-хостнейм к нижнему регистру, u — то же для localpart получателя. user=mailhook — беспарольный служебный юзер без shell; ваш deliver.py запускается от него, а не от root. null_sender= оставляет пустой конверт-sender как есть: bounce-уведомления и автоответы приходят с пустым MAIL FROM:<>, и если этого не учесть, скрипт упадёт на попытке распарсить пустого отправителя.
Макросы ${sender} ${recipient} ${nexthop} Postfix подставит как аргументы. Если нужен приём не на весь домен, а на конкретные адреса, комбинируйте virtual_alias_maps с per-address записью в transport — но для приложения проще выделить поддомен целиком.
Коды возврата: очередь Postfix как ваш ретрай-движок
Это ядро всей затеи. pipe(8) смотрит на exit-код вашего скрипта и по нему решает, что делать с письмом. Коды берутся не с потолка, а из sysexits.h — и именно правильный маппинг превращает очередь Postfix в готовый механизм ретраев с экспоненциальным backoff.
0 (EX_OK) — возвращаем только после HTTP 2xx от вебхука. Письмо доставлено, удаляется из очереди.
75 (EX_TEMPFAIL) — на 5xx, таймаут или любую сетевую ошибку. Postfix заново ставит письмо в очередь и повторит позже с нарастающей задержкой. Вам не нужно писать свою очередь, свой Redis-лист, свой воркер ретраев — всё это уже есть.
69 (EX_UNAVAILABLE) — неустранимая ошибка вроде битого MIME, который никогда не распарсится. Письмо уйдёт в bounce, а не будет крутиться в очереди пять суток.
Смертный грех — вернуть 0 при сетевой ошибке «чтобы не спамило» или наоборот 75 на битом письме. В первом случае вы молча теряете почту, во втором — забиваете очередь вечными зомби.
Первая попытка повтора — через 5 минут, интервал растёт до ~66 минут, и через 5 суток безнадёжных попыток письмо отбивается отправителю с NDR. Ваши инструменты, когда что-то пошло не так:
bash
postqueue -p # что застряло и почему (см. колонку с ошибкой)
postcat -q 3F2A14B9C1 # прочитать конкретное письмо из очереди по QUEUEID
postsuper -r 3F2A14B9C1 # принудительно перегнать письмо (requeue)
tail -f /var/log/mail.log # живой лог доставки
Когда я разгребал очередь на 40 тысяч застрявших писем, postqueue -p с грепом по коду ошибки и postcat -q для сэмплов — это всё, что понадобилось, чтобы понять: вебхук отдавал 502, а скрипт корректно копил письма в deferred, а не терял их.
Парсинг MIME без иллюзий
Реальный MIME грязный. Клиенты присылают битые charset'ы, отсутствующие Content-Type, заголовки в encoded-words и вложения с именами на кириллице по RFC 2231. Python stdlib с современной policy справляется с большинством этого сама — не тащите тяжёлые парсеры, если не надо.
python
import sys
from email import policy
from email.parser import BytesParser
msg = BytesParser(policy=policy.default).parse(sys.stdin.buffer)
# policy.default сам декодирует =?UTF-8?B?...?= в заголовках
subject = str(msg["subject"] or "")
from_addr = str(msg["from"] or "")
# Тело: предпочесть text/plain, откатиться на html
body = msg.get_body(preferencelist=("plain", "html"))
text = body.get_content() if body else ""
attachments = []
for part in msg.walk():
if part.get_content_disposition() != "attachment":
continue
payload = part.get_payload(decode=True) # снимает base64/quoted-printable
attachments.append({
"filename": part.get_filename(), # RFC 2231 filename* декодируется сам
"content_type": part.get_content_type(),
"data": payload,
})
Ключевые моменты, на которых теряют письма. msg.walk() обходит всё дерево — нельзя «взять первый part», потому что структура обычно multipart/mixed → multipart/alternative → (text/plain + text/html) → attachment, и тело зарыто на два уровня вглубь. get_payload(decode=True) возвращает уже декодированные байты, а не base64-строку. Различайте inline-картинки (Content-ID, встроены в HTML) и настоящие вложения (Content-Disposition: attachment) — иначе к письму «прилипнут» логотипы из подписи. Для тела ловите LookupError/UnicodeDecodeError и делайте fallback на utf-8 с errors="replace", потом на latin-1. Полезные RFC под рукой: 2045–2047 (MIME и encoded-words), 2183 (Content-Disposition), 2231 (filename* с кодировкой и языком), 5322 (Message-ID и заголовки).
Вложения: почему не base64-в-JSON
Соблазн запихнуть вложение прямо в JSON как base64 понятен — один POST, ничего не надо хранить. Считаем цену. Письмо с PDF на 8 МБ после base64 раздувается на 33% (кодирование 3 байт в 4 символа) до ~10.6 МБ, и это ещё до экранирования внутри JSON-строки. Получатель ловит таймаут вебхука на приёме тела, а его JSON-парсер держит весь мегабайтный блоб в памяти — прямая дорога к OOM на пике.
Порог решения простой: inline base64 только для мелочи меньше 256 КБ, всё крупное — стримом в S3 или другое объектное хранилище, а в payload идёт presigned URL. При заливке считаем sha256 — он даёт и дедуп (одинаковые вложения не хранить дважды), и контроль целостности на стороне получателя. Обязательно ставим потолки: суммарный размер вложений и лимит на число MIME-part'ов, потому что MIME-бомба с тысячами вложенных multipart положит парсер задолго до того, как вы дойдёте до логики.
Два заголовка обязательны. X-Evilmail-Signature: sha256=<HMAC> считается по сырому телу запроса общим секретом — получатель верифицирует подпись до парсинга JSON, чтобы не пускать неаутентифицированные данные в свой код. Idempotency-Key: <Message-ID> (а если Message-ID отсутствует или дублируется — sha256 всего письма) защищает от повторной обработки: при ретрае Postfix пришлёт то же письмо ещё раз, и без ключа идемпотентности вы создадите дубль тикета или заказа.
Про SPF/DKIM/DMARC: скрипт их не проверяет. Проверки делают opendkim/opendmarc на уровне smtpd и пишут результат в заголовок Authentication-Results:. Ваш deliver.py только читает этот заголовок и прокидывает вердикт в payload. Письмо с dmarc=failпомечаем, а не тихо принимаем как валидное — решение о доверии принимает приложение, видя флаг.
Защита от петель и переполнения
Флаг D штампует на письмо Delivered-To. Если ваш обработчик отправит ответ на тот же inbound-адрес и письмо снова попадёт в этот транспорт, Delivered-To уже стоит — это маркер петли; вдобавок Postfix жёстко рубит бесконечные циклы по числу заголовков Received (hopcount_limit, по умолчанию 50). Отсюда правило: обработчик вебхука не должен отвечать на тот же inbound-адрес, иначе вы построите цикл своими руками. Дальше по гигиене: message_size_limit (разумно 25–50 МБ), мониторинг дискового давления на /var/spool/postfix и алерт на глубину очереди — когда postqueue -p показывает рост deferred, это первый сигнал, что вебхук лёг. Деливери-юзер mailhook — без shell и привилегий, желательно в изоляции.
Взрослый вариант: LMTP вместо pipe
Скажу честно: pipe(8) прост, но форкает процесс на каждое письмо и общается кодами возврата — на серьёзной нагрузке это накладно. Взрослый вариант — поднять маленький LMTP-сервер, который держит соединение и отвечает нормальными SMTP-кодами. В master.cf:
webhook unix - - n - - lmtp
а в таблице transport направляем домен на webhook:inet:127.0.0.1:2400. Тогда ваш демон отвечает 250 (принято → EX_OK), 451 (временно → requeue) или 550 (постоянно → bounce), что чище и однозначнее маппится на состояния очереди, чем exit-коды. Правило выбора: pipe — пока писем немного и хочется минимума движущихся частей; LMTP — когда доставок тысячи в минуту и нужен персистентный процесс.
Чеклист перед продом
postmap /etc/postfix/transport выполнен и postfix reload сделан.
deliver.py возвращает 75 на таймаут/5xx и 69 на битый MIME — проверено юнит-тестом.
HMAC-подпись X-Evilmail-Signature включена и считается по сырому телу.
Idempotency-Key прокинут (Message-ID или sha256 письма).
Вложения больше 256 КБ уходят в S3, в payload — presigned URL + sha256 + size.
dmarc=fail помечается в payload, а не принимается молча.
message_size_limit и лимит числа MIME-part выставлены.
Loop-protection проверена инъекцией письма на тот же адрес — цикл обрывается.
Настроен алерт на глубину postqueue -p.
End-to-end тест прошёл: swaks --to [email protected] --server localhost --attach @/tmp/test.pdf --header "Subject: hook test" дошёл до приложения с вложением из S3.