Алиасы и catch-all в Postfix через PostgreSQL: порядок разрешения и защита от циклов
Postfix не выбирает «лучший» алиас — он делает предсказуемый key-fallback от полного адреса к @домену и рекурсивно раскрывает результат. Разбираем схему таблицы virtual_aliases, порядок разрешения адресов, три предохранителя против петель и отладку через postmap -q.
EvilMail Team11 июля 2026 г.12 мин чтения
Ночью очередь Postfix распухла с сотни сообщений до сорока с лишним тысяч, и в mail.log одна и та же строка бежала сплошным потоком: mail forwarding loop. Виновата была одна строка таблицы алиасов — catch-all @evilmail.pro → [email protected], где [email protected] не был ни виртуальным ящиком, ни явным алиасом. Postfix брал письмо на несуществующий адрес, раскрывал его в support@, снова не находил такого получателя, снова матчил на @evilmail.pro — и так по кругу, пока не упирался в hop count.
Алиасы и catch-all в PostgreSQL — это не «гибкая пересылка». Это предсказуемый порядок разрешения адреса и три предохранителя от рекурсии. Держите в голове эти две вещи — и петля становится невозможной по построению, а не по удаче.
Схема данных, которую не стыдно показать
Одна таблица, одна пара source → destination
Postfix virtual_alias_maps + PostgreSQL: приоритеты и защита от циклов — EvilMail Blog
на строку. Никакой магии.
sql
CREATE TABLE virtual_aliases (
id BIGSERIAL PRIMARY KEY,
domain_id BIGINT NOT NULL REFERENCES virtual_domains(id) ON DELETE CASCADE,
source VARCHAR(255) NOT NULL,
destination VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(source, destination)
);
CREATE INDEX idx_alias_source ON virtual_aliases(source) WHERE active;
Несколько строк с одинаковым source — это фан-аут: Postfix склеит все destination через запятую и доставит письмо каждому. Так [email protected] уезжает трём людям без всяких групп в LDAP.
UNIQUE(source, destination) защищает от дублей одной и той же пары, но намеренно разрешает несколько адресатов на один источник. Индекс — частичный, WHERE active, потому что запрос Postfix всегда фильтрует по active = true; неактивные строки в индексе только раздувают его и замедляют вставку.
Обратите внимание, чего в схеме нет: колонки priority. Её нет не по забывчивости. Приоритет между «точным алиасом» и «catch-all» решается не в SQL — его определяет сам Postfix порядком поиска ключа. Об этом ниже, и это самая важная часть.
Catch-all живёт в этой же таблице обычной строкой: source = '@evilmail.pro', destination = '[email protected]'.
Подключаем virtual_alias_maps к PostgreSQL
Одно правило перекрывает по важности всё остальное в статье: домен не должен одновременно находиться в virtual_alias_domains и virtual_mailbox_domains. Если он в обоих списках, логика раскрытия ломается непредсказуемо — Postfix то ищет ящик, то раскрывает алиас, в зависимости от того, какой lookup сработал первым. Держите домен ровно в одном из этих списков.
Сам коннектор к базе — отдельный файл /etc/postfix/pgsql/virtual_alias_maps.cf:
ini
user = mailadmin
password = your_strong_db_password
hosts = 127.0.0.1
dbname = mailserver
query = SELECT destination FROM virtual_aliases WHERE source='%s' AND active=true
%s — это адрес, который Postfix подставляет при поиске. Его экранирует движок pgsql-таблицы, а не строка SQL, так что инъекции через адрес получателя тут нет.
Пароль лежит открытым текстом, поэтому права на файл строго 640 root:postfix:
После правки .cf или строк с map в main.cf перезапуск не нужен — достаточно postfix reload. restart тут избыточен и рвёт активные соединения.
Порядок разрешения: почему точный алиас всегда бьёт catch-all
Вот механика, из-за которой вся статья и написана. Когда Postfix разрешает [email protected] в виртуальном домене, он делает не один lookup, а серию — key-fallback в жёстко зафиксированном порядке:
1.[email protected] — точный адрес с расширением (только если задан recipient_delimiter)
2.[email protected] — точный адрес; сюда же Postfix сам сбрасывает запрос, отрезав +tag
3.@evilmail.pro — catch-all
Первый непустой ответ выигрывает, дальнейшие ключи не проверяются. Значит, точный алиас автоматически обходит catch-all — не потому что он «важнее», а потому что его ключ проверяется раньше. Пока обе строки живут в одной таблице и запрос матчит по source = '%s', настраивать ничего не нужно: [email protected] найдётся на шаге 2 и до catch-all дело не дойдёт.
Заметьте, чего в этой цепочке нет: поиска по «голой» локальной части alice без домена. Такой lookup Postfix делает только для доменов из mydestination/myorigin, но не для виртуальных — поэтому в virtual-схеме на него закладываться нельзя.
Когда нужен явный контроль, порядок задают не в SQL, а расщеплением на два map-файла:
Здесь работает то же правило «первый непустой ответ побеждает», но уже между файлами: Postfix спрашивает exact.cf, и только если тот вернул пусто — идёт в catchall.cf. Это чище, чем держать всё в одной таблице, потому что запрос catch-all можно физически изолировать и добавить в него исключения.
Две тонкости, о которые спотыкаются.
Рекурсия. Результат алиаса сам прогоняется через maps заново. Если a@d → b@d, а b@d → c@d, то письмо на a@d уедет на c@d. Это удобно ровно до тех пор, пока цепочка не замкнётся сама на себя.
Расширения адреса. С recipient_delimiter = + Postfix сам обрабатывает +tag: сначала пробует ключ [email protected] (можно завести под конкретный тег отдельную строку и увести его в свой ящик), затем автоматически [email protected]. Оба ключа идут в %s, поэтому запрос source='%s' штатно ловит и теговые, и базовые адреса — теговое письмо садится на базовый алиас, а не проваливается в catch-all. %u/%d в запросе нужны для других задач (например, матчить строку только по домену), а не для отрезания расширения.
Catch-all без выстрела в ногу
Тот самый ночной баг сводится к одному правилу: адресат catch-all обязан быть реальным `virtual_mailbox` или явным алиасом на реальный ящик. Если @evilmail.pro → [email protected], а support@ не существует ни как ящик, ни как строка алиаса, то раскрытие support@ снова доходит до ключа @evilmail.pro, снова матчит catch-all — и вы получили петлю внутри одного письма.
Три антипаттерна, которые надо знать в лицо:
Самоматч catch-all.@domain → x@domain, где x@domain не ящик и не алиас. Разбирали выше — самый частый способ убить очередь.
Ping-pong.a@d → b@d и b@d → a@d. Две встречные строки, каждая по отдельности выглядит безобидно.
Внешний бумеранг. catch-all на внешний адрес, который настроен форвардить обратно к вам. Рекурсия внутри maps его не поймает — сработает уже hop count по Received-заголовкам.
На evilmail catch-all всегда живёт в отдельном map-файле, и приёмный адрес в нём явно исключён из самого catch-all-запроса, чтобы письмо на приёмник не могло снова притянуться к @domain.
Защита от циклов: три предохранителя Postfix
Даже с идеальной таблицей нужна страховка на случай человеческой ошибки. У Postfix её ровно три параметра, и важно понимать, что каждый ловит свой тип петли.
`virtual_alias_recursion_limit` (дефолт 1000) — максимальная глубина рекурсивного раскрытия внутри виртуальных maps. Режет цепочку a→b→c→... в пределах одного письма.
`virtual_alias_expansion_limit` (дефолт 1000) — максимальное число адресов, в которое может развернуться один алиас. Ограничивает ширину фан-аута, чтобы одна строка не расползлась в тысячи адресатов.
`maximal_hop_count` (дефолт 50) — ловит межтранспортные петли по числу Received-заголовков. Именно он выдаёт too many hops и mail forwarding loop, когда письмо ходит между серверами.
Дефолты в 1000 разумны для «не мешать легальным цепочкам», но чудовищны для отладки: к моменту, когда рекурсия упрётся в тысячу, очередь уже распухнет. Для среднего домена без сложных списков рассылки понизьте пороги так, чтобы ошибка всплывала раньше, чем накопится мусор:
С этими значениями кривой алиас упрётся в лимит на десятках писем, а не на десятках тысяч, и вернёт отправителю внятный bounce. В mail.log при этом появится строка с именем сработавшего лимита — сразу видно, ловить рекурсию внутри maps или межсерверную петлю. Текущие дефолты своей сборки проверьте через postconf -d virtual_alias_recursion_limit: они изредка отличаются между версиями.
Отладка: postmap -q и что искать в логах
Не гадайте, что вернёт база — спросите Postfix ровно тем же lookup, каким он резолвит адрес:
bash
# точный адрес
postmap -q [email protected] pgsql:/etc/postfix/pgsql/virtual_alias_maps.cf
# catch-all — кавычки обязательны, @ в начале
postmap -q "@evilmail.pro" pgsql:/etc/postfix/pgsql/virtual_alias_maps.cf
# что реально подключено и в каком порядке
postconf virtual_alias_maps
postconf -M
Если postmap -q для точного адреса пуст, а для @evilmail.pro возвращает адресата — значит все письма домена садятся на catch-all, и точные алиасы либо неактивны, либо не матчатся. Проверьте гипотезу прямым запросом в psql, тем же условием, что в .cf:
sql
SELECT destination FROM virtual_aliases
WHERE source='[email protected]' AND active=true;
Логи и очередь:
bash
grep "mail forwarding loop" /var/log/mail.log
grep "too many hops" /var/log/mail.log
grep -i "pgsql" /var/log/mail.log # ошибки коннекта к базе
postqueue -p # он же mailq — сколько застряло
postsuper -d ALL # снос очереди целиком, только осознанно
postsuper -d ALL — крайняя мера при уже забитой петлёй очереди; сначала убедитесь через postqueue -p, что там именно петлевой мусор, а не легитимная почта, ждущая ретрая.
Чеклист перед продакшеном
Домен присутствует только в одном из virtual_alias_domains / virtual_mailbox_domains, не в обоих.
Цель catch-all — реальный virtual_mailbox или явный алиас на реальный ящик, а не адрес того же домена «в никуда».
virtual_alias_expansion_limit и virtual_alias_recursion_limit понижены (≈50 и ≈30), чтобы ошибка всплывала раньше распухания очереди.
Права на .cf с паролем — 640 root:postfix, проверено ls -l.
Частичный индекс WHERE active на source создан, UNIQUE(source, destination) на месте.
postmap -q для точного адреса и для @домен возвращает ровно то, что ожидаете.
После тестового письма на заведомо несуществующий адрес grep "mail forwarding loop" по логу чист.
Если нужен явный приоритет — catch-all вынесен в отдельный map (virtual_alias_maps = pgsql:exact.cf, pgsql:catchall.cf), приёмник исключён из его запроса.