Віртуальні домени в Exim: routers і transports для доставки в Maildir кількох доменів
У Exim немає «магії віртуальних доменів» — є два незалежні шари, які початківці плутають. Router вирішує, кому належить адреса; transport вирішує, у який файл покласти лист. Розбираємо один робочий конфіг для N доменів із доставкою в Maildir++ і вчимося читати -bt замість того, щоб гадати.
EvilMail Team9 липня 2026 р.12 хв читання
Лист прийшов на [email protected], Exim відрапортував delivered, а Dovecot цей лист не бачить. Або гірше: файл ліг у Maildir, але власником став root, і IMAP-клієнт мовчить. Або зовсім класика — exim -bt [email protected] каже Unrouteable address, хоча домен «точно налаштований». Усі три симптоми — це не баги Exim. Це наслідок одного й того ж непорозуміння: люди шукають у Exim «підтримку віртуальних доменів» як окрему фічу, а її не існує.
У Exim є рівно два незалежні шари. Router відповідає на питання «кому належить ця адреса, чи взагалі її приймати, і який transport її обслуговує». Transport відповідає на питання «у який каталог і від імені якого користувача фізично покласти байти». Віртуальний домен — це просто domainlist плюс пара routerʼів, що ведуть в один appendfile-transport. Ніякої магії. І 90% проблем із доставкою на кілька доменів — це переплутаний порядок routerʼів, забутий domains = +virtual_domains або transport, що пише під root замість vmail
Віртуальні домени в Exim: routers і transports для Maildir | evilmail.pro — EvilMail Blog
.
Розберемо один конфіг, керований із бази, для довільної кількості доменів (на прикладі evilmail.pro і evilmail.cloud), який віддає пошту в /var/mail/vhosts/$domain/$local_part у форматі Maildir++, сумісному з Dovecot. І — головне — навчимося читати трасування, а не копіпастити чужий exim.conf.
Два шари, які всі плутають: router проти transport
Routerʼи в Exim виконуються згори вниз у порядку оголошення в секції begin routers. Кожен router для конкретної адреси або відхиляє її («не моє, передаю далі»), або приймає (accept), або переспрямовує (redirect), або фейлить (fail). Виконання зупиняється на першому accept/redirect, після якого стоїть no_more, або на fail. Якщо жоден router адресу не підхопив — отримуємо Unrouteable address і 550.
Запамʼятайте цю картинку: лівий шар вирішує *кому і чи*, правий — *куди і від імені кого*. Плутанина між ними породжує майже всі листи, що «доставилися, але зникли».
Як Exim дізнається, що домен «наш»: domainlist із lookup у БД
Тут проходить головна лінія розлому. Є local_domains — домени, під якими стоять реальні unix-акаунти (check_local_user, доставка в /home/user). І є virtual_domains — домени без жодного unix-користувача: усе живе в базі, а на диск пише один системний vmail з uid/gid 5000. Домен не можна тримати одночасно в обох списках — Exim спробує маршрутизувати його двома шляхами, і ви отримаєте недетермінований результат залежно від порядку routerʼів.
Список віртуальних доменів тягнемо прямо з таблиці virtual_domains:
hide mysql_servers = localhost/mailserver/mailuser/your_strong_db_password
domainlist virtual_domains = mysql;SELECT name FROM virtual_domains \
WHERE name='${quote_mysql:$domain}'
Два моменти, які не можна проґавити. hide перед mysql_servers ховає пароль із виводу exim -bP, щоб він не світився в дампі конфігу. І ${quote_mysql:$domain} — це не косметика. $domain приходить із конверта SMTP, тобто ним керує зовнішній відправник. Без екранування зловмисник підсуне вам домен на кшталт x' OR '1'='1, і lookup поверне зайве. ${quote_mysql:...} обгортає значення так, як цього чекає MySQL, і закриває інʼєкцію. Це правило діє для кожного$local_part і $domain у будь-якому SQL по всьому конфігу — без винятків.
Routerʼи по порядку: aliases → delivery → catch-all
Порядок оголошення = порядок виконання, тому послідовність тут не стилістична, а функціональна.
begin routers
virtual_aliases:
driver = redirect
domains = +virtual_domains
data = ${lookup mysql{SELECT goto FROM aliases \
WHERE address='${quote_mysql:$local_part@$domain}'}}
redirect_router = virtual_delivery
virtual_delivery:
driver = accept
domains = +virtual_domains
local_part_suffix = +*
local_part_suffix_optional
condition = ${lookup mysql{SELECT 1 FROM virtual_users \
WHERE email='${quote_mysql:$local_part@$domain}'}{yes}{no}}
transport = virtual_maildir
no_more
virtual_aliases іде першим і розкриває aliases та форварди: якщо в таблиці aliases для адреси є goto, redirect замінює адресу на реальну скриньку і передає її далі (redirect_router = virtual_delivery). Якщо lookup порожній — router просто пропускає адресу до наступного.
virtual_delivery приймає адресу, але тільки якщо condition підтвердив, що такий email реально існує в virtual_users. Ось критична деталь, яку постійно забувають: без цієї перевірки accept прийме будь-що на вашому домені, transport створить каталог, і ви отримаєте тихий приймач спаму замість чесного 550 unknown user. no_more в кінці гарантує, що після цього router нижчі не запускаються — інакше alias-домен ризикує ще й «прийнятися» повторно.
Зверніть увагу на domains = +virtual_domains у *кожному* router. Забудете в одному — і цей router почне ловити адреси чужих доменів. Це друга за популярністю причина дивної маршрутизації після переплутаного порядку.
Transport для Maildir: appendfile, який Dovecot зрозуміє
Router вирішив «так, доставляємо». Тепер transport кладе байти на диск.
begin transports
virtual_maildir:
driver = appendfile
directory = /var/mail/vhosts/$domain/${local_part}
maildir_format
maildir_use_size_file = true
create_directory
directory_mode = 0700
delivery_date_add
envelope_to_add
return_path_add
user = vmail
group = vmail
mode = 0600
quota = ${lookup mysql{SELECT quota FROM virtual_users \
WHERE email='${quote_mysql:$local_part@$domain}'}{$value}{0}}
quota_warn_threshold = 90%
maildir_format перемикає appendfile з mbox на Maildir. Чому не mbox? Maildir не потребує блокувань файлу: кожен лист — окремий файл, конкурентна доставка не псує скриньку, а падіння процесу посеред запису не руйнує всю поштову скриньку. Саме тому Dovecot із ним дружить без бубна.
Maildir++ — це структура, яку Dovecot очікує побачити: три підкаталоги tmp/, new/, cur/ плюс файл maildirsize. Лист спершу пишеться в tmp/, потім атомарним rename() переїжджає в new/ (ось де атомарність — читач ніколи не бачить напівзаписаний файл), а коли IMAP-клієнт його відкрив, Dovecot переносить його в cur/. maildir_use_size_file = true веде maildirsize для швидкого підрахунку квоти без сканування всіх файлів.
Ключові рядки, які рятують від «листа під root»: user = vmail, group = vmail. Без них appendfile спробує писати від імені того, під ким крутиться Exim (часто root або Debian-exim), Dovecot не зможе прочитати чужі файли, і скринька виглядатиме порожньою. directory_mode = 0700 і mode = 0600 тримають права так, як їх хоче Dovecot: каталоги 0700, файли 0600, усе під 5000:5000.
І застереження про безпеку: у directory стоїть $local_part, який приходить із конверта. Оскільки маршрут уже пройшов через condition-перевірку існування в virtual_users, ми доставляємо лише адреси, що реально є в базі — довільний ../ у local_part просто не пройде router. Ніколи не будуйте шлях transport на неперевіреному $local_part в обхід цієї перевірки.
Ця схема і є вся суть «віртуальності»: рядок у таблиці проєктується у шлях на диску. Нічого магічного — просто lookup плюс appendfile.
Квоти, plus-addressing і catch-all без болю
Квота підтягується тим самим lookupʼом: якщо virtual_users.quota порожня, дефолт {0} означає «без ліміту». Коли скринька заповнена, Exim віддає SMTP 452 (тимчасова відмова — відправник ретраїть), а не bounce, тож користувач встигне почистити пошту. quota_warn_threshold = 90% шле попередження на 90% заповнення.
Plus-addressing уже вбудований у router вище: local_part_suffix = +* разом із local_part_suffix_optional роблять так, що [email protected] доставляється в скриньку user, а +github лишається доступним у заголовках для фільтрів. Це безкоштовна фіча — жодної окремої скриньки під кожен тег не треба.
Catch-all, якщо він потрібен, — це окремий router нижче за virtual_delivery, а не заміна умови в ньому:
virtual_catchall:
driver = accept
domains = +virtual_domains
condition = ${lookup mysql{SELECT goto FROM aliases \
WHERE address='${quote_mysql:@$domain}'}{yes}{no}}
transport = virtual_maildir
Порядок тут вирішує все. Якщо catch-all опиниться *вище* за delivery, він проковтне геть усе, включно з неіснуючими адресами, і ви ніколи не побачите 550. Класична пастка «тиша замість bounce».
Діагностика: -bt, -d+route і реальна доставка
Найважливіше правило: не гадайте, трасуйте. Exim показує свою логіку сам.
bash
# Який router підхопить адресу і в який transport піде (БЕЗ доставки):
exim -bt [email protected]
# Повне трасування умов і lookupʼів — видно кожен SELECT:
exim -d-all+route -bt [email protected]
# Перевірити lookup ізольовано, поза маршрутизацією:
exim -be '${lookup mysql{SELECT name FROM virtual_domains WHERE name='"'"'evilmail.pro'"'"'}}'
# Живий SMTP-тест наскрізь:
swaks --to [email protected] --server 127.0.0.1 --from [email protected]
# Лог-рядок конкретного повідомлення / стан черги:
exim -Mvl <msgid>
exim -bp
# Факт доставки на диск:
ls -la /var/mail/vhosts/evilmail.pro/user/new/
exim -bt — перше, що ви запускаєте після будь-якої зміни конфігу. Він за секунду каже, чи домен взагалі впізнається і який transport спрацює. Якщо бачите Unrouteable address — домен не потрапив у domainlist virtual_domains, перевіряйте exim -be з тим самим SELECT.
Симптом → причина, які закривають більшість тікетів:
`Unrouteable address` → домену немає в domainlist virtual_domains (або лист доменів кешується — перезапустіть Exim).
Лист належить root, Dovecot не бачить → у transport забуто user = vmail / group = vmail.
Тиша замість `550` → catch-all router стоїть вище за delivery, або в delivery немає condition-перевірки існування.
`Permission denied` під час доставки → каталог /var/mail/vhosts не належить 5000:5000; полагодьте chown -R vmail:vmail.
Чекліст перед продакшеном
exim -bt для реальної та завідомо неіснуючої адреси кожного домену — перша дає transport, друга дає 550.
Кожен віртуальний домен є в virtual_domains і немає в local_domains.
У кожному router стоїть domains = +virtual_domains; delivery-router має no_more.
Кожен $local_part/$domain у SQL обгорнутий у ${quote_mysql:...}; mysql_servers під hide.
Порядок routerʼів: aliases → delivery → catch-all (catch-all тільки нижче).
У transport стоять user = vmail, group = vmail, directory_mode = 0700, mode = 0600.
/var/mail/vhosts та все під ним належить 5000:5000 (ls -la для перевірки).
swaks кладе живий лист, і він зʼявляється в .../new/, а Dovecot показує його по IMAP.
Значення vmail uid/gid збігаються з тим, що очікує Dovecot (mail_uid/mail_gid = 5000).
Коли ці дев'ять пунктів зелені, доставка на десять доменів працює так само надійно, як на один — бо для Exim це та сама пара шарів, просто з довшим списком у domainlist.