Свой SMTP API поверх Postfix: приём по HTTP, очередь, статусы и вебхуки о доставке
Postfix отлично шлёт почту, но не умеет ни принимать задание по HTTP, ни честно рассказывать о доставке. Собираем недостающий слой: тонкий HTTP-приёмник, синхронный Queue ID, трейсинг по DSN и вебхуки — с разбором, где именно status=sent врёт про «доставлено».
EvilMail Team1 августа 2026 г.14 мин чтения
У вас есть Postfix, который годами без нареканий доставляет почту. А у бэкенда — одно желание: дёрнуть POST /v1/messages, получить 202 Accepted с message_id, а через пару минут — вебхук delivered или bounced. Ни первого, ни второго Postfix из коробки не умеет: он не принимает задания по HTTP и не отдаёт статус доставки наружу. Хорошая новость в том, что достраивать собственный SMTP-стек (Haraka, свой парсер MIME, свою очередь на диске) не нужно — почти всё, что требуется для честной обратной связи, Postfix уже знает. Надо лишь аккуратно вытащить это наружу. Ниже — рабочая архитектура и три места, где новички стабильно ловят грабли: сдача письма мимо очереди, вера в status=sent и попытка ловить доставку грепом логов.
Архитектура: три компонента и одна граница ответственности
Слой над Postfix — это ровно три вещи. Первая: HTTP-приёмник (Node, Go, Python — неважно), который валидирует JSON, собирает MIME и сдаёт письмо в Postfix. Вторая: сам Postfix как очередь, ретраи и доставщик до чужих MX. Третья: обработчик статусов, который парсит DSN-отчёты и стреляет вебхуками. Граница ответственности здесь жёсткая, и её нарушение — источник большинства бед.
Ключевая мысль: приёмник не должен сам говорить SMTP по TCP на 25-й порт к чужим MX. Он сдаёт письмо локально — либо через бинарник sendmail, либо инжектом в локальный submission-listener на 127.0.0.1. Очередь, ретраи с backoff, TLS-переговоры с удалённой стороной, MX-резолвинг — это работа Postfix, и она уже сделана надёжно. Не переизобретайте очередь на Redis поверх MTA, у которого есть проверенная временем очередь на диске с гарантиями fsync. Единственное, что действительно приходится строить самому, — это обратный канал: как узнать, что стало с письмом после того, как Postfix его принял.
Приём задания по HTTP и постановка в очередь
Сдать письмо в Postfix можно двумя способами, и они не равноценны. Первый — через sendmail:
Здесь -G помечает сообщение как пришедшее из внешнего источника (gateway submission — Postfix применит к нему нужные ограничения), -i запрещает трактовать одиночную точку в строке как конец ввода, -f задаёт envelope-sender. Проблема одна, но серьёзная: Queue ID синхронно не возвращается. Даже с -v его нет в stderr — придётся потом искать письмо в maillog по Message-ID, а это гонка и лишняя латентность.
Второй способ — прямой SMTP-инжект в локальный listener — предпочтителен именно потому, что Queue ID приходит в ответе синхронно. Когда вы отправляете завершающую точку, Postfix отвечает:
250 2.0.0 Ok: queued as 4Wq8xR2mNz
4Wq8xR2mNz — это и есть Queue ID, который вы сразу пишете в БД как provider_id для задания. Никакого грепа логов. Для этого в master.cf поднимается отдельный внутренний приёмник без аутентификации, доступный только с петли:
127.0.0.1:10025 inet n - y - - smtpd
-o smtpd_authorized_xforward_hosts=127.0.0.0/8
-o smtpd_client_restrictions=permit_mynetworks,reject
-o smtpd_recipient_restrictions=permit_mynetworks,reject
-o cleanup_service_name=cleanup
-o receive_override_options=no_unknown_recipient_checks
Инжект на стороне приёмника — обычный SMTP-диалог по сокету:
python
import smtplib, uuid
msg_id = uuid.uuid4().hex
mime["X-Api-Message-Id"] = msg_id # трейс-заголовок переживёт всю дорогу
with smtplib.SMTP("127.0.0.1", 10025) as s:
s.ehlo("api")
s.mail(sender)
s.rcpt(rcpt, options=["NOTIFY=SUCCESS,FAILURE,DELAY", f"ORCPT=rfc822;{rcpt}"])
code, text = s.data(mime.as_string()) # data() сам делает dot-stuffing
# text == b"2.0.0 Ok: queued as 4Wq8xR2mNz"
queue_id = text.decode().rsplit("queued as ", 1)[-1]
Два момента про идемпотентность и трейсинг. UUID X-Api-Message-Id генерируется на входе HTTP-приёмника, до постановки в очередь, и сразу возвращается клиенту в теле 202 Accepted {"message_id": "..."}. Повторный запрос с тем же идемпотентным ключом не должен создавать второе письмо — проверка по этому ключу в БД до инжекта. И этот же UUID уезжает кастомным заголовком внутри письма: он вернётся к вам целиком внутри DSN-отчёта (оригинал письма прикладывается к отчёту о недоставке), и именно по нему вы свяжете входящий bounce с исходным заданием. Queue ID для этого не годится — он живёт только пока письмо в очереди этого Postfix.
Отслеживание статуса: почему log ≠ доставка
Разберём жизнь письма по Queue ID в /var/log/mail.log. Менеджер очереди берёт письмо, транспорт smtp отдаёт его удалённому MX, и вы видите победную строку:
Вот здесь — самая частая и дорогая ошибка. `status=sent` не означает «письмо в инбоксе». Оно означает ровно одно: следующий хоп (в данном случае входящий MX Gmail) принял на себя ответственность за письмо и ответил 250. Что дальше — попадёт ли оно в Inbox, в Spam, отфильтруется ли молча, отскочит ли асинхронным bounce через десять минут — из этой строки не следует ничего. Строить статус delivered на status=sent — значит показывать клиенту зелёную галочку там, где письмо ушло в спам или вот-вот отскочит.
Честных источников обратной связи два, и оба не про греп логов. Первый — DSN (Delivery Status Notification, RFC 3464). Запрашивая NOTIFY=SUCCESS,FAILURE,DELAY в команде RCPT (или -N success,failure,delay у sendmail), вы просите удалённую сторону прислать отчёт о фактической судьбе письма — включая успешную доставку. Второй — локальные bounce, которые Postfix генерирует сам, когда доставка окончательно провалилась после всех ретраев. И то и другое приходит к вам обычным письмом, которое надо перехватить и распарсить.
DSN — это письмо с Content-Type: multipart/report; report-type=delivery-status. Внутри три части: человекочитаемое пояснение, машинная часть message/delivery-status и оригинал (или его заголовки). Машинная часть — то, ради чего всё затевалось:
Action бывает delivered, failed, delayed, relayed, expanded. Status — это enhanced status code по RFC 3463, и именно он несёт причину. А в третьей части лежит оригинал письма с вашим X-Api-Message-Id — ключ для связывания с заданием.
Ловим DSN и bounce обратно в приложение
Здесь важно не перепутать два встречных потока. Удалённые DSN (в том числе успешные Action: delivered) приходят на envelope-sender письма, то есть на его Return-Path. Поэтому Return-Path задавайте не обычным [email protected], а выделенным VERP-адресом (например, bounce+<queue_id>@evilmail.pro), замапленным на транспорт-pipe. Собственные уведомления Postfix — двойные баунсы и предупреждения о задержке — заворачиваются туда же через *_notice_recipient. Оба потока сходятся в одном хендлере.
Транспорт-pipe в master.cf:
apihook unix - n n - - pipe
flags=Rq user=apihook argv=/opt/api/dsn-consumer ${sender} ${recipient}
Собственные уведомления Postfix маршрутизируем на него через main.cf:
плюс transport_maps (или запись в /etc/aliases), направляющая и dsn@localhost, и VERP-домен возвратов в транспорт apihook. Хендлер читает письмо из stdin, разбирает message/delivery-status, достаёт Action и Status, находит оригинал по X-Api-Message-Id и обновляет строку задания. Маппинг enhanced-кодов на ваши состояния — сердце обработчика:
2.0.0 — доставлено → delivered (терминал)
4.2.2 — ящик переполнен, временно → deferred, ждём ретрая
Разница между 4.x.x и 5.x.x — это разница между «попробуем ещё» и «всё, конец». Класс 4 — временный отказ, Postfix сам поставит письмо в deferred и повторит по своему расписанию; на вашей стороне это deferred, не финал. Класс 5 — постоянный, ретраить бессмысленно, для hard bounce типа 5.1.1 адрес надо немедленно занести в suppression-list, иначе следующая отправка на него ударит по репутации IP. Сырой DSN сохраняйте целиком — при спорных недоставках диагностический текст удалённого MX (Diagnostic-Code) единственное, что позволит понять, почему Gmail молча дропнул письмо.
Вебхуки о доставке
Каждая смена состояния задания порождает событие вебхука. Схема — плоский JSON:
json
{
"event_id": "evt_9fK2...",
"message_id": "3b1e9c...",
"event": "bounced",
"timestamp": "2026-07-04T10:22:41Z",
"recipient": "[email protected]",
"smtp_response": "550 5.1.1 The email account does not exist",
"diagnostic_code": "5.1.1"
}
event пробегает queued → delivered | deferred | bounced | complaint. Доставляется вебхук обычным POST на URL клиента, но с двумя обязательными защитами. Подпись: X-Signature: sha256=<hex(hmac(secret, timestamp + "." + body))> плюс X-Webhook-Timestamp — клиент проверяет HMAC и отвергает запросы старше 300 секунд, что закрывает replay. Ретраи: клиент может лежать, поэтому расписание с экспоненциальным backoff — 1m, 5m, 30m, 2h, 6h, после чего событие уходит в dead-letter и поднимает алерт. На стороне клиента дедупликация по event_id: сеть ненадёжна, один и тот же вебхук может прилететь дважды, и это нормально — идемпотентность обязана быть у получателя.
Отдельная, но критичная ветка — complaint-события. Когда пользователь Gmail или Yahoo жмёт «Спам», провайдер через FBL присылает ARF-отчёт (multipart/report; report-type=feedback-report) на ваш abuse@-адрес. Это тот же механизм перехвата, что и для DSN, только другой парсер, и это второй источник вебхуков — событие complaint. Игнорировать его нельзя: рост жалоб выше 0.1% в Google Postmaster Tools означает, что вы на пути к блокировке диапазона. Complaint должен так же немедленно гасить адрес в suppression-list, как и hard bounce.
Продакшн-чек-лист
Rate-limit на приёмнике. HTTP-слой — первая линия, где режется всплеск. Не давайте одному клиенту забить очередь миллионом писем за минуту.
Тюнинг исходящей отдачи.smtp_destination_concurrency_limit = 20, smtp_destination_rate_delay = 1s на прогреве нового IP, default_process_limit = 100. Спалить свежий IP залпом на Gmail — вопрос одной ошибки в цикле.
PTR / SPF / DKIM / DMARC на отправляющем IP — без этого status=sent будет массово превращаться в bounced 5.7.x и молчаливый спам. PTR должен резолвиться в имя, которое форвардом резолвится обратно в этот же IP (FCrDNS), и совпадать с HELO.
Лимиты размера:message_size_limit и header_size_limit выставлены осознанно, иначе большой аттач или раздутые заголовки роняют cleanup.
Отдельная транспорт-очередь для API-трафика через transport_maps — чтобы всплеск транзакционки не воевал за слоты с остальной почтой сервера.
Мониторинг очереди.postqueue -j даёт JSON по каждому письму (queue_id, sender, recipients, arrival_time, queue_name), mailq | tail -1 — для человека. Алерт на рост deferred — ранний сигнал проблем с репутацией или чужим MX.
TLS наружу:smtp_tls_security_level = dane там, где есть TLSA-записи под DNSSEC, с фолбэком на may. Оппортунистический TLS — минимум по умолчанию.
Forensics в одну строку. Логируйте связку queue_id ↔ api_message_id единой записью — когда через неделю прилетит спорный bounce, вы найдёте письмо по любому из ключей за секунды, а не будете сшивать три лога по времени.
Собранная связка даёт ровно то, что просил бэкенд: синхронный 202 с message_id на входе и честные асинхронные вебхуки delivered / deferred / bounced / complaint на выходе — без своего SMTP-стека, поверх Postfix, который всё это уже умеет, просто молчит об этом по умолчанию. Главное — не путать «Postfix передал письмо» с «письмо дошло»: между ними живёт вся ваша доставляемость.