Полностью переработанная версия статьи о Postfix для Debian 13 (Trixie). Основной сценарий — самостоятельный почтовый сервер: виртуальные домены и ящики, база PostgreSQL или MariaDB, PostfixAdmin, IMAPS, защищённая отправка, Rspamd и DKIM.
В конце статьи — автоматический установщик Mailstack (install.sh). Он разворачивает расширенный стек: веб-почта Roundcube, Sieve-фильтры и автоответы, ClamAV, лимиты отправки, проверки состояния и шифрованное резервное копирование. СУБД выбирается при установке: PostgreSQL (по умолчанию) или MariaDB. Ручная установка и установщик — альтернативы, выполнять оба на одном сервере нельзя.
В разделе 22 — Mailroom: собственная панель управления и веб-почта для этой схемы. Она объединяет управление доменами и ящиками, мастер DNS с проверкой DKIM, SPF и DMARC, рекомендации, очередь, трассировку писем, резервные копии, оповещения, веб-почту, календарь и контакты.
Что изменилось по сравнению со старой версией
Прежняя статья была написана для FreeBSD и Dovecot 2.3. Что исправлено:
- Команды FreeBSD (
portsnap,make config,rc.conf) заменены пакетами Debian и systemd. Пути —/etc/postfix,/etc/dovecot,/etc/postfixadmin. - Конфигурация Dovecot переписана для ветки 2.4. Старые
mail_location,disable_plaintext_auth, переменные%u/%d/%nи безымянные блокиpassdb/userdbбольше не работают; обязательныdovecot_config_versionиdovecot_storage_version.[1] - SMTP-аутентификацию выполняет Dovecot. Cyrus SASL и
saslauthdне нужны. - Письма виртуальным пользователям доставляются по LMTP через закрытый UNIX-сокет, а не внешним
pipe-процессом. - Убраны права
777, сокеты, доступные всем пользователям, MD5-пароли и RSA-ключи 1024 бит. Пароли ящиков хранятся в ARGON2ID. - Приём почты на порту 25 отделён от отправки клиентами на 587/465. Для клиентов TLS и авторизация обязательны.
- Добавлены DNS, PTR, SPF, DKIM, DMARC, обновление сертификатов, квоты, диагностика и резервное копирование.
- Добавлен выбор СУБД: PostgreSQL или MariaDB. SQL-запросы карт Postfix одинаковы для обеих.
- Уточнены роли: Postfix принимает и отправляет письма по SMTP, Dovecot отдаёт ящики по IMAP/POP3 и доставляет письма в них по LMTP. Ответ SMTP
queued as …означает постановку в очередь, а не доставку получателю.
Обещанная в старой версии настройка LDAP так и не была написана. В разделе о LDAP перечислено, что нужно решить до неё.
Схема работы
Входящее письмо:
Интернет → Postfix :25 → Rspamd → Dovecot LMTP → Maildir
Отправка пользователем:
Почтовый клиент → Postfix :587 STARTTLS / :465 TLS
↓ SASL через Dovecot → база (PostgreSQL / MariaDB)
Rspamd (проверка + DKIM-подпись) → Интернет
Чтение почты:
Почтовый клиент → Dovecot :993 TLS → Maildir
Управление:
Администратор → PostfixAdmin → базаСервер с собственными ящиками — не только «шлюз». Настоящий шлюз принимает письма и пересылает их на другой сервер, например Exchange. Такой вариант описан отдельно в разделе «Если нужен именно шлюз». Эти две конфигурации нельзя механически объединять.
Значения в примерах
| Параметр | Значение в статье |
|---|---|
| Почтовый домен | example.com |
| Имя сервера | mail.example.com |
| Публичный IPv4 | 203.0.113.10 — документационный адрес, не рабочий |
| Проверочный ящик | alice@example.com |
| Хранилище писем | /var/vmail |
| Служебный пользователь | vmail, UID/GID 5000 |
| База данных | postfixadmin |
| Пользователь БД приложения | pfa |
| Пользователь БД только для чтения | mailreader |
Все команды выполняются от root (sudo -i), если не сказано иное. В почтовом клиенте логином служит полный адрес ящика.
1. Подготовка сервера и DNS
Нужны статический публичный IPv4, управление DNS, возможность задать PTR у провайдера и открытый TCP/25 в обе стороны. Если исходящий 25 заблокирован, отправлять придётся через SMTP-relay провайдера. Если заблокирован входящий 25, приём почты из Интернета без внешнего шлюза невозможен.
Почтовый сервер лучше держать на отдельной машине. MX не должен указывать на HTTP-прокси или CDN: SMTP нужен прямой адрес.
DNS
example.com. IN MX 10 mail.example.com.
mail.example.com. IN A 203.0.113.10
example.com. IN TXT "v=spf1 ip4:203.0.113.10 -all"
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"- SPF с
-allподходит, только если этот IP — единственный источник отправки. Если письма уходят ещё через рассылки, CRM или relay, перечислите их в одной записиv=spf1. Две SPF-записи на одном имени — ошибка. - MX указывает на имя с A-записью, не на CNAME.
- AAAA публикуйте только при полностью работающем IPv6 (маршрутизация, firewall, PTR). В примерах IPv6 для Postfix выключен намеренно.
- DMARC начинайте с
p=none, создайте ящик или алиасdmarc@example.com, изучите отчёты и проверьте все легальные источники отправки. Затем переходите кquarantineиreject. DMARC проходит, когда домен видимогоFrom:совпадает с доменом, успешно прошедшим SPF или DKIM, — само наличие записей ничего не гарантирует.
У провайдера установите PTR: 203.0.113.10 → mail.example.com. Прямая A-запись должна возвращать тот же адрес.
Имя и время
hostnamectl set-hostname mail.example.com
apt update && apt upgrade
apt install systemd-timesyncd ca-certificates curl dnsutils openssl nano
systemctl enable --now systemd-timesyncd
timedatectl status
hostname -fЕсли уже работает chrony или другой NTP-клиент, оставьте его: два клиента времени не нужны. Не подменяйте публичное имя сервера записью 127.0.0.1 в /etc/hosts.
dig +short MX example.com
dig +short A mail.example.com
dig +short -x 203.0.113.10
dig +short TXT example.com2. Установка пакетов
Выберите СУБД. Набор пакетов отличается только драйверами базы:
PostgreSQL
apt install postgresql postfix postfix-pgsql \
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-pgsql \
apache2 libapache2-mod-php php-cli php-pgsql php-mbstring \
postfixadmin certbot swaks rsyslogMariaDB
apt install mariadb-server postfix postfix-mysql \
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-mysql \
apache2 libapache2-mod-php php-cli php-mysql php-mbstring \
postfixadmin certbot swaks rsyslogВ диалоге Postfix выберите Internet Site, почтовое имя — example.com. Если на сервере есть Exim, проверьте, что apt предлагает его удалить: два MTA не нужны.
В диалоге PostfixAdmin откажитесь от автоматической настройки БД через dbconfig-common: базу создадим вручную. Если она уже создана автоматически, не создавайте вторую схему с другими учётными данными — сначала разберитесь с /etc/postfixadmin/dbconfig.inc.php.
postconf mail_version
dovecot --version
apt-cache policy postfix dovecot-core postfixadmin
systemctl enable --now apache2 rsyslog
systemctl stop postfix dovecotВ Debian 13 используется Dovecot 2.4. Ориентируйтесь на фактически установленные версии, а не на номер из статьи.
На время настройки закройте SMTP/IMAP на внешнем firewall: сервисы с промежуточной конфигурацией не должны быть доступны из Интернета.
Резервная копия пакетной конфигурации
umask 077
BACKUP="/root/mail-config-$(date +%F-%H%M%S)"
mkdir -p "$BACKUP"
cp -a /etc/postfix /etc/dovecot /etc/postfixadmin /etc/apache2 "$BACKUP/"3. Пользователь и каталог хранения
Проверьте, что UID/GID 5000 свободны:
getent passwd 5000; getent group 5000
getent passwd vmail; getent group vmailЕсли заняты — выберите другие значения и замените их во всех настройках ниже.
groupadd --gid 5000 vmail
useradd --uid 5000 --gid vmail --home-dir /var/vmail \
--no-create-home --shell /usr/sbin/nologin vmail
install -d -o vmail -g vmail -m 0750 /var/vmailПароль vmail не нужен. Он владеет письмами всех виртуальных ящиков; учётные записи Linux для ящиков не создаются. Никаких chmod -R 777.
4. База данных и разделение доступа
Сгенерируйте два разных длинных пароля (openssl rand -hex 32) и сохраните их в менеджере паролей. Hex-пароль удобен тем, что его не нужно экранировать в конфигурационных файлах. Ниже PFA_DB_PASSWORD и MAIL_DB_PASSWORD — маркеры, а не значения.
PostfixAdmin нужны права на изменение таблиц и обновление собственной схемы. Postfix и Dovecot достаточно SELECT. Суперпользователя БД этим сервисам не давайте.
PostgreSQL
# Debian по умолчанию слушает только localhost — проверьте:
runuser -u postgres -- psql -Atc 'SHOW listen_addresses'
ss -lntp | grep ':5432'
# SQL-консоль без истории
runuser -u postgres -- env PSQL_HISTORY=/dev/null psqlMariaDB
# В /etc/mysql/mariadb.conf.d/50-server.cnf должно быть bind-address = 127.0.0.1
systemctl restart mariadb
ss -lntp | grep ':3306'
# SQL-консоль без истории
MYSQL_HISTFILE=/dev/null mariadbPostgreSQL
CREATE ROLE pfa LOGIN PASSWORD 'PFA_DB_PASSWORD';
CREATE ROLE mailreader LOGIN PASSWORD 'MAIL_DB_PASSWORD';
CREATE DATABASE postfixadmin OWNER pfa ENCODING 'UTF8' TEMPLATE template0;
REVOKE ALL ON DATABASE postfixadmin FROM PUBLIC;
GRANT CONNECT ON DATABASE postfixadmin TO mailreader;
\qMariaDB
CREATE DATABASE postfixadmin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'pfa'@'127.0.0.1' IDENTIFIED BY 'PFA_DB_PASSWORD';
GRANT ALL PRIVILEGES ON postfixadmin.* TO 'pfa'@'127.0.0.1';
CREATE USER 'mailreader'@'127.0.0.1' IDENTIFIED BY 'MAIL_DB_PASSWORD';
GRANT SELECT ON postfixadmin.* TO 'mailreader'@'127.0.0.1';
EXIT;В PostgreSQL право SELECT для mailreader выдаётся после создания таблиц — это шаг в конце раздела 5. В MariaDB учётная запись @127.0.0.1 работает при TCP-подключении к 127.0.0.1; не меняйте её на localhost без смены способа подключения.
5. PostfixAdmin
В пакете Debian веб-каталог — /usr/share/postfixadmin/public, настройки — /etc/postfixadmin. Свои параметры кладите в config.local.php: остальные файлы обновляются вместе с пакетом. SQL-примеры из README.Debian написаны для Dovecot 2.3, переносить их без адаптации нельзя.
/etc/postfixadmin/config.local.php:
<?php
$CONF['configured'] = true;
$CONF['default_language'] = 'ru';
$CONF['database_type'] = 'pgsql'; // для MariaDB: 'mysqli'
$CONF['database_host'] = '127.0.0.1';
$CONF['database_user'] = 'pfa';
$CONF['database_password'] = 'PFA_DB_PASSWORD';
$CONF['database_name'] = 'postfixadmin';
$CONF['encrypt'] = 'dovecot:ARGON2ID';
$CONF['dovecotpw'] = '/usr/bin/doveadm -O pw';
$CONF['domain_path'] = 'YES';
$CONF['domain_in_mailbox'] = 'NO';
$CONF['quota'] = 'YES';
$CONF['quota_multiplier'] = '1048576';
$CONF['emailcheck_resolve_domain'] = 'YES';
$CONF['password_validation'] = array('/.{12}/' => 'password_too_short 12');
$CONF['setup_password'] = 'REPLACE_WITH_SETUP_HASH';quota_multiplier = 1048576 означает, что квота в интерфейсе задаётся в МиБ, а в базе хранится в байтах. В старых инструкциях встречается 1024000 — это не мебибайт, и квоты получаются на 2,3 % меньше ожидаемых.
doveadm -O pw не читает конфигурацию Dovecot с секретами. Проверьте поддержку ARGON2ID и запуск от имени веб-сервера:
doveadm -O pw -l
runuser -u www-data -- /usr/bin/doveadm -O pw -s ARGON2IDПароль вводится интерактивно; полученный хеш никуда не публикуйте. Если ARGON2ID недоступен, выберите другую стойкую схему (например, SHA512-CRYPT) одновременно в PostfixAdmin и Dovecot. К MD5 не возвращайтесь.
chown root:www-data /etc/postfixadmin/config.local.php
chmod 0640 /etc/postfixadmin/config.local.php
php -l /etc/postfixadmin/config.local.phpПанель — только через SSH-туннель
На отдельном новом сервере замените /etc/apache2/ports.conf на:
Listen 127.0.0.1:80Если Apache уже обслуживает сайты, глобальные настройки не трогайте: сделайте отдельный virtual host с HTTPS и доступом только из VPN или с административных IP.
a2disconf postfixadmin
a2dissite 000-default/etc/apache2/sites-available/mailadmin.conf:
<VirtualHost 127.0.0.1:80>
ServerName localhost
DocumentRoot /usr/share/postfixadmin/public
<Directory /usr/share/postfixadmin/public>
Options -Indexes +FollowSymLinks
AllowOverride None
Require local
DirectoryIndex index.php
</Directory>
ErrorLog ${APACHE_LOG_DIR}/mailadmin-error.log
CustomLog ${APACHE_LOG_DIR}/mailadmin-access.log combined
</VirtualHost>a2ensite mailadmin
apache2ctl configtest
systemctl restart apache2
ss -lntp | grep ':80' # должно быть 127.0.0.1:80, не 0.0.0.0:80На своём компьютере откройте туннель и перейдите на http://127.0.0.1:8080/setup.php:
ssh -N -L 8080:127.0.0.1:80 admin@mail.example.comHTTP здесь допустим, потому что трафик идёт внутри SSH. Дальше:
- На странице настройки сформируйте хеш setup-пароля и подставьте его вместо
REPLACE_WITH_SETUP_HASH. - Создайте схему БД и администратора.
- Создайте домен
example.com, ящикalice@example.comи алиасыpostmaster@,abuse@,dmarc@на существующий ящик. - Закройте setup внутри virtual host и перезагрузите Apache:
<LocationMatch "^/(setup|upgrade)\.php$">
Require all denied
</LocationMatch>Для планового обновления схемы открывайте setup временно и только через административный канал. Наружу публикуется только public, а не весь /usr/share/postfixadmin.
Только для PostgreSQL: когда таблицы созданы, выдайте читателю права. ALTER DEFAULT PRIVILEGES нужен, чтобы таблицы, добавленные будущими обновлениями PostfixAdmin, тоже были доступны:
-- runuser -u postgres -- psql -d postfixadmin
GRANT USAGE ON SCHEMA public TO mailreader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mailreader;
ALTER DEFAULT PRIVILEGES FOR ROLE pfa IN SCHEMA public GRANT SELECT ON TABLES TO mailreader;Проверьте данные:
SELECT domain, active FROM domain;
SELECT username, maildir, quota, active FROM mailbox;
SELECT address, goto, active FROM alias;С этими настройками maildir нового ящика — example.com/alice/. Это домашний каталог ящика, сами письма лежат в его подкаталоге Maildir.
6. Сертификат TLS
Нужен публично доверенный сертификат для mail.example.com. Для HTTP-01 в режиме standalone нужны правильная A-запись и доступ извне к TCP/80. Apache у нас слушает только loopback, поэтому на время выпуска его останавливаем:
systemctl stop apache2
certbot certonly --standalone -d mail.example.com
systemctl start apache2
certbot certificatesЕсли certbot завершился ошибкой, запустите Apache вручную и устраните причину (частая — неверная AAAA-запись). Пути сертификата:
/etc/letsencrypt/live/mail.example.com/fullchain.pem
/etc/letsencrypt/live/mail.example.com/privkey.pemКаталог может получить суффикс, если сертификат уже выпускался, — берите фактический путь из certbot certificates. Права закрытого ключа не меняйте: Postfix и Dovecot читают его привилегированными процессами.
Самоподписанный сертификат годится только для лаборатории. Для внутреннего домена используйте свой CA или реальное DNS-имя с проверкой DNS-01: публичный сертификат для .loc не выпустить.
Автоматическое обновление
/etc/letsencrypt/renewal-hooks/pre/10-stop-mailadmin:
#!/bin/sh
set -eu
systemctl stop apache2/etc/letsencrypt/renewal-hooks/post/10-start-mailadmin:
#!/bin/sh
set -eu
systemctl start apache2/etc/letsencrypt/renewal-hooks/deploy/20-reload-mail (создайте после настройки Postfix и Dovecot):
#!/bin/sh
set -eu
postfix check
systemctl reload postfix
doveconf -n >/dev/null
systemctl reload dovecotПри DNS-01 или webroot останавливать Apache не нужно, pre/post-хуки тогда не создавайте.
chmod 0750 /etc/letsencrypt/renewal-hooks/pre/10-stop-mailadmin \
/etc/letsencrypt/renewal-hooks/post/10-start-mailadmin \
/etc/letsencrypt/renewal-hooks/deploy/20-reload-mail
systemctl enable --now certbot.timer
certbot renew --dry-runСухой прогон проверяет выпуск, но deploy-хуки в нём не обязательно выполняются — перезагрузку сервисов проверьте отдельно. Срок действия сертификата контролируйте мониторингом.
7. Postfix: основная конфигурация
Пакетный main.cf не перезаписываем: параметры ставим через postconf -e. Переменная MAP задаёт драйвер базы:
PostgreSQL
MAP=pgsqlMariaDB
MAP=mysqlpostconf -e 'myhostname = mail.example.com'
postconf -e 'mydomain = example.com'
postconf -e 'myorigin = $mydomain'
postconf -e 'mydestination = $myhostname, localhost.$mydomain, localhost'
postconf -e 'inet_interfaces = all'
postconf -e 'inet_protocols = ipv4'
postconf -e 'mynetworks = 127.0.0.0/8'
postconf -e 'relayhost ='
postconf -e 'relay_domains ='
postconf -e 'alias_maps = hash:/etc/aliases'
postconf -e 'alias_database = hash:/etc/aliases'
postconf -e 'recipient_delimiter = +'
postconf -e 'message_size_limit = 26214400'
postconf -e 'mailbox_size_limit = 0'
postconf -e 'disable_vrfy_command = yes'
postconf -e 'smtpd_helo_required = yes'
postconf -e 'smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination'
postconf -e 'smtpd_recipient_restrictions = reject_non_fqdn_recipient, reject_unknown_recipient_domain, reject_unlisted_recipient'
postconf -e 'smtpd_sasl_type = dovecot'
postconf -e 'smtpd_sasl_path = private/auth'
postconf -e 'smtpd_sasl_auth_enable = no'
postconf -e 'smtpd_sasl_security_options = noanonymous'
postconf -e 'smtpd_tls_auth_only = yes'
postconf -e 'smtpd_tls_security_level = may'
postconf -e 'smtpd_tls_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem'
postconf -e 'smtpd_tls_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem'
postconf -e 'smtpd_tls_protocols = >=TLSv1.2'
postconf -e 'smtpd_tls_mandatory_protocols = >=TLSv1.2'
postconf -e 'smtpd_tls_loglevel = 1'
postconf -e 'smtp_tls_security_level = may'
postconf -e 'smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt'
postconf -e 'smtp_tls_protocols = >=TLSv1.2'
postconf -e "virtual_mailbox_domains = proxy:$MAP:/etc/postfix/sql/domains.cf"
postconf -e "virtual_mailbox_maps = proxy:$MAP:/etc/postfix/sql/mailboxes.cf"
postconf -e "virtual_alias_maps = proxy:$MAP:/etc/postfix/sql/aliases.cf, proxy:$MAP:/etc/postfix/sql/alias-domains.cf"
postconf -e 'virtual_transport = lmtp:unix:private/dovecot-lmtp'
postconf -e "smtpd_sender_login_maps = proxy:$MAP:/etc/postfix/sql/sender-login.cf"Строки с $MAP в двойных кавычках, остальные — в одинарных, чтобы shell не подставил $mydomain и $myhostname.
Что здесь важно:
example.comне входит вmydestination: это виртуальный домен. Иначе письма уйдут в локальную доставку Linux.mynetworks— только loopback. Добавив туда офисную сеть, вы разрешите ей отправку без пароля; это отдельное осознанное решение.- Пересылку контролирует
smtpd_relay_restrictions.[2] - На порту 25 AUTH выключен, он включается только на клиентских портах (раздел 9).
- На 25 TLS необязательный (
may), чтобы не отвергать серверы без STARTTLS. Обязательный TLS для межсерверной почты требует отдельной политики (DANE, MTA-STS). - Лимит 25 МиБ относится ко всему письму. Из-за base64 вложение может быть примерно на треть меньше.
Служебные письма
В /etc/aliases в пакете Debian уже есть postmaster: root. Добавьте только перенаправление root на реальный ящик — дубликаты ключей newaliases отметит предупреждением:
root: alice@example.comnewaliases8. SQL-карты Postfix
install -d -o root -g postfix -m 0750 /etc/postfix/sqlКаждый из пяти файлов начинается с общего блока (пароль замените):
user = mailreader
password = MAIL_DB_PASSWORD
hosts = 127.0.0.1
dbname = postfixadminЗатем — строка query, одной логической строкой. Запросы одинаковы для PostgreSQL и MariaDB: флаги PostfixAdmin в PostgreSQL логические, в MariaDB целые, а литералы '1'/'0' понимают обе. По той же причине вместо MySQL-функции FIND_IN_SET используется POSITION.
/etc/postfix/sql/domains.cf — основной домен и активные домены-алиасы:
query = SELECT domain FROM domain WHERE domain='%s' AND active='1' AND backupmx='0' UNION SELECT ad.alias_domain FROM alias_domain ad JOIN domain d ON d.domain=ad.target_domain WHERE ad.alias_domain='%s' AND ad.active='1' AND d.active='1' AND d.backupmx='0'/etc/postfix/sql/mailboxes.cf — подтверждает существование ящика (доставку выполняет Dovecot, путь здесь не нужен):
query = SELECT 1 FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.username='%s' AND m.active='1' AND d.active='1' AND d.backupmx='0'/etc/postfix/sql/aliases.cf:
query = SELECT a.goto FROM alias a JOIN domain d ON d.domain=a.domain WHERE a.address='%s' AND a.active='1' AND d.active='1' AND d.backupmx='0'/etc/postfix/sql/alias-domains.cf — для alice@alias.example вернёт alice@example.com, только если такой получатель существует. Цепочки доменов-алиасов и catch-all через них не поддерживаются намеренно:
query = SELECT CONCAT('%u','@',ad.target_domain) FROM alias_domain ad JOIN domain d ON d.domain=ad.target_domain WHERE ad.alias_domain='%d' AND ad.active='1' AND d.active='1' AND d.backupmx='0' AND (EXISTS (SELECT 1 FROM mailbox m WHERE m.username=CONCAT('%u','@',ad.target_domain) AND m.active='1') OR EXISTS (SELECT 1 FROM alias a WHERE a.address=CONCAT('%u','@',ad.target_domain) AND a.active='1'))/etc/postfix/sql/sender-login.cf — кто может отправлять с этого адреса: владелец ящика, а для алиаса — его активные получатели-ящики:
query = SELECT m.username FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.username='%s' AND m.active='1' AND d.active='1' AND d.backupmx='0' UNION SELECT m.username FROM alias a JOIN mailbox m ON POSITION(CONCAT(',',m.username,',') IN CONCAT(',',a.goto,','))>0 JOIN domain d ON d.domain=a.domain JOIN domain md ON md.domain=m.domain WHERE a.address='%s' AND a.active='1' AND m.active='1' AND d.active='1' AND md.active='1' AND d.backupmx='0' AND md.backupmx='0'chown root:postfix /etc/postfix/sql/*.cf
chmod 0640 /etc/postfix/sql/*.cf
postconf -m | grep -E 'pgsql|mysql'
postmap -q example.com proxy:$MAP:/etc/postfix/sql/domains.cf
postmap -q alice@example.com proxy:$MAP:/etc/postfix/sql/mailboxes.cf
postmap -q postmaster@example.com proxy:$MAP:/etc/postfix/sql/aliases.cf
postmap -q alice@example.com proxy:$MAP:/etc/postfix/sql/sender-login.cfОжидаются: домен, 1, адрес назначения и alice@example.com. Неизвестный ящик даёт пустой результат. Отдельно проверьте выключенный ящик, выключенный домен и домен-алиас. Ошибка подключения к БД — не то же самое, что «нет пользователя»: ищите её в логе. postmap без -q для SQL-карт не нужен.
9. Отправка клиентами: порты 587 и 465
В /etc/postfix/master.cf сохраните пакетные службы. Если строки submission, submissions или smtps уже есть, отредактируйте их, а не добавляйте дубли:
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
-o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch
submissions inet n - y - - smtpd
-o syslog_name=postfix/submissions
-o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
-o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatchsubmissions — имя службы порта 465 (getent services submissions). Если имя не разрешается, укажите 465. Строки -o начинаются с пробела — это продолжение определения службы.
reject_authenticated_sender_login_mismatch проверяет адрес MAIL FROM, а не заголовок From:. AUTH PLAIN/LOGIN допустимы только потому, что TLS здесь обязателен.
10. Dovecot 2.4: IMAPS, SASL и LMTP
Для новой установки используем один самостоятельный /etc/dovecot/dovecot.conf без подключения старого conf.d. Так не будет одновременной SQL- и системной аутентификации, дублей listener-ов и параметров 2.3.
Блок подключения к базе отличается для двух СУБД:
PostgreSQL
sql_driver = pgsql
pgsql 127.0.0.1 {
parameters {
user = mailreader
password = MAIL_DB_PASSWORD
dbname = postfixadmin
}
}MariaDB
sql_driver = mysql
mysql 127.0.0.1 {
user = mailreader
password = MAIL_DB_PASSWORD
dbname = postfixadmin
}Остальной файл общий. Вставьте выбранный блок на место комментария:
dovecot_config_version = 2.4.0
dovecot_storage_version = 2.4.0
protocols = imap lmtp
listen = *
ssl = required
ssl_min_protocol = TLSv1.2
ssl_server_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_server_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem
auth_allow_cleartext = no
auth_mechanisms = plain login
auth_username_format = %{user | lower}
mail_uid = vmail
mail_gid = vmail
first_valid_uid = 5000
mail_driver = maildir
mail_path = %{home}/Maildir
mailbox_list_index = yes
namespace inbox {
inbox = yes
separator = /
mailbox Sent {
auto = subscribe
special_use = \Sent
}
mailbox Drafts {
auto = subscribe
special_use = \Drafts
}
mailbox Trash {
auto = subscribe
special_use = \Trash
}
mailbox Junk {
auto = subscribe
special_use = \Junk
}
}
# ← сюда блок sql_driver + pgsql/mysql из вкладки выше
passdb sql {
default_password_scheme = ARGON2ID
query = SELECT m.username AS user, m.password FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.username='%{user}' AND m.active='1' AND d.active='1' AND d.backupmx='0'
}
userdb sql {
query = SELECT CONCAT('/var/vmail/',m.maildir) AS home, CONCAT(m.quota,'B') AS quota_storage_size FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.username='%{user}' AND m.active='1' AND d.active='1' AND d.backupmx='0'
iterate_query = SELECT m.username AS user FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.active='1' AND d.active='1' AND d.backupmx='0'
}
service imap-login {
inet_listener imap {
port = 0
}
inet_listener imaps {
port = 993
ssl = yes
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
}
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
protocol lmtp {
postmaster_address = postmaster@example.com
}
mail_plugins {
quota = yes
}
protocol imap {
mail_plugins {
imap_quota = yes
}
}
quota user {
driver = count
}dovecot_config_version должна идти первой. Значение 2.4.0 задаёт базовую версию синтаксиса, а не точную версию пакета. Блоки SQL и переменные соответствуют документации 2.4.[1:1][3] Пути сертификатов в 2.4 указываются без старого префикса <. Сокеты лежат внутри chroot Postfix, поэтому Postfix видит их как private/auth и private/dovecot-lmtp.
chown root:root /etc/dovecot/dovecot.conf
chmod 0600 /etc/dovecot/dovecot.conf
doveconf -n >/dev/null && echo OKПолный вывод doveconf -n и debug-логи не публикуйте: в них пароль БД.
Квоты
Поле mailbox.quota хранит байты. SQL возвращает их с суффиксом B, ноль — без ограничения. Для новых установок рекомендован драйвер count.[4][5]
doveadm user alice@example.com
doveadm quota get -u alice@example.comСмените лимит тестового ящика и убедитесь, что Dovecot видит новое значение и ограничивает доставку. Фактический объём ящика PostfixAdmin с драйвером count сам не показывает.
POP3 — только при необходимости
IMAP обычно удобнее: папки и состояние синхронизируются между устройствами. Если POP3S всё же нужен:
apt install dovecot-pop3dЗамените строку на protocols = imap pop3 lmtp и добавьте:
service pop3-login {
inet_listener pop3 {
port = 0
}
inet_listener pop3s {
port = 995
ssl = yes
}
}Открывайте только 995. Незашифрованные 110 и 143 не нужны. Проверять вход через telnet с настоящим паролем, как в старой версии статьи, нельзя.
11. Rspamd: антиспам и DKIM
Rspamd и проверяет входящие письма, и подписывает исходящие. Отдельный OpenDKIM не нужен.
apt install rspamd redis-server
systemctl enable --now redis-server rspamd/etc/rspamd/local.d/redis.conf:
servers = "127.0.0.1";/etc/rspamd/local.d/worker-proxy.inc:
bind_socket = "127.0.0.1:11332";
milter = yes;
timeout = 120s;
upstream "local" {
default = yes;
self_scan = yes;
}/etc/rspamd/local.d/worker-controller.inc:
bind_socket = "127.0.0.1:11334";Порты Rspamd и Redis в Интернет не открывайте. Веб-интерфейс Rspamd — через SSH-туннель и с паролем контроллера (хеш даёт rspamadm pw).
Ключ DKIM
install -d -o _rspamd -g _rspamd -m 0700 /var/lib/rspamd/dkim
umask 077
rspamadm dkim_keygen -b 2048 -s mail -d example.com \
-k /var/lib/rspamd/dkim/example.com.mail.key \
> /root/example.com.mail.dns.txt
chown _rspamd:_rspamd /var/lib/rspamd/dkim/example.com.mail.key
chmod 0600 /var/lib/rspamd/dkim/example.com.mail.key
cat /root/example.com.mail.dns.txtОпубликуйте выведенную TXT-запись для mail._domainkey.example.com. Если DNS-панель ждёт только значение, перенесите v=DKIM1; k=rsa; p=… целиком; несколько строк в кавычках — части одного значения. Закрытый .key не публикуется никогда.
/etc/rspamd/local.d/dkim_signing.conf:
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
selector = "mail";
use_domain = "header";
use_esld = false;
sign_authenticated = true;
sign_local = true;
allow_username_mismatch = false;
allow_hdrfrom_mismatch = false;Для каждого отправляющего домена нужен свой ключ и своя TXT-запись.[6]
rspamadm configtest
systemctl restart rspamd
ss -lntp | grep -E ':11332|:11334|:6379'
dig +short TXT mail._domainkey.example.comТолько после успешного запуска подключайте milter:
postconf -e 'smtpd_milters = inet:127.0.0.1:11332'
postconf -e 'non_smtpd_milters = $smtpd_milters'
postconf -e 'milter_protocol = 6'
postconf -e 'milter_default_action = tempfail'
postconf -e 'milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}'tempfail выбран сознательно: если Rspamd недоступен, Postfix временно откажет, и нормальный отправитель повторит попытку. Это требует мониторинга Rspamd. Вариант accept доступнее, но во время сбоя пропускает письма без проверки и без подписи.[7]
Не отклоняйте письма только по SPF: пересылка ломает SPF, а DKIM при этом остаётся верным. DNSBL-списки часто не отвечают публичным резолверам — при необходимости поднимите свой рекурсивный резолвер. Заголовок спама сам по себе не переносит письмо в «Спам»: для этого нужен Sieve (он есть в установщике Mailstack). Антивирус в ручную схему не входит.
12. Первый запуск и firewall
postfix check
doveconf -n >/dev/null
rspamadm configtest
apache2ctl configtest
systemctl enable --now dovecot postfix
systemctl restart dovecot postfix
systemctl --no-pager status dovecot postfix rspamd
ss -lntp | grep -E ':(25|465|587|993)\b'
ls -l /var/spool/postfix/private/auth /var/spool/postfix/private/dovecot-lmtpПосле изменения inet_protocols нужен restart Postfix, дальше достаточно reload.
На выделенном сервере без своего firewall подойдёт UFW. Сначала разрешите фактический порт SSH и убедитесь, что есть аварийная консоль:
apt install ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 25/tcp
ufw allow 465/tcp
ufw allow 587/tcp
ufw allow 993/tcp
ufw allow 80/tcp
ufw enable
ufw status verbose80/tcp нужен только для HTTP-01 certbot. Для POP3S добавьте 995. Порты 5432/3306, 6379, 11332–11334 и LMTP наружу не открываются. Не накладывайте UFW вслепую на существующие правила nftables, Docker или firewall провайдера.
13. Проверка работы
TLS
С другого компьютера, по имени из сертификата:
openssl s_client -connect mail.example.com:993 \
-servername mail.example.com -verify_hostname mail.example.com \
-verify_return_error -CApath /etc/ssl/certs
openssl s_client -starttls smtp -connect mail.example.com:587 \
-servername mail.example.com -verify_hostname mail.example.com \
-verify_return_error -CApath /etc/ssl/certs
openssl s_client -connect mail.example.com:465 \
-servername mail.example.com -verify_hostname mail.example.com \
-verify_return_error -CApath /etc/ssl/certsСовпасть должны имя, цепочка доверия и срок действия. Один баннер SMTP/IMAP доверенного TLS не доказывает.
Аутентификация
doveadm user alice@example.com
doveadm auth test alice@example.comПароль вводите в запросе, не в аргументах. Проверьте верный пароль, неверный, несуществующий адрес и выключенный ящик.
Отправка и доставка
С внешнего компьютера, на свой реальный контрольный адрес:
swaks --server mail.example.com --port 587 --tls \
--auth LOGIN --auth-user alice@example.com --auth-password \
--from alice@example.com --to external-recipient@example.net--auth-password без значения запросит пароль интерактивно. Для 465 используйте --tls-on-connect. Проверка должна подтвердить:
- TLS включается до AUTH.
- Верный пароль проходит, неверный — нет.
- Без AUTH отправка на 587/465 отвергается, даже на свой домен.
- Вошедший как
alice@example.comне может указатьMAIL FROM: bob@example.com. - Письмо пришло внешнему получателю, а в его заголовке
Authentication-Results(добавленном принимающей стороной) —spf=pass,dkim=pass,dmarc=pass. - Ответное письмо извне доставляется в IMAP-ящик.
Приём на порт 25 (отправитель — ваш реальный тестовый адрес):
swaks --server mail.example.com --port 25 \
--from sender@example.net --to alice@example.com250 … queued as … — это принятие в очередь, а не доставка.
Проверка на open relay
Только с внешнего узла и без авторизации: с 127.0.0.1 тест бессмыслен, loopback входит в mynetworks.
swaks --server mail.example.com --port 25 \
--from sender@external.example --to recipient@another-external.example \
--quit-after RCPTПодставьте два настоящих чужих домена. На RCPT TO должен быть отказ в relay. Если сервер принял — в эксплуатацию его не вводить. Так же проверьте неизвестного получателя в своём домене: отказ должен прийти во время SMTP-сессии, а не отдельным bounce (иначе сервер рассылает backscatter).
Настройки почтового клиента
| Параметр | Значение |
|---|---|
| Входящий сервер | mail.example.com, IMAP, порт 993, SSL/TLS |
| Исходящий сервер | mail.example.com, 587 STARTTLS или 465 SSL/TLS |
| Аутентификация SMTP | обязательна |
| Логин | полный адрес, например alice@example.com |
14. Логи, очередь и типичные ошибки
Если /var/log/mail.log не появился, добавьте правило mail.* -/var/log/mail.log в отдельный файл в /etc/rsyslog.d/, проверьте rsyslogd -N1 и перезапустите rsyslog. Второе такое правило не создавайте.
tail -f /var/log/mail.log
journalctl -u dovecot -u rspamd --since '30 minutes ago'
journalctl -u 'postfix@-.service' --since '30 minutes ago'
postqueue -p
postcat -q QUEUE_ID
postqueue -i QUEUE_IDВ Debian журнал Postfix пишет instance-unit postfix@-.service. postcat показывает содержимое письма — не публикуйте его без обезличивания. Не удаляйте всю очередь и не гоняйте flush по кругу вместо поиска причины.
| Симптом | Что проверять |
|---|---|
| Dovecot не стартует: неизвестные настройки | остатки конфигурации 2.3, %u/%d, нет version-параметров, не установлен dovecot-pgsql/dovecot-mysql |
User unknown |
запись в mailbox, активность ящика и домена, полный логин, SQL-карты |
Relay access denied при авторизованной отправке |
порт, TLS, AUTH, сокет private/auth, ограничения в master.cf |
Sender address rejected: not owned by user |
MAIL FROM не принадлежит вошедшему по sender-login.cf |
| Нет соединения с LMTP | Dovecot запущен, lmtp в protocols, сокет существует, путь и права совпадают |
Permission denied |
владелец /var/vmail, UID/GID, права сокетов; не лечить правами 777 |
permission denied for table (PostgreSQL) |
не выполнены GRANT SELECT / ALTER DEFAULT PRIVILEGES для mailreader |
| Письмо висит в очереди с timeout | DNS, блокировка исходящего 25, недоступность получателя |
Connection refused от milter |
Rspamd не слушает 127.0.0.1:11332 |
| Письма попадают в спам у получателей | PTR/HELO, SPF/DKIM/DMARC, репутация IP и домена, содержимое |
| PostfixAdmin не открывается | PHP, Apache, loopback-listener, SSH-туннель, mailadmin-error.log |
| Панель не создаёт пароль | doveadm -O pw от www-data, поддержка ARGON2ID |
| Сертификат не обновляется | доступность HTTP-01/DNS-01, DNS/AAAA, таймер и хуки |
15. Защита от перебора и эксплуатация
apt install fail2ban/etc/fail2ban/jail.d/mail.local:
[postfix-sasl]
enabled = true
backend = auto
port = 465,587
logpath = /var/log/mail.log
maxretry = 5
findtime = 10m
bantime = 1h
[dovecot]
enabled = true
backend = auto
port = 993
logpath = /var/log/mail.log
maxretry = 5
findtime = 10m
bantime = 1hfail2ban-client -t
fail2ban-regex /var/log/mail.log /etc/fail2ban/filter.d/postfix-sasl.conf
fail2ban-regex /var/log/mail.log /etc/fail2ban/filter.d/dovecot.conf
systemctl enable --now fail2ban
fail2ban-client statusКорректный конфиг ещё не доказывает, что бан работает: проверьте banaction и тестовую блокировку внешнего адреса, не заблокировав свой единственный доступ. Пользователи за общим NAT могут попасть под чужой бан.
Также нужны:
- раздельные пароли администраторов и ящиков, минимальные права;
- обновления безопасности с контролем перезапусков;
- мониторинг места и inode, размера и возраста очереди, сроков сертификатов, доступности LMTP, Rspamd и БД;
- контроль объёма исходящей почты: Fail2ban не остановит злоумышленника, который уже знает пароль ящика;
- резервные копии с проверкой восстановления.
Резервное копирование
Минимальный состав: /var/vmail, база postfixadmin, конфигурации Postfix, Dovecot, PostfixAdmin, Apache, Rspamd, закрытые DKIM-ключи и /etc/letsencrypt.
PostgreSQL
umask 077
runuser -u postgres -- pg_dumpall --globals-only > /root/pg-roles-$(date +%F).sql
runuser -u postgres -- pg_dump -Fc postfixadmin > /root/postfixadmin-$(date +%F).dumpMariaDB
umask 077
mariadb-dump --single-transaction --routines --triggers postfixadmin \
> /root/postfixadmin-$(date +%F).sqlСогласованную копию Maildir лучше делать снапшотом файловой системы. Без снапшотов остановите Dovecot и доставку на время копирования. Сохраняйте числовые UID/GID. Дамп содержит адреса и хеши паролей: храните его зашифрованным вне сервера и проверяйте восстановление на отдельной машине.
16. Перенос существующего сервера
Конфигурации FreeBSD и Dovecot 2.3 нельзя просто скопировать в Debian 13. Порядок:
- Инвентаризировать домены, ящики, алиасы, фильтры, квоты, transport maps и схемы паролей.
- Сохранить БД, письма, конфигурацию и ключи. Проверить восстановление.
- Развернуть Debian 13 отдельно и испытать на тестовом домене.
- Перенести данные БД и проверить совместимость схемы с версией PostfixAdmin. Переход с MySQL на PostgreSQL — отдельная миграция данных, не смена драйвера в конфиге.
- Перенести Maildir с сохранением владельцев. Здесь письма лежат в
/var/vmail/<домен>/<пользователь>/Maildir; в старой статье путь был другим. Либо переносите файлы, либо осознанно меняйтеmail_pathиhome. - mbox — другой формат, не переименование каталога. Конвертируйте средствами Dovecot или переносите по IMAP со сверкой папок, флагов и количества писем.
- Dovecot 2.4 по умолчанию отключает слабые схемы паролей. MD5-хеш нельзя превратить в ARGON2ID без исходного пароля: лучше сбросить пароли или перехешировать при смене.[1:2]
- Заранее снизить TTL, переключить MX/A и PTR в согласованное окно.
- Держать старый сервер под контролем, пока идут повторные доставки и обновляются DNS-кэши.
- После переключения проверить входящую и исходящую почту, подписи, алиасы, квоты и сверить данные.
Не переносите публичную DKIM-запись без её закрытого ключа. При смене селектора старый публичный ключ оставьте на время.
17. Альтернатива: системные пользователи Linux
Это другая схема, а не дополнение к SQL-варианту. Подходит небольшому серверу, где ящики — обычные пользователи Linux.
postconf -e 'mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain'
postconf -e 'home_mailbox = Maildir/'
postconf -e 'virtual_mailbox_domains ='
postconf -e 'virtual_mailbox_maps ='
postconf -e 'virtual_alias_maps ='
postconf -e 'virtual_transport = virtual'
useradd --create-home --shell /usr/sbin/nologin alice
passwd aliceВ Dovecot вместо SQL-блоков и фиксированных mail_uid/mail_gid/first_valid_uid = 5000:
auth_username_format = %{user | username | lower}
first_valid_uid = 1000
mail_driver = maildir
mail_path = %{home}/Maildir
passdb pam {
}
userdb passwd {
}Проверьте /etc/pam.d/dovecot: политика PAM должна разрешать почтовый вход, но не давать лишнего системного доступа. Почтовый вход root запрещён. Логин в клиенте — alice.
Проверку владельца отправителя нужно заменить, а не оставить с пустой картой. Файл /etc/postfix/sender-login:
alice@example.com alicepostmap /etc/postfix/sender-login
postconf -e 'smtpd_sender_login_maps = hash:/etc/postfix/sender-login'SQL-квоты в такой схеме удалите или перенастройте. Подключать PAM и SQL одновременно «на удачу» нельзя.
18. LDAP: что решить до настройки
LDAP/Active Directory — ещё одна самостоятельная модель. Готовый конфиг без знания вашего каталога привести нельзя. Определите заранее:
- OpenLDAP или Active Directory, адреса серверов, доверенный CA и проверку сертификатов;
- base DN, сервисную учётную запись с доступом только к нужным атрибутам;
- атрибуты логина и адреса (
mail,userPrincipalNameили свой) и фильтр активного пользователя; - способ проверки пароля: bind от имени пользователя или хеши;
- откуда берутся UID/GID, пути ящиков и квоты;
- какие домены, алиасы и получатели Postfix считает существующими;
- поведение при недоступности каталога и проверку выключенной учётной записи.
Понадобятся dovecot-ldap, для запросов из Postfix — postfix-ldap. Используйте синтаксис Dovecot 2.4, а не старые dovecot-ldap.conf.ext. Связь с каталогом — только через проверяемый TLS. PostfixAdmin при этом не становится интерфейсом управления LDAP.
19. Если нужен именно шлюз перед другим сервером
Пример: публичный Postfix принимает почту example.com и передаёт её на внутренний сервер 192.0.2.20 (например, Exchange). Виртуальные ящики, Dovecot и PostfixAdmin на шлюзе не нужны; Rspamd и TLS остаются.
mydestination = $myhostname, localhost.$mydomain, localhost
relay_domains = example.com
transport_maps = hash:/etc/postfix/transport
relay_recipient_maps = hash:/etc/postfix/relay_recipients
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination/etc/postfix/transport:
example.com smtp:[192.0.2.20]:25/etc/postfix/relay_recipients — полный список существующих адресов:
alice@example.com OK
postmaster@example.com OK
abuse@example.com OKpostmap /etc/postfix/transport /etc/postfix/relay_recipients
postfix check && systemctl reload postfix- Квадратные скобки в
[192.0.2.20]отключают поиск MX — письмо идёт прямо на этот адрес. - Без
relay_recipient_mapsшлюз примет письмо на любой адрес домена, а потом будет рассылать недоставляемые уведомления (backscatter). Список получателей выгружайте из внутреннего сервера автоматически. - Внутренний сервер должен принимать почту только от шлюза. Исходящая почта организации идёт через шлюз: добавьте адрес внутреннего сервера в
mynetworksтолько на шлюзе либо настройте ему авторизованную отправку на 587. - DKIM-подпись в этой схеме ставит шлюз (Rspamd
sign_local = trueдля внутреннего сервера изmynetworks).
20. Итоговый чек-лист перед вводом в эксплуатацию
- A, MX, PTR совпадают; AAAA нет или IPv6 полностью настроен.
- SPF — одна запись, перечислены все источники отправки.
- DKIM опубликован, у внешнего получателя
dkim=pass. - DMARC
p=noneс адресом отчётов; план перехода кquarantine/reject. - Сертификат доверенный, обновление и перезагрузка сервисов проверены.
- Open relay с внешнего узла — отказ; неизвестный получатель — отказ в SMTP-сессии.
- Без AUTH на 587/465 — отказ; чужой
MAIL FROM— отказ. - Наружу открыты только 25, 465, 587, 993 (и 80 для certbot); БД, Redis и Rspamd закрыты.
- Fail2ban реально блокирует; мониторинг очереди, места и сертификатов настроен.
- Резервная копия создана, вынесена и восстановлена на отдельной машине.
21. Автоматическая установка: Mailstack
install.sh разворачивает на новом Debian 13 всё, что описано выше, и сверх того:
- выбор СУБД: PostgreSQL (по умолчанию) или MariaDB — ключ
--db mysql; от выбора зависят пакеты, создание баз и ролей, драйверы Postfix/Dovecot, PostfixAdmin, Roundcube, резервная копия и проверка состояния; - Roundcube по HTTPS: письма, папки, поиск, адресная книга, вложения, смена пароля, Sieve-фильтры, автоответ и переадресация;
- ManageSieve на 4190 — только локально, для Roundcube;
- Sieve-правило: письма с пометкой спама попадают в «Спам» (Junk);
- ClamAV: проверка вложений, отклонение заражённых писем и временный отказ при недоступности сканера;
- ограничение отправки для авторизованных пользователей (token bucket: 100 писем сразу, пополнение 100 в час);
- Fail2ban, сертификат Let’s Encrypt через webroot с автообновлением;
- собственная таблица nftables: закрывает внутренние порты и не пускает внешнюю почту и веб-почту до окончания проверок; чужие правила firewall не сбрасывает;
- самопроверка после установки: настоящая отправка по SMTP с AUTH, доставка через milter и LMTP, чтение по IMAP, отказ неавторизованной отправки;
- проверка состояния каждые 15 минут и команда шифрованного резервного копирования (таймер копирования установлен, но не включён).
Не настраиваются: календари и контакты CalDAV/CardDAV, ActiveSync, LDAP, SSO, кластер, миграция, relay провайдера, MTA-STS/DANE и публикация DNS. Это базовый почтовый стек, а не замена Exchange.
Требования
- Чистый Debian 13 с systemd, доступ root, снимок VM перед запуском.
- От 4 ГиБ RAM (лучше 8 — из-за ClamAV), от 10 ГиБ свободного места плюс место под почту.
- Публичный статический IPv4; AAAA-записи нет (установщик работает только с IPv4).
- A-запись
mail.example.comуже указывает на сервер, MX домена — на неё. PTR пока даёт только предупреждение, но до запуска в работу обязателен. - TCP/25 открыт в обе стороны, 80/443/465/587/993 — к серверу.
- На машине нет Postfix, Exim, Dovecot, PostgreSQL, MariaDB, Apache, Roundcube, Rspamd, Redis, ClamAV и данных в
/var/vmail,/var/lib/postgresql,/var/lib/mysql— установщик это проверит и откажется работать. - Установлен
python3(apt install python3).
Запуск
Скачайте install.sh (ниже), сверьте контрольную сумму и прочитайте файл. Не запускайте через curl | bash.
sha256sum install.sh
# fabe2f2a62b2a2d7b3a947f4768a77a39611a1fb48549f27a2885648f1020190
bash -n install.sh
less install.shПросмотр конфигураций без установки — ничего в системе не меняет, секреты подставляются фиктивные:
bash install.sh --render-only ./preview-configs \
--domain example.com --hostname mail.example.com \
--public-ip 203.0.113.10Установка (PostgreSQL по умолчанию):
sudo bash install.sh \
--domain example.com \
--hostname mail.example.com \
--public-ip YOUR_REAL_PUBLIC_IPV4 \
--ssh-port 22Для MariaDB добавьте --db mysql, для POP3S — --pop3. Скрипт попросит напечатать INSTALL; ключ --yes — для осознанного запуска без вопроса. Порт SSH установщик не меняет: параметр нужен для проверки конфликтов и итоговой подсказки. Сменить СУБД на уже установленном сервере нельзя — это миграция.
Журнал установки — /var/log/mailstack-install.log (только root), секреты в него не выводятся.
Что создаётся
| Что | Где |
|---|---|
| Администратор PostfixAdmin | admin@ДОМЕН |
| Первый ящик (2 ГиБ) | mailadmin@ДОМЕН; алиасы postmaster@, abuse@, dmarc@ ведут на него |
| Все стартовые пароли и ключ резервных копий | /root/mailstack-credentials.json (0600, только root) |
| DNS-записи для публикации, включая DKIM | /root/mailstack-dns.txt |
| Версии пакетов | /root/mailstack-versions.txt |
| Веб-почта | https://mail.example.com/ |
| PostfixAdmin | только через туннель: ssh -N -L 8080:127.0.0.1:8080 user@mail.example.com, затем http://127.0.0.1:8080/ |
Файл с паролями и закрытые ключи не публикуйте. Каждому пользователю передавайте только его пароль.
После установки — обязательно
- Опубликуйте SPF, DKIM, DMARC из
/root/mailstack-dns.txtи установите PTR. - Укажите контакт ACME-аккаунта:
certbot update_account --email ВАШ_АДРЕС(при выпуске ящик ещё не работал). - С внешней машины выполните проверку open relay и всё из раздела 13.
- Проверьте
Authentication-Resultsу внешнего получателя: SPF, DKIM и DMARC —pass. - В Roundcube проверьте смену пароля, фильтр, автоответ, попадание спама в «Спам».
- Антивирус проверьте тестовым файлом EICAR и остановкой ClamAV (ожидается временный отказ).
- Проверьте реальную блокировку Fail2ban.
- Сделайте резервную копию, вынесите её вместе с паролем шифрования на отдельное хранилище и восстановите на отдельной VM.
- Настройте обновления безопасности и внешний мониторинг: health-таймер только пишет в журнал.
Обслуживание
sudo bash install.sh --check # проверка состояния
sudo journalctl -u mailstack-health.service --since today
sudo postqueue -p
sudo certbot renew --dry-run
sudo /usr/local/sbin/mailstack-backup # офлайн-копия: на время останавливает почтуКопия шифруется GPG (AES256) и кладётся в /var/backups/mailstack. Для PostgreSQL сохраняются роли (pg_dumpall --globals-only) и обе базы (pg_dump -Fc), для MariaDB — mariadb-dump. Расписание включайте, только оценив время простоя и проверив восстановление: systemctl enable --now mailstack-backup.timer. Удаление старых копий и выгрузка наружу не настроены.
Если установка прервалась
Скрипт останавливается на первой ошибке и оставляет внешние почтовые порты закрытыми. Он сохраняет пароли и журнал, но не откатывает пакеты, базы и конфиги. Повторно запускать его на том же сервере нельзя — он это и не позволит. Восстановите снимок VM и устраните причину по журналу либо вручную завершите упавший этап.
Что проверено
- Синтаксис скрипта, генерация конфигураций для обеих СУБД, с POP3 и без.
- Сквозной прогон версии с MariaDB на Debian 13 (с заменой DNS и Let’s Encrypt тестовыми заглушками): установка пакетов, создание баз, bootstrap PostfixAdmin, схема Roundcube, проверки PHP и Apache, выпуск сертификата, загрузка баз ClamAV. Обнаружено, что трёх минут на запуск clamd мало — ожидание увеличено до 10 минут.
- Конфигурация Dovecot для PostgreSQL принята
doveconfна Dovecot 2.4.1. - SQL-карты Postfix на PostgreSQL 17: 14 сценариев — выключенный домен и ящик, домен-алиас, алиас с несколькими получателями, права отправки от имени алиаса.
Полный прогон с PostgreSQL, доставка во внешний мир и восстановление из копии на тестовом стенде не выполнялись. Испытайте установщик на отдельной VM перед боевым сервером.
install.sh целиком (Mailstack 1.1.0)
#!/usr/bin/env bash
# Mailstack for a NEW Debian 13 host. Review README before running.
# No remote scripts are executed. Packages come from configured apt repositories.
set -Eeuo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
umask 077
VERSION=1.1.0
DOMAIN='' MAILHOST='' PUBLIC_IP='' SSH_PORT=22 POP3=0 YES=0 MODE=install OUT=''
DB=postgresql
POLICY_OWNED=0 TMP='' INSTALL_STARTED=0
usage() {
cat <<'HELP'
Usage:
sudo bash install.sh --domain example.com --hostname mail.example.com \
--public-ip YOUR_PUBLIC_IPV4 [--db postgresql|mysql] [--ssh-port 22] \
[--pop3] [--yes]
sudo bash install.sh --check
bash install.sh --render-only DIRECTORY --domain example.com \
--hostname mail.example.com --public-ip 203.0.113.10 [--db mysql] [--pop3]
--db selects the database: postgresql (default) or mysql (MariaDB server).
Fresh Debian 13 ONLY. Does not migrate or repair an existing installation.
Render-only writes dummy credentials and configuration, not usable secrets;
no packages, services, DNS or system files are changed in that mode.
Setup blocks external mail/webmail until local checks pass. Port 80 must be
reachable for Let's Encrypt HTTP-01. PTR/DNS changes remain your responsibility.
HELP
}
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
while (($#)); do
case "$1" in
--domain|--hostname|--public-ip|--ssh-port|--render-only|--db)
(($# >= 2)) || die "Missing value for $1"
case "$1" in
--domain) DOMAIN=$2;; --hostname) MAILHOST=$2;;
--public-ip) PUBLIC_IP=$2;; --ssh-port) SSH_PORT=$2;;
--render-only) MODE=render; OUT=$2;;
--db) DB=$2;;
esac; shift 2;;
--pop3) POP3=1; shift;; --yes) YES=1; shift;;
--check) MODE=check; shift;; --help|-h) usage; exit 0;;
*) die "Unknown option: $1";;
esac
done
case $DB in
postgresql|postgres|pgsql) DB=postgresql DBSVC=postgresql;;
mysql|mariadb) DB=mysql DBSVC=mariadb;;
*) die '--db must be postgresql or mysql.';;
esac
cleanup() {
local code=$?
trap - EXIT
if ((POLICY_OWNED)); then rm -f /usr/sbin/policy-rc.d; fi
[[ -z "$TMP" ]] || rm -rf -- "$TMP"
if ((code != 0 && INSTALL_STARTED)); then
if declare -F firewall >/dev/null; then firewall setup || true; fi
printf '\nInstallation incomplete. External mail/webmail remain blocked.\n' >&2
printf 'Inspect /var/log/mailstack-install.log; preserve credentials.\n' >&2
printf 'Do not re-run on this partially configured host. Restore a snapshot\n' >&2
printf 'or finish/repair the failed stage after reviewing generated files.\n' >&2
fi
exit "$code"
}
trap cleanup EXIT
trap 'printf "Failed at line %s (status %s).\n" "$LINENO" "$?" >&2' ERR
if [[ $MODE == check ]]; then
((EUID == 0)) || die 'Run --check as root.'
[[ -x /usr/local/sbin/mailstack-health ]] || die 'Mailstack is not installed.'
/usr/local/sbin/mailstack-health
exit
fi
validate_inputs() {
command -v python3 >/dev/null || die 'python3 is required (apt install python3).'
python3 - "$DOMAIN" "$MAILHOST" "$PUBLIC_IP" "$SSH_PORT" <<'PY'
import sys,re,ipaddress
D,H,I,P=sys.argv[1:]
for name,value in [('domain',D),('hostname',H)]:
if len(value)>253 or value!=value.lower() or '.' not in value:
raise SystemExit('Invalid '+name+': use lowercase ASCII FQDN.')
if any(not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?',x) for x in value.split('.')):
raise SystemExit('Invalid '+name)
if H==D: raise SystemExit('Use a separate mail hostname, e.g. mail.example.com.')
try: ipaddress.IPv4Address(I)
except ValueError: raise SystemExit('Invalid IPv4 address')
if not P.isdecimal() or not 1<=int(P)<=65535: raise SystemExit('Invalid SSH port')
if int(P) in [25,80,443,465,587,993,995,3310,4190,5432,8080,3306,6379,11332,11333,11334]:
raise SystemExit('SSH port conflicts with a mailstack port.')
PY
}
render() {
python3 - "$OUT" "$CREDS" "$POP3" <<'PY'
from pathlib import Path
import sys,json,os
root=Path(sys.argv[1]); c=json.load(open(sys.argv[2])); pop=sys.argv[3]=='1'
d=c['domain']; h=c['hostname']; ip=c['public_ip']
pg=c['db']=='postgresql'
# MAP: Postfix/Dovecot driver; DSN: Roundcube/PEAR scheme; PFADB: PostfixAdmin type.
params={'D':d,'H':h,'IP':ip,'MAP':'pgsql' if pg else 'mysql',
'DSN':'pgsql' if pg else 'mysql','PFADB':'pgsql' if pg else 'mysqli',
'DBSVC':'postgresql' if pg else 'mariadb',
'DBCONF':'etc/postgresql' if pg else 'etc/mysql',
'DBDUMP':('runuser -u postgres -- pg_dumpall --globals-only > "$work/globals.sql"\n'
'for db in postfixadmin roundcube; do\n'
' runuser -u postgres -- pg_dump -Fc "$db" > "$work/$db.dump"\n'
'done') if pg else
'mariadb-dump --single-transaction --routines --triggers --databases postfixadmin roundcube > "$work/databases.sql"',
# Must precede **c: the block itself contains @@read_pass@@.
'DOVECOT_SQL':('sql_driver = pgsql\npgsql 127.0.0.1 {\n parameters {\n'
' user = mailreader\n password = @@read_pass@@\n dbname = postfixadmin\n }\n}')
if pg else
('sql_driver = mysql\nmysql 127.0.0.1 {\n user = mailreader\n'
' password = @@read_pass@@\n dbname = postfixadmin\n}'),
**c}
def write(name,text,mode=0o640):
for k,v in params.items(): text=text.replace('@@'+k+'@@',str(v))
p=root/name.lstrip('/');p.parent.mkdir(parents=True,exist_ok=True)
p.write_text(text.rstrip()+'\n');p.chmod(mode)
write('/etc/mailstack/identity.json',json.dumps({k:c[k] for k in ['domain','hostname','public_ip','ssh_port','mailbox','db']},indent=2),0o600)
write('/etc/mailstack/backup.pass',c['backup_pass'],0o600)
if not pg:
write('/etc/mysql/mariadb.conf.d/99-mailstack.cnf','''[mysqld]
bind-address = 127.0.0.1
''',0o644)
write('/etc/postfix/mailstack-main.cf','''myhostname = @@H@@
mydomain = @@D@@
myorigin = $mydomain
mydestination = $myhostname, localhost.$mydomain, localhost
inet_interfaces = all
inet_protocols = ipv4
mynetworks = 127.0.0.0/8
relayhost =
relay_domains =
virtual_alias_domains =
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
recipient_delimiter = +
message_size_limit = 26214400
mailbox_size_limit = 0
disable_vrfy_command = yes
smtpd_helo_required = yes
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
smtpd_recipient_restrictions = reject_non_fqdn_recipient, reject_unknown_recipient_domain, reject_unlisted_recipient
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = no
smtpd_sasl_security_options = noanonymous
smtpd_tls_auth_only = yes
smtpd_tls_security_level = may
smtpd_tls_cert_file = /etc/letsencrypt/live/@@H@@/fullchain.pem
smtpd_tls_key_file = /etc/letsencrypt/live/@@H@@/privkey.pem
smtpd_tls_protocols = >=TLSv1.2
smtpd_tls_mandatory_protocols = >=TLSv1.2
smtpd_tls_loglevel = 1
smtp_tls_security_level = may
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
smtp_tls_protocols = >=TLSv1.2
virtual_mailbox_domains = proxy:@@MAP@@:/etc/postfix/sql/domains.cf
virtual_mailbox_maps = proxy:@@MAP@@:/etc/postfix/sql/mailboxes.cf
virtual_alias_maps = proxy:@@MAP@@:/etc/postfix/sql/aliases.cf, proxy:@@MAP@@:/etc/postfix/sql/alias-domains.cf
virtual_transport = lmtp:unix:private/dovecot-lmtp
smtpd_sender_login_maps = proxy:@@MAP@@:/etc/postfix/sql/sender-login.cf
smtpd_milters = inet:127.0.0.1:11332
non_smtpd_milters = $smtpd_milters
milter_protocol = 6
milter_default_action = tempfail
milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}
''')
write('/etc/postfix/mailstack-submission.cf','''submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
-o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch
465 inet n - y - - smtpd
-o syslog_name=postfix/submissions
-o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
-o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch
''')
common='user = mailreader\npassword = @@read_pass@@\nhosts = 127.0.0.1\ndbname = postfixadmin\nquery = '
# Portable SQL: PostfixAdmin flags are BOOLEAN in PostgreSQL and TINYINT in
# MySQL; the literals '1'/'0' are valid for both. POSITION/CONCAT exist in both,
# FIND_IN_SET is MySQL-only. A bare % must not appear: Postfix expands it.
A1="active='1'"; B0="backupmx='0'"
queries={
'domains':f"SELECT domain FROM domain WHERE domain='%s' AND {A1} AND {B0} UNION SELECT ad.alias_domain FROM alias_domain ad JOIN domain d ON d.domain=ad.target_domain WHERE ad.alias_domain='%s' AND ad.{A1} AND d.{A1} AND d.{B0}",
'mailboxes':f"SELECT 1 FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.username='%s' AND m.{A1} AND d.{A1} AND d.{B0}",
'aliases':f"SELECT a.goto FROM alias a JOIN domain d ON d.domain=a.domain WHERE a.address='%s' AND a.{A1} AND d.{A1} AND d.{B0}",
'alias-domains':f"SELECT CONCAT('%u','@',ad.target_domain) FROM alias_domain ad JOIN domain d ON d.domain=ad.target_domain WHERE ad.alias_domain='%d' AND ad.{A1} AND d.{A1} AND d.{B0} AND (EXISTS (SELECT 1 FROM mailbox m WHERE m.username=CONCAT('%u','@',ad.target_domain) AND m.{A1}) OR EXISTS (SELECT 1 FROM alias a WHERE a.address=CONCAT('%u','@',ad.target_domain) AND a.{A1}))",
'sender-login':f"SELECT m.username FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.username='%s' AND m.{A1} AND d.{A1} AND d.{B0} UNION SELECT m.username FROM alias a JOIN mailbox m ON POSITION(CONCAT(',',m.username,',') IN CONCAT(',',a.goto,','))>0 JOIN domain d ON d.domain=a.domain JOIN domain md ON md.domain=m.domain WHERE a.address='%s' AND a.{A1} AND m.{A1} AND d.{A1} AND md.{A1} AND d.{B0} AND md.{B0}"
}
for name,q in queries.items(): write('/etc/postfix/sql/'+name+'.cf',common+q)
write('/etc/postfixadmin/config.local.php',r'''<?php
$CONF['configured'] = true;
$CONF['default_language'] = 'ru';
$CONF['database_type'] = '@@PFADB@@';
$CONF['database_host'] = '127.0.0.1';
$CONF['database_name'] = 'postfixadmin';
$CONF['database_user'] = 'pfa';
$CONF['database_password'] = '@@pfa_pass@@';
$CONF['encrypt'] = 'dovecot:ARGON2ID';
$CONF['dovecotpw'] = '/usr/bin/doveadm -O pw';
$CONF['domain_path'] = 'YES';
$CONF['domain_in_mailbox'] = 'NO';
$CONF['quota'] = 'YES';
$CONF['quota_multiplier'] = '1048576';
$CONF['emailcheck_resolve_domain'] = 'YES';
$CONF['password_expiration'] = 'NO';
$CONF['vacation'] = 'NO'; // Autoreplies are managed via Sieve, not PFA vacation.pl.
$CONF['setup_password'] = 'disabled-installation-managed-locally';
$CONF['password_validation'] = array('/.{12}/' => 'password_too_short 12');
''')
write('/etc/dovecot/dovecot.conf',r'''dovecot_config_version = 2.4.0
dovecot_storage_version = 2.4.0
protocols = imap lmtp sieve
listen = *
ssl = required
ssl_min_protocol = TLSv1.2
ssl_server_cert_file = /etc/letsencrypt/live/@@H@@/fullchain.pem
ssl_server_key_file = /etc/letsencrypt/live/@@H@@/privkey.pem
auth_allow_cleartext = no
auth_mechanisms = plain login
auth_username_format = %{user | lower}
mail_uid = vmail
mail_gid = vmail
first_valid_uid = 5000
mail_driver = maildir
mail_path = %{home}/Maildir
mailbox_list_index = yes
namespace inbox {
inbox = yes
separator = /
mailbox Sent {
auto = subscribe
special_use = \Sent
}
mailbox Drafts {
auto = subscribe
special_use = \Drafts
}
mailbox Trash {
auto = subscribe
special_use = \Trash
}
mailbox Junk {
auto = subscribe
special_use = \Junk
}
}
@@DOVECOT_SQL@@
passdb sql {
default_password_scheme = ARGON2ID
query = SELECT m.username AS user, m.password FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.username='%{user}' AND m.active='1' AND d.active='1' AND d.backupmx='0'
}
userdb sql {
query = SELECT CONCAT('/var/vmail/',m.maildir) AS home, CONCAT(m.quota,'B') AS quota_storage_size FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.username='%{user}' AND m.active='1' AND d.active='1' AND d.backupmx='0'
iterate_query = SELECT m.username AS user FROM mailbox m JOIN domain d ON d.domain=m.domain WHERE m.active='1' AND d.active='1' AND d.backupmx='0'
}
service imap-login {
inet_listener imap {
port = 0
}
inet_listener imaps {
port = 993
ssl = yes
}
}
service managesieve-login {
inet_listener sieve {
listen = 127.0.0.1
port = 4190
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
}
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
protocol lmtp {
postmaster_address = postmaster@@@D@@
mail_plugins {
sieve = yes
}
}
mail_plugins {
quota = yes
}
protocol imap {
mail_plugins {
imap_quota = yes
}
}
quota user {
driver = count
}
sieve_script personal {
driver = file
path = ~/sieve
active_path = ~/.dovecot.sieve
}
sieve_script spam {
type = before
driver = file
path = /etc/dovecot/sieve/spam.sieve
}
''',0o600)
if pop:
p=root/'etc/dovecot/dovecot.conf'
p.write_text(p.read_text().replace('protocols = imap lmtp sieve','protocols = imap pop3 lmtp sieve')+'''\nservice pop3-login {
inet_listener pop3 {
port = 0
}
inet_listener pop3s {
port = 995
ssl = yes
}
}
''')
write('/etc/dovecot/sieve/spam.sieve',r'''require ["fileinto", "mailbox"];
if anyof (header :is "X-Rspamd-Action" "add header", header :is "X-Spam" "Yes", header :is "X-Spam-Flag" "YES") {
fileinto :create "Junk";
stop;
}
''',0o644)
write('/etc/roundcube/config.inc.php',r'''<?php
$config = [];
$config['db_dsnw'] = '@@DSN@@://roundcube:@@rc_pass@@@127.0.0.1/roundcube';
$config['imap_host'] = 'ssl://@@H@@:993';
$config['smtp_host'] = 'tls://@@H@@:587';
$config['smtp_user'] = '%u';
$config['smtp_pass'] = '%p';
$config['imap_conn_options'] = ['ssl' => ['verify_peer'=>true, 'verify_peer_name'=>true, 'cafile'=>'/etc/ssl/certs/ca-certificates.crt']];
$config['smtp_conn_options'] = $config['imap_conn_options'];
$config['des_key'] = '@@rc_key@@';
$config['product_name'] = 'Mail';
$config['language'] = 'ru_RU';
$config['skin'] = 'elastic';
$config['plugins'] = ['managesieve','password','archive','zipdownload'];
$config['force_https'] = true;
$config['session_lifetime'] = 30;
$config['session_samesite'] = 'Lax';
$config['enable_installer'] = false;
$config['support_url'] = '';
''')
# Plugin options live in the plugin config files: Roundcube loads them after the
# main config, so values left in a packaged plugin file would otherwise win.
TLSOPT="['ssl' => ['verify_peer'=>true, 'verify_peer_name'=>true, 'cafile'=>'/etc/ssl/certs/ca-certificates.crt']]"
write('/etc/roundcube/plugins/managesieve/config.inc.php',r'''<?php
$config = [];
$config['managesieve_host'] = 'tls://@@H@@:4190';
$config['managesieve_auth_type'] = 'PLAIN';
$config['managesieve_conn_options'] = @@TLSOPT@@;
$config['managesieve_vacation'] = 1;
$config['managesieve_forward'] = 1;
'''.replace('@@TLSOPT@@',TLSOPT))
write('/etc/roundcube/plugins/password/config.inc.php',r'''<?php
$config = [];
$config['password_driver'] = 'sql';
$config['password_confirm_current'] = true;
$config['password_minimum_length'] = 12;
$config['password_algorithm'] = 'dovecot';
$config['password_dovecotpw'] = '/usr/bin/doveadm -O pw';
$config['password_dovecotpw_method'] = 'ARGON2ID';
$config['password_dovecotpw_with_method'] = true;
$config['password_db_dsn'] = '@@DSN@@://rcpassword:@@rcpw_pass@@@127.0.0.1/postfixadmin';
$config['password_query'] = "UPDATE mailbox SET password=%P, modified=NOW() WHERE username=%u AND active='1'";
''')
write('/etc/php/mailstack.ini','''expose_php = Off
upload_max_filesize = 18M
post_max_size = 20M
memory_limit = 256M
session.cookie_httponly = 1
session.use_strict_mode = 1
''')
write('/etc/apache2/ports.conf','''Listen 80
Listen 443
Listen 127.0.0.1:8080
''',0o644)
write('/etc/apache2/sites-available/mailstack-http.conf',r'''<VirtualHost *:80>
ServerName @@H@@
DocumentRoot /var/www/mailstack-acme
<Directory /var/www/mailstack-acme>
Options -Indexes
AllowOverride None
Require all granted
</Directory>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
RewriteRule ^ https://@@H@@%{REQUEST_URI} [R=301,L]
</VirtualHost>
''',0o644)
write('/etc/apache2/sites-available/mailstack-webmail.conf',r'''<VirtualHost *:443>
ServerName @@H@@
DocumentRoot /var/lib/roundcube/public_html
SSLEngine on
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCertificateFile /etc/letsencrypt/live/@@H@@/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/@@H@@/privkey.pem
Include /etc/roundcube/apache.conf
<Directory /var/lib/roundcube/public_html>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<LocationMatch "^/(installer|config|SQL|logs|temp)(/|$)">
Require all denied
</LocationMatch>
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "same-origin"
Header always set X-Frame-Options "SAMEORIGIN"
ErrorLog ${APACHE_LOG_DIR}/webmail-error.log
CustomLog ${APACHE_LOG_DIR}/webmail-access.log combined
</VirtualHost>
''',0o644)
write('/etc/apache2/sites-available/mailstack-admin.conf',r'''<VirtualHost 127.0.0.1:8080>
ServerName localhost
DocumentRoot /usr/share/postfixadmin/public
<Directory /usr/share/postfixadmin/public>
Options -Indexes +FollowSymLinks
AllowOverride None
Require local
DirectoryIndex index.php
</Directory>
<LocationMatch "^/(setup|upgrade)\.php$">
Require all denied
</LocationMatch>
ErrorLog ${APACHE_LOG_DIR}/mailadmin-error.log
CustomLog ${APACHE_LOG_DIR}/mailadmin-access.log combined
</VirtualHost>
''',0o644)
write('/etc/rspamd/local.d/redis.conf','servers = "127.0.0.1";')
write('/etc/rspamd/local.d/worker-proxy.inc','''bind_socket = "127.0.0.1:11332";
milter = yes;
timeout = 120s;
upstream "local" {
default = yes;
self_scan = yes;
}
''')
write('/etc/rspamd/local.d/worker-controller.inc','''bind_socket = "127.0.0.1:11334";
password = "@@rspamd_hash@@";
enable_password = "@@rspamd_hash@@";
''')
write('/etc/rspamd/local.d/dkim_signing.conf','''path = "/var/lib/rspamd/dkim/$domain.$selector.key";
selector = "mail";
use_domain = "header";
use_esld = false;
sign_authenticated = true;
sign_local = true;
allow_username_mismatch = false;
allow_hdrfrom_mismatch = false;
''')
write('/etc/rspamd/local.d/milter_headers.conf','''extended_spam_headers = true;
''')
write('/etc/rspamd/local.d/antivirus.conf','''clamav {
action = "reject";
symbol = "CLAM_VIRUS";
type = "clamav";
servers = "127.0.0.1:3310";
scan_mime_parts = true;
timeout = 30s;
retransmits = 2;
}
''')
write('/etc/rspamd/local.d/force_actions.conf','''rules {
CLAMAV_UNAVAILABLE {
action = "soft reject";
expression = "CLAM_VIRUS_FAIL";
message = "Antivirus temporarily unavailable; try again later";
}
}
''')
write('/etc/rspamd/local.d/ratelimit.conf','''rates {
authenticated_user {
selector = "user.lower";
bucket {
burst = 100;
rate = "100 / 1h";
}
}
}
''')
write('/etc/redis/mailstack-note.txt','Keep Redis on loopback; do not expose port 6379.',0o600)
write('/etc/fail2ban/jail.d/mailstack.local',f'''[DEFAULT]
banaction = nftables-multiport
[postfix-sasl]
enabled = true
backend = auto
port = 465,587
logpath = /var/log/mail.log
maxretry = 5
findtime = 10m
bantime = 1h
[dovecot]
enabled = true
backend = auto
port = 993{',995' if pop else ''}
logpath = /var/log/mail.log
maxretry = 5
findtime = 10m
bantime = 1h
''')
write('/etc/letsencrypt/renewal-hooks/deploy/20-mailstack',r'''#!/bin/sh
set -eu
[ -f /etc/mailstack/installed ] || exit 0
postfix check
doveconf -n >/dev/null
apache2ctl configtest
systemctl reload postfix
systemctl reload dovecot
systemctl reload apache2
''',0o750)
write('/usr/local/lib/mailstack/bootstrap.php',r'''<?php
// Run locally as root; secrets are read from a root-only JSON file, not argv.
define('POSTFIXADMIN_CLI', true);
$c = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
chdir('/usr/share/postfixadmin/public');
require 'upgrade.php';
function create_record($class,$id,$values) {
$o=new $class(1);
if (!$o->init($id) || !$o->set($values) || !$o->save()) {
// Do not dump handler state: it can contain plaintext passwords.
throw new RuntimeException('PostfixAdmin bootstrap failed for '.$class.' '.$id);
}
}
create_record('DomainHandler', $c['domain'], [
'description'=>'Mail domain','aliases'=>0,'mailboxes'=>0,
'maxquota'=>10240,'quota'=>0,'transport'=>'virtual',
'backupmx'=>0,'active'=>1,'default_aliases'=>0]);
create_record('AdminHandler', 'admin@'.$c['domain'], [
'password'=>$c['admin_pass'],'password2'=>$c['admin_pass'],
'superadmin'=>1,'active'=>1,'domains'=>[]]);
create_record('MailboxHandler', $c['mailbox'], [
'password'=>$c['mailbox_pass'],'password2'=>$c['mailbox_pass'],
'name'=>'Mail administrator','quota'=>2048,'active'=>1,'welcome_mail'=>0]);
foreach (['postmaster','abuse','dmarc'] as $local) {
create_record('AliasHandler', $local.'@'.$c['domain'], [
'goto'=>[$c['mailbox']], 'active'=>1]);
}
echo "PostfixAdmin schema, domain, admin, mailbox and aliases initialized.\n";
''',0o600)
write('/usr/local/sbin/mailstack-health',r'''#!/usr/bin/env bash
set -uo pipefail
fail=0
for s in @@DBSVC@@ dovecot rspamd redis-server apache2 clamav-daemon clamav-freshclam fail2ban; do
if systemctl is-active --quiet "$s"; then echo "OK service $s";
else echo "FAIL service $s"; fail=1; fi
done
if pgrep -x master >/dev/null && postfix status >/dev/null 2>&1; then echo 'OK Postfix';
else echo 'FAIL Postfix'; fail=1; fi
if ! postfix check; then fail=1; fi
if ! doveconf -n >/dev/null; then fail=1; fi
if ! rspamadm configtest >/dev/null; then fail=1; fi
if ! apache2ctl configtest; then fail=1; fi
h=$(python3 -c 'import json;print(json.load(open("/etc/mailstack/identity.json"))["hostname"])')
m=$(python3 -c 'import json;print(json.load(open("/etc/mailstack/identity.json"))["mailbox"])')
if ! openssl x509 -in "/etc/letsencrypt/live/$h/fullchain.pem" -checkend 1209600 -noout; then fail=1; fi
if ! doveadm user "$m" >/dev/null; then fail=1; fi
if ! [[ -S /var/spool/postfix/private/auth && -S /var/spool/postfix/private/dovecot-lmtp ]]; then fail=1; fi
if ! nft list table inet mailstack >/dev/null; then fail=1; fi
for port in 25 465 587 993 443 3310 11332; do
if ! timeout 3 bash -c "exec 3<>/dev/tcp/127.0.0.1/$port"; then echo "FAIL port $port"; fail=1; fi
done
printf 'Disk and inode usage:\n'; df -h /var/vmail; df -i /var/vmail
printf 'Mail queue:\n'; postqueue -p
exit "$fail"
''',0o750)
write('/usr/local/sbin/mailstack-backup',r'''#!/usr/bin/env bash
# OFFLINE encrypted local backup. Schedule only after testing off-host restore.
set -Eeuo pipefail
umask 077
exec 9>/run/mailstack-backup.lock
flock -n 9 || exit 0
[[ $EUID == 0 ]] || { echo 'Run as root'; exit 1; }
install -d -m 0700 /var/backups/mailstack
work=$(mktemp -d /var/backups/mailstack/work.XXXXXX)
restore_services() {
rc=$?
systemctl start dovecot postfix apache2 || true
rm -rf -- "$work"
exit "$rc"
}
trap restore_services EXIT
systemctl stop apache2 postfix dovecot
@@DBDUMP@@
out="/var/backups/mailstack/mailstack-$(date +%F-%H%M%S).tar.gz.gpg"
tar --numeric-owner -czf - \
-C / etc/postfix etc/dovecot etc/postfixadmin etc/roundcube etc/apache2 \
@@DBCONF@@ etc/rspamd etc/mailstack etc/letsencrypt etc/fail2ban \
var/vmail var/spool/postfix var/lib/rspamd/dkim root/mailstack-credentials.json \
-C "$work" . |
gpg --batch --yes --pinentry-mode loopback \
--passphrase-file /etc/mailstack/backup.pass --symmetric \
--cipher-algo AES256 --output "$out.partial"
mv "$out.partial" "$out"
printf 'Created %s\nCopy it AND the backup passphrase to independent storage.\n' "$out"
''',0o750)
write('/etc/systemd/system/mailstack-health.service','''[Unit]
Description=Mailstack local health checks
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/mailstack-health
''',0o644)
write('/etc/systemd/system/mailstack-health.timer','''[Unit]
Description=Check mailstack every 15 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
[Install]
WantedBy=timers.target
''',0o644)
write('/etc/systemd/system/mailstack-backup.service','''[Unit]
Description=Offline encrypted mailstack backup (maintenance window)
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/mailstack-backup
''',0o644)
write('/etc/systemd/system/mailstack-backup.timer','''[Unit]
Description=Optional nightly offline backup; NOT enabled by installer
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=false
[Install]
WantedBy=timers.target
''',0o644)
write('/etc/systemd/system/mailstack-firewall.service','''[Unit]
Description=Mailstack scoped network protection (does not replace global firewall)
After=network-pre.target nftables.service
Before=postfix.service dovecot.service apache2.service mariadb.service postgresql.service redis-server.service rspamd.service clamav-daemon.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/mailstack/firewall.nft
ExecReload=/usr/sbin/nft -f /etc/mailstack/firewall.nft
[Install]
WantedBy=multi-user.target
''',0o644)
if pg:
# Roles first; databases owned by the application roles. Passwords are hex.
write('/db-initial.sql',r'''CREATE ROLE pfa LOGIN PASSWORD '@@pfa_pass@@';
CREATE ROLE mailreader LOGIN PASSWORD '@@read_pass@@';
CREATE ROLE roundcube LOGIN PASSWORD '@@rc_pass@@';
CREATE ROLE rcpassword LOGIN PASSWORD '@@rcpw_pass@@';
CREATE DATABASE postfixadmin OWNER pfa ENCODING 'UTF8' TEMPLATE template0;
CREATE DATABASE roundcube OWNER roundcube ENCODING 'UTF8' TEMPLATE template0;
REVOKE ALL ON DATABASE postfixadmin FROM PUBLIC;
REVOKE ALL ON DATABASE roundcube FROM PUBLIC;
GRANT CONNECT ON DATABASE postfixadmin TO mailreader, rcpassword;
''',0o600)
# Runs inside the postfixadmin database after the PostfixAdmin schema exists.
write('/db-rights.sql',r'''GRANT USAGE ON SCHEMA public TO mailreader, rcpassword;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mailreader;
ALTER DEFAULT PRIVILEGES FOR ROLE pfa IN SCHEMA public GRANT SELECT ON TABLES TO mailreader;
GRANT SELECT (username,password,active), UPDATE (password,modified) ON mailbox TO rcpassword;
''',0o600)
else:
write('/db-initial.sql',r'''CREATE DATABASE postfixadmin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE roundcube CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'pfa'@'127.0.0.1' IDENTIFIED BY '@@pfa_pass@@';
GRANT ALL PRIVILEGES ON postfixadmin.* TO 'pfa'@'127.0.0.1';
CREATE USER 'mailreader'@'127.0.0.1' IDENTIFIED BY '@@read_pass@@';
GRANT SELECT ON postfixadmin.* TO 'mailreader'@'127.0.0.1';
CREATE USER 'roundcube'@'127.0.0.1' IDENTIFIED BY '@@rc_pass@@';
GRANT ALL PRIVILEGES ON roundcube.* TO 'roundcube'@'127.0.0.1';
CREATE USER 'rcpassword'@'127.0.0.1' IDENTIFIED BY '@@rcpw_pass@@';
''',0o600)
write('/db-rights.sql',r'''GRANT SELECT (username,password,active), UPDATE (password,modified) ON postfixadmin.mailbox TO 'rcpassword'@'127.0.0.1';
''',0o600)
# Ensure no accidental placeholder survived. Generated @ characters in email/DSN
# are intentional; only the exact @@NAME@@ pattern is a placeholder.
import re
for f in root.rglob('*'):
if f.is_file() and re.search(r'@@[A-Za-z_]+@@',f.read_text()):
raise SystemExit('Unresolved placeholder in '+str(f))
print('Rendered configuration files (credentials not printed).')
PY
}
make_credentials() {
python3 - "$CREDS" "$DOMAIN" "$MAILHOST" "$PUBLIC_IP" "$SSH_PORT" "$MODE" "$DB" <<'PY'
import json,secrets,sys,os
path,d,h,ip,ssh,mode,db=sys.argv[1:]
def secret(n=32): return secrets.token_hex(n) if mode!='render' else 'a1'*n
c=dict(domain=d,hostname=h,public_ip=ip,ssh_port=int(ssh),mailbox='mailadmin@'+d,db=db,
**{k:secret() for k in ['pfa_pass','read_pass','rc_pass','rcpw_pass','admin_pass','mailbox_pass','rspamd_pass','backup_pass']})
c['rc_key']=secret(12);c['rspamd_hash']='DUMMY_HASH_RENDER_ONLY'
with open(path,'x') as f: json.dump(c,f,indent=2);f.write('\n')
os.chmod(path,0o600)
PY
}
if [[ $MODE == render ]]; then
validate_inputs
[[ ! -e "$OUT" ]] || die 'Render output must not exist (prevents overwrite).'
mkdir -p -- "$OUT"
chmod 0700 "$OUT"
OUT=$(realpath "$OUT"); CREDS="$OUT/dummy-credentials.json"
make_credentials; render
printf 'RENDER ONLY complete. Dummy configs at %s. Do not deploy them.\n' "$OUT"
exit
fi
((EUID == 0)) || die 'Run as root.'
[[ -r /etc/os-release ]] || die 'Missing /etc/os-release.'
# shellcheck disable=SC1091
. /etc/os-release
[[ ${ID:-} == debian && ${VERSION_ID:-} == 13 ]] || die 'Only Debian 13 is supported.'
if [[ -z $DOMAIN ]]; then read -r -p 'Mail domain (lowercase ASCII): ' DOMAIN; fi
if [[ -z $MAILHOST ]]; then read -r -p 'Mail hostname (lowercase ASCII): ' MAILHOST; fi
if [[ -z $PUBLIC_IP ]]; then read -r -p 'Public IPv4 address: ' PUBLIC_IP; fi
validate_inputs
[[ ! -e /etc/mailstack && ! -e /root/mailstack-credentials.json ]] || die 'Existing/partial mailstack installation detected.'
[[ ! -e /usr/sbin/policy-rc.d ]] || die 'Existing policy-rc.d: review host policy before installation.'
for data_dir in /var/vmail /var/lib/mysql /var/lib/postgresql; do
if [[ -d $data_dir && -n $(find "$data_dir" -mindepth 1 -print -quit) ]]; then
die "Existing data in $data_dir. Use a fresh host."
fi
done
for pkg in postfix exim4-daemon-light exim4-daemon-heavy dovecot-core mariadb-server postgresql apache2 roundcube-core postfixadmin rspamd redis-server clamav-daemon; do
if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed'; then
die "Existing package $pkg detected. Use a fresh dedicated host, not a migration."
fi
done
[[ $(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo) -ge 3800 ]] || die 'At least 4 GiB RAM required with ClamAV; 8 GiB recommended.'
[[ $(df -Pk / | awk 'NR==2{print $4}') -ge 10485760 ]] || die 'At least 10 GiB free space required (more for real mail).'
getent passwd 5000 >/dev/null && die 'UID 5000 is occupied.'
getent group 5000 >/dev/null && die 'GID 5000 is occupied.'
getent passwd vmail >/dev/null && die 'User vmail already exists.'
if (( ! YES )); then
printf '\nWill configure a NEW host for %s (%s). DNS/PTR are not modified.\n' "$DOMAIN" "$MAILHOST"
printf 'Existing host firewall is preserved. Mailstack ports will be protected.\n'
read -r -p 'Type INSTALL to proceed (a provider snapshot is recommended): ' answer
[[ $answer == INSTALL ]] || die 'Cancelled.'
fi
INSTALL_STARTED=1
install -d -m 0700 /etc/mailstack
CREDS=/root/mailstack-credentials.json
make_credentials
install -m 0600 /dev/null /var/log/mailstack-install.log
exec > >(tee -a /var/log/mailstack-install.log) 2>&1
printf 'Mailstack %s started at %s\n' "$VERSION" "$(date -Is)"
TMP=$(mktemp -d /root/mailstack-build.XXXXXX)
printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
chmod 0755 /usr/sbin/policy-rc.d; POLICY_OWNED=1
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y nftables dnsutils ca-certificates openssl curl python3 gnupg
firewall() {
local mode=$1
cat > /etc/mailstack/firewall.nft <<'NFT'
add table inet mailstack
flush table inet mailstack
table inet mailstack {
chain input {
type filter hook input priority -20; policy accept;
iifname "lo" accept
tcp dport { 8080, 4190, 3306, 3310, 5432, 6379, 11332, 11333, 11334 } reject with tcp reset
NFT
if [[ $mode == setup ]]; then
printf ' tcp dport { 25, 443, 465, 587, 993, 995 } reject with tcp reset\n' >> /etc/mailstack/firewall.nft
elif (( ! POP3 )); then
printf ' tcp dport 995 reject with tcp reset\n' >> /etc/mailstack/firewall.nft
fi
printf ' }\n}\n' >> /etc/mailstack/firewall.nft
nft -c -f /etc/mailstack/firewall.nft
nft -f /etc/mailstack/firewall.nft
}
firewall setup
# No external scripts executed; only distribution packages.
A=$(dig +short A "$MAILHOST" | grep -Fx "$PUBLIC_IP" || true)
[[ -n $A ]] || die 'Public A record must already point to --public-ip.'
[[ -z $(dig +short AAAA "$MAILHOST") ]] || die 'IPv4-only installer: remove AAAA or use a reviewed IPv6 configuration.'
PTR=$(dig +short -x "$PUBLIC_IP" | tr '[:upper:]' '[:lower:]')
[[ $PTR == "$MAILHOST." ]] || printf 'WARNING: PTR does not match; change it at your provider before production.\n'
printf 'postfix postfix/main_mailer_type select Internet Site\npostfix postfix/mailname string %s\npostfixadmin postfixadmin/dbconfig-install boolean false\nroundcube-core roundcube/dbconfig-install boolean false\n' "$DOMAIN" | debconf-set-selections
if [[ $DB == postgresql ]]; then
DBPKGS=(postgresql postfix-pgsql dovecot-pgsql php-pgsql roundcube-pgsql)
else
DBPKGS=(mariadb-server postfix-mysql dovecot-mysql php-mysql roundcube-mysql)
fi
apt-get install -y "${DBPKGS[@]}" postfix dovecot-core dovecot-imapd \
dovecot-lmtpd dovecot-sieve dovecot-managesieved \
apache2 libapache2-mod-php php-cli \
php-mbstring php-intl php-xml php-curl php-zip php-gd \
postfixadmin roundcube roundcube-plugins \
rspamd redis-server clamav-daemon clamav-freshclam fail2ban \
certbot swaks rsyslog
if ((POP3)); then apt-get install -y dovecot-pop3d; fi
[[ $(dovecot --version) == 2.4.* ]] || die 'Expected Dovecot 2.4.x.'
doveadm -O pw -l | grep -qw ARGON2ID || die 'ARGON2ID unavailable.'
# rspamadm requires a controlling TTY. Use a private PTY, not plaintext argv.
python3 - "$CREDS" <<'PY'
import json,os,pty,select,time,re,sys,signal
p=sys.argv[1];c=json.load(open(p))
pid,fd=pty.fork()
if pid==0: os.execvp('rspamadm',['rspamadm','pw'])
buf=b''; sent=False; deadline=time.monotonic()+30
try:
while time.monotonic()<deadline:
ready,_,_=select.select([fd],[],[],0.5)
if not ready: continue
try: part=os.read(fd,4096)
except OSError: break
if not part: break
buf+=part
if b'passphrase:' in buf.lower() and not sent:
os.write(fd,(c['rspamd_pass']+'\n').encode());sent=True
else:
os.kill(pid,signal.SIGKILL)
raise RuntimeError('rspamadm pw timeout')
finally:
os.close(fd)
_,status=os.waitpid(pid,0)
if os.waitstatus_to_exitcode(status)!=0: raise SystemExit('rspamadm pw failed')
m=re.search(rb'\$[A-Za-z0-9]+\$[^\s]+',buf)
if not m: raise SystemExit('Cannot parse rspamadm password hash')
c['rspamd_hash']=m.group(0).decode()
with open(p,'w') as f: json.dump(c,f,indent=2);f.write('\n')
PY
OUT="$TMP/render";mkdir -m 0700 "$OUT";render
# Install only generated /etc and /usr files, not SQL scratch files.
while IFS= read -r -d '' f; do
target=${f#"$OUT"}
install -D -m "$(stat -c %a "$f")" "$f" "$target"
done < <(find "$OUT/etc" "$OUT/usr" -type f -print0)
# Preserve package directory modes; grant only intended service readers.
chown root:www-data /etc/postfixadmin/config.local.php /etc/roundcube/config.inc.php \
/etc/roundcube/plugins/managesieve/config.inc.php /etc/roundcube/plugins/password/config.inc.php
chown root:postfix /etc/postfix/sql/*.cf
chmod 0750 /etc/postfix/sql
chown root:postfix /etc/postfix/sql
chown root:_rspamd /etc/rspamd/local.d/*.conf /etc/rspamd/local.d/*.inc
chmod 0755 /etc/dovecot/sieve
PHPVER=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
install -m 0644 /etc/php/mailstack.ini "/etc/php/$PHPVER/mods-available/mailstack.ini"
phpenmod mailstack
hostnamectl set-hostname "$MAILHOST"
# Force local clients to loopback while still verifying the public hostname.
printf '\n127.0.0.1 %s # mailstack-local-client\n' "$MAILHOST" >> /etc/hosts
groupadd --gid 5000 vmail
useradd --uid 5000 --gid vmail --home-dir /var/vmail --no-create-home --shell /usr/sbin/nologin vmail
install -d -o vmail -g vmail -m 0750 /var/vmail
# Service starts are now explicit; remove our package-start suppression.
rm -f /usr/sbin/policy-rc.d; POLICY_OWNED=0
systemctl daemon-reload
systemctl enable --now mailstack-firewall.service
# Database administration helpers: local superuser access via peer/unix socket.
if [[ $DB == postgresql ]]; then
dbsu() { runuser -u postgres -- psql -X -q -v ON_ERROR_STOP=1 "$@"; }
systemctl enable --now postgresql
# Debian default: listen on localhost only, scram-sha-256 for 127.0.0.1.
[[ $(dbsu -Atc 'SHOW listen_addresses') == localhost ]] || die 'PostgreSQL must listen on localhost only.'
dbsu < "$OUT/db-initial.sql"
else
dbsu() { mariadb "$@"; }
systemctl enable --now mariadb
dbsu < "$OUT/db-initial.sql"
fi
php /usr/local/lib/mailstack/bootstrap.php "$CREDS"
if [[ $DB == postgresql ]]; then
[[ $(dbsu -d postfixadmin -Atc 'SELECT COUNT(*) FROM mailbox') -eq 1 ]] || die 'Mailbox bootstrap verification failed.'
dbsu -d postfixadmin < "$OUT/db-rights.sql"
RC_SCHEMA=/usr/share/roundcube/SQL/postgres.initial.sql
[[ -f $RC_SCHEMA ]] || die 'Roundcube schema path changed; review package.'
# Create Roundcube tables as their owner, not as the postgres superuser.
{ echo 'SET ROLE roundcube;'; cat "$RC_SCHEMA"; } | dbsu -d roundcube
else
[[ $(dbsu -NBe 'SELECT COUNT(*) FROM postfixadmin.mailbox') -eq 1 ]] || die 'Mailbox bootstrap verification failed.'
dbsu < "$OUT/db-rights.sql"
RC_SCHEMA=/usr/share/roundcube/SQL/mysql.initial.sql
[[ -f $RC_SCHEMA ]] || die 'Roundcube schema path changed; review package.'
dbsu roundcube < "$RC_SCHEMA"
fi
for cfg in /etc/postfixadmin/config.local.php /etc/roundcube/config.inc.php; do php -l "$cfg"; done
# Apply main.cf changes without losing distribution service settings.
while IFS= read -r line; do [[ -z $line || $line == \#* ]] || postconf -e "$line"; done < /etc/postfix/mailstack-main.cf
# Fresh-host precondition: a submission service should not already be active.
if grep -Eq '^(submission|submissions|smtps|465)[[:space:]]+inet' /etc/postfix/master.cf; then
die 'Unexpected active submission service in package master.cf; review before merging.'
fi
cat /etc/postfix/mailstack-submission.cf >> /etc/postfix/master.cf
# Debian already ships "postmaster: root"; duplicate keys make newaliases warn.
grep -q '^postmaster:' /etc/aliases || printf 'postmaster: root\n' >> /etc/aliases
sed -i '/^root:/d' /etc/aliases
printf 'root: mailadmin@%s\n' "$DOMAIN" >> /etc/aliases
newaliases
install -d -m 0755 /var/www/mailstack-acme/.well-known/acme-challenge
# Disable package-global aliases and the default site on this new dedicated host.
a2disconf postfixadmin || true
a2disconf roundcube || true
a2dissite 000-default default-ssl || true
a2enmod ssl headers rewrite
# HTTP only until a valid certificate exists.
a2ensite mailstack-http
apache2ctl configtest
systemctl enable --now apache2
certbot certonly --non-interactive --agree-tos --register-unsafely-without-email \
--webroot -w /var/www/mailstack-acme --cert-name "$MAILHOST" -d "$MAILHOST"
[[ -f /etc/letsencrypt/live/$MAILHOST/fullchain.pem ]] || die 'Certificate path missing.'
# CA account has no email because first mailbox is not online at issue time.
# Configure an externally reachable ACME account contact after installation.
a2ensite mailstack-webmail mailstack-admin
apache2ctl configtest
systemctl restart apache2
# Loopback TCP ClamAV avoids permissive Unix socket modes.
python3 - <<'PY'
from pathlib import Path
p=Path('/etc/clamav/clamd.conf');s=p.read_text().splitlines()
s=[x for x in s if not x.startswith(('TCPSocket ', 'TCPAddr '))]
p.write_text('\n'.join(s)+'\nTCPSocket 3310\nTCPAddr 127.0.0.1\n')
PY
# Download signatures before starting scanning. Do not claim virus protection
# when the database is missing. freshclam failure aborts behind the setup guard.
systemctl stop clamav-freshclam || true
freshclam
systemctl enable --now clamav-freshclam clamav-daemon
# Loading the signature database takes minutes on small VMs (3 min was not
# enough in testing); wait up to 10 minutes and show the reason on failure.
for ((attempt=1; attempt<=300; attempt++)); do
if timeout 2 bash -c 'exec 3<>/dev/tcp/127.0.0.1/3310' 2>/dev/null; then break; fi
sleep 2
done
if ! timeout 3 bash -c 'exec 3<>/dev/tcp/127.0.0.1/3310'; then
journalctl -u clamav-daemon -n 30 --no-pager || true
die 'ClamAV did not become ready.'
fi
install -d -o _rspamd -g _rspamd -m 0700 /var/lib/rspamd/dkim
rspamadm dkim_keygen -b 2048 -s mail -d "$DOMAIN" \
-k "/var/lib/rspamd/dkim/$DOMAIN.mail.key" > /root/mailstack-dkim.txt
chown _rspamd:_rspamd "/var/lib/rspamd/dkim/$DOMAIN.mail.key"
chmod 0600 "/var/lib/rspamd/dkim/$DOMAIN.mail.key"
# Redis default listener must be loopback. Abort if config was changed upstream.
if ! grep -Eq '^bind[[:space:]]+127\.0\.0\.1' /etc/redis/redis.conf; then
die 'Redis loopback configuration changed; inspect /etc/redis/redis.conf.'
fi
systemctl enable --now redis-server rsyslog
if ! grep -REq '^[[:space:]]*mail\.\*.*mail\.log' /etc/rsyslog.conf /etc/rsyslog.d; then
printf 'mail.* -/var/log/mail.log\n' > /etc/rsyslog.d/25-mailstack.conf
fi
rsyslogd -N1
systemctl restart rsyslog
touch /var/log/mail.log
chown root:adm /var/log/mail.log
chmod 0640 /var/log/mail.log
sievec /etc/dovecot/sieve/spam.sieve
chmod 0644 /etc/dovecot/sieve/spam.svbin
postfix check
doveconf -n > /dev/null
rspamadm configtest
fail2ban-client -t
systemctl enable --now rspamd dovecot postfix fail2ban certbot.timer
systemctl restart rspamd dovecot postfix fail2ban
# Test canonical maps before opening the public mail ports.
MAP=$([[ $DB == postgresql ]] && echo pgsql || echo mysql)
[[ $(postmap -q "$DOMAIN" proxy:$MAP:/etc/postfix/sql/domains.cf) == "$DOMAIN" ]] || die 'Domain map failed.'
[[ $(postmap -q "mailadmin@$DOMAIN" proxy:$MAP:/etc/postfix/sql/mailboxes.cf) == 1 ]] || die 'Mailbox map failed.'
[[ -z $(postmap -q "missing-$RANDOM@$DOMAIN" proxy:$MAP:/etc/postfix/sql/mailboxes.cf) ]] || die 'Unknown mailbox unexpectedly exists.'
doveadm user "mailadmin@$DOMAIN" >/dev/null
# Real local IMAP/SMTP authentication: read secrets from the root-only file,
# never in command-line arguments or logs. Deliver a test mail through milter/LMTP.
python3 - "$CREDS" <<'PY'
import json,sys,imaplib,smtplib,ssl,time
from email.message import EmailMessage
c=json.load(open(sys.argv[1]));h=c['hostname'];u=c['mailbox'];p=c['mailbox_pass'];ctx=ssl.create_default_context()
msg=EmailMessage();msg['From']=u;msg['To']=u;msg['Subject']='Mailstack installation self-test';msg.set_content('Local SMTP AUTH, TLS, milter and LMTP self-test.')
with smtplib.SMTP(h,587,timeout=30) as s:
s.ehlo();s.starttls(context=ctx);s.ehlo();s.login(u,p);s.send_message(msg)
for attempt in range(15):
with imaplib.IMAP4_SSL(h,993,ssl_context=ctx,timeout=15) as im:
im.login(u,p);im.select('INBOX');typ,data=im.search(None,'SUBJECT','"Mailstack installation self-test"');im.logout()
if typ=='OK' and data and data[0]:break
time.sleep(2)
else:raise SystemExit('Local SMTP->LMTP->IMAP delivery failed')
# Verify unauthenticated submission is refused, even for a local recipient.
with smtplib.SMTP(h,587,timeout=15) as s:
s.ehlo();s.starttls(context=ctx);s.ehlo();s.mail(u);code,_=s.rcpt(u)
if code<400:raise SystemExit('Unauthenticated submission unexpectedly allowed')
print('PASS local TLS/AUTH/delivery and unauthenticated-submission check.')
PY
/usr/local/sbin/mailstack-health
# Validate ACME renewal while external access is still guarded.
certbot renew --dry-run
# Save package versions for reproducibility; security updates may change them.
dpkg-query -W postfix dovecot-core postfixadmin roundcube-core rspamd clamav-daemon > /root/mailstack-versions.txt
cat > /root/mailstack-dns.txt <<DNS
; Replace/publish these in the authoritative DNS zone. PTR is set by IP provider.
$MAILHOST. IN A $PUBLIC_IP
$DOMAIN. IN MX 10 $MAILHOST.
$DOMAIN. IN TXT "v=spf1 ip4:$PUBLIC_IP -all"
_dmarc.$DOMAIN. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@$DOMAIN"
; PTR: $PUBLIC_IP -> $MAILHOST
; DKIM public record follows:
DNS
cat /root/mailstack-dkim.txt >> /root/mailstack-dns.txt
firewall production
systemctl enable --now mailstack-health.timer
# Offline backup timer is installed but intentionally not enabled.
printf '%s\n' "$(date -Is) version=$VERSION" > /etc/mailstack/installed
printf '\nINSTALLATION COMPLETE (local checks only).\nWebmail: https://%s/\n' "$MAILHOST"
printf 'Admin: ssh -N -L 8080:127.0.0.1:8080 -p %s YOUR_SSH_USER@%s\n' "$SSH_PORT" "$MAILHOST"
printf 'Then browse http://127.0.0.1:8080/\n'
printf 'Credentials: /root/mailstack-credentials.json (root-only; do not publish).\n'
printf 'DNS records: /root/mailstack-dns.txt\n'
printf 'Mandatory: external relay, inbound/outbound, DKIM/SPF/DMARC, AV outage\n'
printf 'and EICAR tests; DNS/PTR publication; off-host backup/restore test.\n'
printf 'No HA, ActiveSync, LDAP migration or delivery guarantee is configured.\n'22. Mailroom: своя панель управления и веб-почта
PostfixAdmin и Roundcube из установщика — проверенный, но разрозненный набор: одна программа создаёт ящики, другая показывает почту, а DNS, очередь, журналы и резервные копии остаются в консоли. Mailroom собирает всё это в одном интерфейсе. Это панель администратора и веб-почта для схемы из этой статьи: Postfix, Dovecot 2.4, Rspamd и PostgreSQL на Debian 13.
Исходный код (лицензия MIT): github.com/AlexsandrPetrov/mailroom.
Установка
Нужен чистый Debian 13 (виртуальная машина или контейнер LXC от 2 ГБ памяти) и доступ root. Ручную установку из этой статьи и Mailstack на этот же сервер ставить не нужно: Mailroom сама настраивает Postfix, Dovecot, Rspamd, PostgreSQL, Redis, Fail2ban и Radicale.
apt install git
git clone https://github.com/AlexsandrPetrov/mailroom.git /root/mailroom
cd /root/mailroom
sudo bash deploy/install.sh --hostname mail.example.com --ip 192.0.2.10--hostname— полное имя почтового сервера, на него же выпускается сертификат;--ip— адрес, на котором будут доступны панель и веб-почта:https://192.0.2.10:5080/иhttps://192.0.2.10:5080/mail.
Стартовый пароль администратора (логин admin) лежит в /var/lib/mailroom/.bootstrap-admin. После смены пароля в панели файл удаляется сам. Сертификат выпускается собственным CA; корневой сертификат для компьютеров и телефонов скачивается в веб-почте. Для сервера, доступного из Интернета, позже замените его сертификатом Let’s Encrypt.
Обновление — тот же скрипт после git pull:
cd /root/mailroom && git pull
sudo bash deploy/install.sh --hostname mail.example.com --ip 192.0.2.10Скрипт можно запускать повторно: секреты, ключи DKIM и сертификаты не перевыпускаются, прежние конфиги сохраняются в /root/mailroom-backup-ДАТА.
Как устроена
- Домены, ящики и алиасы хранятся в PostgreSQL панели. Postfix и Dovecot читают их напрямую через SQL-представления от отдельной роли с правом только на чтение. Изменения в панели действуют сразу, без перезапуска сервисов.
- Пароли ящиков хранятся в формате SHA512-CRYPT, его проверяет сам Dovecot.
- Веб-почта подключается к Dovecot через мастер-пользователя, которому разрешён вход только с
127.0.0.1. Пароль пользователя проверяется по базе и нигде не сохраняется. - Ключи DKIM создаёт панель, Rspamd подписывает письма по карте селекторов, которую панель же и ведёт.
- Привилегированные действия выполняет отдельный помощник с белым списком команд и проверкой аргументов: очередь, квоты, применение настроек Postfix, резервное копирование.
Панель администратора

Рекомендации. Живая проверка того, о чём рассказано в этой статье: DNS-записи, PTR, сертификат, работа сервисов, чёрные списки, служебные адреса postmaster@ и abuse@, 2FA администраторов, свежесть резервных копий, всплески отправки. У каждого пункта есть объяснение и способ исправить.

Мастер DNS. Для каждого домена панель формирует MX, A, PTR, SPF, DKIM, DMARC, TLS-RPT и SRV-записи для автонастройки почтовых программ, а кнопкой «Проверить DNS» сравнивает их с опубликованными. Мастер учитывает то, что уже есть:
- если у домена уже есть SPF (например,
redirect=_spf.mail.ru), он предлагает одну объединённую запись, а не вторую; - более строгую политику DMARC не ослабляет — добавляет только недостающий адрес отчётов;
- если MX указывает на другой сервер, предупреждает, что менять его можно только после переноса ящиков.

DKIM. Ключ 2048 бит создаётся одной кнопкой. Подпись включается только после того, как панель найдёт опубликованный ключ в DNS: так получатели не увидят dkim=fail, пока запись ещё не появилась. Ключ меняется с новым селектором без перерыва подписи. SPF и DMARC настраиваются в форме с объяснением каждого тега, а запись собирается автоматически.

Остальные разделы:
- Ящики — квоты с фактически занятым местом, отключение, сброс пароля, удаление с переносом писем в архив.
- Алиасы — пересылка одному или нескольким получателям; получатели-ящики могут отправлять письма от имени алиаса.
- Очередь — причина задержки каждого письма, повторная доставка, задержка и удаление.
- Трассировка — путь письма по журналам Postfix, Rspamd и Dovecot по адресу, ID очереди или Message-ID.
- Проверки — DNS любого домена, SMTP-диалог (EHLO, STARTTLS, AUTH), сертификат, чёрные списки, тестовое письмо и разбор заголовков: как получатель увидел SPF, DKIM и DMARC и сколько письмо шло между серверами.
- Активность — кто входил по IMAP/POP3 и с каких адресов, сколько каждый ящик отправил через 587/465, ошибки паролей. Это главный способ вовремя заметить украденный пароль.

Эксплуатация
- Резервные копии каждую ночь: база, письма, ключи DKIM, сертификаты, конфигурация, календари. Архив шифруется AES256, хранятся последние N копий. Копию можно отправлять на другой сервер по SSH — публичный ключ показан в панели. Пароль шифрования лежит в
/etc/mailroom/backup.pass: сохраните его отдельно от сервера. - Оповещения на почту и в Telegram. Каждые 15 минут сервер проверяет сервисы, очередь, сертификат, место, чёрные списки, копии и всплески отправки. Сообщение приходит о новой проблеме и о её решении, о нерешённой — раз в сутки.
- Лимит отправки для почтовых программ задаётся в Rspamd: по умолчанию 100 писем в час на ящик. Если пароль ящика украдут, рассылка упрётся в лимит, а администратору придёт оповещение.
- Обучение антиспама: кнопки «Спам» и «Не спам» в веб-почте передают письмо байесовскому фильтру Rspamd.

Восстановить доступ можно и без панели — консольной утилитой:
| Команда | Что делает |
|---|---|
sudo mailroom passwd admin |
новый пароль администратора панели |
sudo mailroom disable-2fa admin |
отключить 2FA, если потерян телефон |
sudo mailroom unban 203.0.113.5 |
снять блокировку после ошибок пароля |
sudo mailroom mailbox-passwd user@example.com |
новый пароль ящика |
sudo mailroom backup / restore ФАЙЛ |
создать копию / восстановить из неё |
sudo mailroom check |
проверить сервер и отправить оповещения |
Веб-почта
Отдельная страница для владельцев ящиков, вход — адресом и паролем почты.
- Папки, в том числе вложенные и с кириллицей; поиск по отправителю, теме и тексту; цепочки писем; перетаскивание в папки.
- Безопасный показ HTML: письмо открывается в изолированном фрейме без скриптов, под строгой CSP. Внешние картинки, через которые отправитель узнаёт, что письмо открыто, скрыты до нажатия «Показать изображения».
- Письма с оформлением: жирный и курсив, списки, цитаты, ссылки, картинки прямо в тексте (уходят встроенными вложениями). Вставленный текст из Word и с сайтов очищается от лишнего.
- Ответ, ответ всем, пересылка с вложениями, черновики, подпись, адресная книга с автодополнением.
- Предпросмотр вложений: картинки, PDF и текст.
- Новые письма появляются сразу, без обновления страницы (IMAP IDLE), по желанию — уведомления на рабочем столе.
- Автоответ на время отпуска, переадресация, фильтры по отправителю, получателю и теме — всё через Sieve, без ручного редактирования скриптов.
- Смена пароля, список активных сеансов, квота, параметры для почтовых программ и корневой сертификат для скачивания.
- Тёмная тема и раскладка для телефона.




Календарь и контакты
Вместе с панелью ставится Radicale — сервер CalDAV и CardDAV. Логин и пароль те же, что у почты: Radicale проверяет их через Dovecot. У каждого ящика заранее есть календарь и адресная книга. Подключение:
- Android — приложение DAVx5, «вход по URL»:
https://mail.example.com:5232/; - iPhone и iPad — Настройки → Календарь → Учётные записи → Другое → CalDAV, и так же CardDAV для контактов;
- Thunderbird — новый сетевой календарь и адресная книга CardDAV.
Точные адреса для конкретного ящика показаны в веб-почте: «Настройки → Календарь».
Кому подойдёт
Mailroom рассчитана на одного администратора и от нескольких до полусотни ящиков: семья, небольшая организация, отдел, внутренняя почта в локальной сети. Она не заменяет Exchange: нет ActiveSync, общих ящиков и календарей, резервного сервера. Доставляемость писем, как и при любой другой панели, зависит от репутации IP, PTR и DNS-записей из разделов 1 и 11.