Почтовый сервер Postfix на Debian 13: Dovecot 2.4, Rspamd, DKIM и панель Mailroom

обновлено 83 мин проверено на Debian 13 (Trixie), Postfix 3.10, Dovecot 2.4.1, PostgreSQL 17
Содержание
  1. Что изменилось по сравнению со старой версией
  2. Схема работы
  3. Значения в примерах
  4. 1. Подготовка сервера и DNS
  5. DNS
  6. Имя и время
  7. 2. Установка пакетов
  8. Резервная копия пакетной конфигурации
  9. 3. Пользователь и каталог хранения
  10. 4. База данных и разделение доступа
  11. 5. PostfixAdmin
  12. Панель — только через SSH-туннель
  13. 6. Сертификат TLS
  14. Автоматическое обновление
  15. 7. Postfix: основная конфигурация
  16. Служебные письма
  17. 8. SQL-карты Postfix
  18. 9. Отправка клиентами: порты 587 и 465
  19. 10. Dovecot 2.4: IMAPS, SASL и LMTP
  20. Квоты
  21. POP3 — только при необходимости
  22. 11. Rspamd: антиспам и DKIM
  23. Ключ DKIM
  24. 12. Первый запуск и firewall
  25. 13. Проверка работы
  26. TLS
  27. Аутентификация
  28. Отправка и доставка
  29. Проверка на open relay
  30. Настройки почтового клиента
  31. 14. Логи, очередь и типичные ошибки
  32. 15. Защита от перебора и эксплуатация
  33. Резервное копирование
  34. 16. Перенос существующего сервера
  35. 17. Альтернатива: системные пользователи Linux
  36. 18. LDAP: что решить до настройки
  37. 19. Если нужен именно шлюз перед другим сервером
  38. 20. Итоговый чек-лист перед вводом в эксплуатацию
  39. 21. Автоматическая установка: Mailstack
  40. Требования
  41. Запуск
  42. Что создаётся
  43. После установки — обязательно
  44. Обслуживание
  45. Если установка прервалась
  46. Что проверено
  47. 22. Mailroom: своя панель управления и веб-почта
  48. Установка
  49. Как устроена
  50. Панель администратора
  51. Эксплуатация
  52. Веб-почта
  53. Календарь и контакты
  54. Кому подойдёт
  55. Источники

Полностью переработанная версия статьи о 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

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-запись должна возвращать тот же адрес.

Имя и время

bash
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.

bash
dig +short MX example.com
dig +short A mail.example.com
dig +short -x 203.0.113.10
dig +short TXT example.com

2. Установка пакетов

Выберите СУБД. Набор пакетов отличается только драйверами базы:

PostgreSQL

bash
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 rsyslog

MariaDB

bash
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.

bash
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: сервисы с промежуточной конфигурацией не должны быть доступны из Интернета.

Резервная копия пакетной конфигурации

bash
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 свободны:

bash
getent passwd 5000; getent group 5000
getent passwd vmail; getent group vmail

Если заняты — выберите другие значения и замените их во всех настройках ниже.

bash
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

bash
# Debian по умолчанию слушает только localhost — проверьте:
runuser -u postgres -- psql -Atc 'SHOW listen_addresses'
ss -lntp | grep ':5432'
# SQL-консоль без истории
runuser -u postgres -- env PSQL_HISTORY=/dev/null psql

MariaDB

bash
# В /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 mariadb

PostgreSQL

sql
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;
\q

MariaDB

sql
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:

config.local.phpphp
<?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 и запуск от имени веб-сервера:

bash
doveadm -O pw -l
runuser -u www-data -- /usr/bin/doveadm -O pw -s ARGON2ID

Пароль вводится интерактивно; полученный хеш никуда не публикуйте. Если ARGON2ID недоступен, выберите другую стойкую схему (например, SHA512-CRYPT) одновременно в PostfixAdmin и Dovecot. К MD5 не возвращайтесь.

bash
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 на:

apache
Listen 127.0.0.1:80

Если Apache уже обслуживает сайты, глобальные настройки не трогайте: сделайте отдельный virtual host с HTTPS и доступом только из VPN или с административных IP.

bash
a2disconf postfixadmin
a2dissite 000-default

/etc/apache2/sites-available/mailadmin.conf:

mailadmin.confapache
<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>
bash
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:

bash
ssh -N -L 8080:127.0.0.1:80 admin@mail.example.com

HTTP здесь допустим, потому что трафик идёт внутри SSH. Дальше:

  1. На странице настройки сформируйте хеш setup-пароля и подставьте его вместо REPLACE_WITH_SETUP_HASH.
  2. Создайте схему БД и администратора.
  3. Создайте домен example.com, ящик alice@example.com и алиасы postmaster@, abuse@, dmarc@ на существующий ящик.
  4. Закройте setup внутри virtual host и перезагрузите Apache:
apache
<LocationMatch "^/(setup|upgrade)\.php$">
    Require all denied
</LocationMatch>

Для планового обновления схемы открывайте setup временно и только через административный канал. Наружу публикуется только public, а не весь /usr/share/postfixadmin.

Только для PostgreSQL: когда таблицы созданы, выдайте читателю права. ALTER DEFAULT PRIVILEGES нужен, чтобы таблицы, добавленные будущими обновлениями PostfixAdmin, тоже были доступны:

sql
-- 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;

Проверьте данные:

sql
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, поэтому на время выпуска его останавливаем:

bash
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:

bash
#!/bin/sh
set -eu
systemctl stop apache2

/etc/letsencrypt/renewal-hooks/post/10-start-mailadmin:

bash
#!/bin/sh
set -eu
systemctl start apache2

/etc/letsencrypt/renewal-hooks/deploy/20-reload-mail (создайте после настройки Postfix и Dovecot):

bash
#!/bin/sh
set -eu
postfix check
systemctl reload postfix
doveconf -n >/dev/null
systemctl reload dovecot

При DNS-01 или webroot останавливать Apache не нужно, pre/post-хуки тогда не создавайте.

bash
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

bash
MAP=pgsql

MariaDB

bash
MAP=mysql
bash
postconf -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.com
bash
newaliases

8. SQL-карты Postfix

bash
install -d -o root -g postfix -m 0750 /etc/postfix/sql

Каждый из пяти файлов начинается с общего блока (пароль замените):

ini
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 — основной домен и активные домены-алиасы:

sql
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, путь здесь не нужен):

sql
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:

sql
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 через них не поддерживаются намеренно:

sql
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 — кто может отправлять с этого адреса: владелец ящика, а для алиаса — его активные получатели-ящики:

sql
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'
bash
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 уже есть, отредактируйте их, а не добавляйте дубли:

master.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

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_mismatch

submissions — имя службы порта 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
}

Остальной файл общий. Вставьте выбранный блок на место комментария:

/etc/dovecot/dovecot.confтекст
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.

bash
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]

bash
doveadm user alice@example.com
doveadm quota get -u alice@example.com

Смените лимит тестового ящика и убедитесь, что Dovecot видит новое значение и ограничивает доставку. Фактический объём ящика PostfixAdmin с драйвером count сам не показывает.

POP3 — только при необходимости

IMAP обычно удобнее: папки и состояние синхронизируются между устройствами. Если POP3S всё же нужен:

bash
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 не нужен.

bash
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

bash
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]

bash
rspamadm configtest
systemctl restart rspamd
ss -lntp | grep -E ':11332|:11334|:6379'
dig +short TXT mail._domainkey.example.com

Только после успешного запуска подключайте milter:

bash
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

bash
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 и убедитесь, что есть аварийная консоль:

bash
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 verbose

80/tcp нужен только для HTTP-01 certbot. Для POP3S добавьте 995. Порты 5432/3306, 6379, 11332–11334 и LMTP наружу не открываются. Не накладывайте UFW вслепую на существующие правила nftables, Docker или firewall провайдера.

13. Проверка работы

TLS

С другого компьютера, по имени из сертификата:

bash
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 не доказывает.

Аутентификация

bash
doveadm user alice@example.com
doveadm auth test alice@example.com

Пароль вводите в запросе, не в аргументах. Проверьте верный пароль, неверный, несуществующий адрес и выключенный ящик.

Отправка и доставка

С внешнего компьютера, на свой реальный контрольный адрес:

bash
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. Проверка должна подтвердить:

  1. TLS включается до AUTH.
  2. Верный пароль проходит, неверный — нет.
  3. Без AUTH отправка на 587/465 отвергается, даже на свой домен.
  4. Вошедший как alice@example.com не может указать MAIL FROM: bob@example.com.
  5. Письмо пришло внешнему получателю, а в его заголовке Authentication-Results (добавленном принимающей стороной) — spf=pass, dkim=pass, dmarc=pass.
  6. Ответное письмо извне доставляется в IMAP-ящик.

Приём на порт 25 (отправитель — ваш реальный тестовый адрес):

bash
swaks --server mail.example.com --port 25 \
  --from sender@example.net --to alice@example.com

250 … queued as … — это принятие в очередь, а не доставка.

Проверка на open relay

Только с внешнего узла и без авторизации: с 127.0.0.1 тест бессмыслен, loopback входит в mynetworks.

bash
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. Второе такое правило не создавайте.

bash
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. Защита от перебора и эксплуатация

bash
apt install fail2ban

/etc/fail2ban/jail.d/mail.local:

ini
[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 = 1h
bash
fail2ban-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

bash
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).dump

MariaDB

bash
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. Порядок:

  1. Инвентаризировать домены, ящики, алиасы, фильтры, квоты, transport maps и схемы паролей.
  2. Сохранить БД, письма, конфигурацию и ключи. Проверить восстановление.
  3. Развернуть Debian 13 отдельно и испытать на тестовом домене.
  4. Перенести данные БД и проверить совместимость схемы с версией PostfixAdmin. Переход с MySQL на PostgreSQL — отдельная миграция данных, не смена драйвера в конфиге.
  5. Перенести Maildir с сохранением владельцев. Здесь письма лежат в /var/vmail/<домен>/<пользователь>/Maildir; в старой статье путь был другим. Либо переносите файлы, либо осознанно меняйте mail_path и home.
  6. mbox — другой формат, не переименование каталога. Конвертируйте средствами Dovecot или переносите по IMAP со сверкой папок, флагов и количества писем.
  7. Dovecot 2.4 по умолчанию отключает слабые схемы паролей. MD5-хеш нельзя превратить в ARGON2ID без исходного пароля: лучше сбросить пароли или перехешировать при смене.[1:2]
  8. Заранее снизить TTL, переключить MX/A и PTR в согласованное окно.
  9. Держать старый сервер под контролем, пока идут повторные доставки и обновляются DNS-кэши.
  10. После переключения проверить входящую и исходящую почту, подписи, алиасы, квоты и сверить данные.

Не переносите публичную DKIM-запись без её закрытого ключа. При смене селектора старый публичный ключ оставьте на время.

17. Альтернатива: системные пользователи Linux

Это другая схема, а не дополнение к SQL-варианту. Подходит небольшому серверу, где ящики — обычные пользователи Linux.

bash
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 alice
bash
postmap /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 остаются.

main.cf (только для шлюза)текст
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 OK
bash
postmap /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.

Требования

  1. Чистый Debian 13 с systemd, доступ root, снимок VM перед запуском.
  2. От 4 ГиБ RAM (лучше 8 — из-за ClamAV), от 10 ГиБ свободного места плюс место под почту.
  3. Публичный статический IPv4; AAAA-записи нет (установщик работает только с IPv4).
  4. A-запись mail.example.com уже указывает на сервер, MX домена — на неё. PTR пока даёт только предупреждение, но до запуска в работу обязателен.
  5. TCP/25 открыт в обе стороны, 80/443/465/587/993 — к серверу.
  6. На машине нет Postfix, Exim, Dovecot, PostgreSQL, MariaDB, Apache, Roundcube, Rspamd, Redis, ClamAV и данных в /var/vmail, /var/lib/postgresql, /var/lib/mysql — установщик это проверит и откажется работать.
  7. Установлен python3 (apt install python3).

Запуск

Скачайте install.sh (ниже), сверьте контрольную сумму и прочитайте файл. Не запускайте через curl | bash.

bash
sha256sum install.sh
# fabe2f2a62b2a2d7b3a947f4768a77a39611a1fb48549f27a2885648f1020190
bash -n install.sh
less install.sh

Просмотр конфигураций без установки — ничего в системе не меняет, секреты подставляются фиктивные:

bash
bash install.sh --render-only ./preview-configs \
  --domain example.com --hostname mail.example.com \
  --public-ip 203.0.113.10

Установка (PostgreSQL по умолчанию):

bash
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/

Файл с паролями и закрытые ключи не публикуйте. Каждому пользователю передавайте только его пароль.

После установки — обязательно

  1. Опубликуйте SPF, DKIM, DMARC из /root/mailstack-dns.txt и установите PTR.
  2. Укажите контакт ACME-аккаунта: certbot update_account --email ВАШ_АДРЕС (при выпуске ящик ещё не работал).
  3. С внешней машины выполните проверку open relay и всё из раздела 13.
  4. Проверьте Authentication-Results у внешнего получателя: SPF, DKIM и DMARC — pass.
  5. В Roundcube проверьте смену пароля, фильтр, автоответ, попадание спама в «Спам».
  6. Антивирус проверьте тестовым файлом EICAR и остановкой ClamAV (ожидается временный отказ).
  7. Проверьте реальную блокировку Fail2ban.
  8. Сделайте резервную копию, вынесите её вместе с паролем шифрования на отдельное хранилище и восстановите на отдельной VM.
  9. Настройте обновления безопасности и внешний мониторинг: health-таймер только пишет в журнал.

Обслуживание

bash
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)
install.shbash
#!/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.

bash
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:

bash
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, резервное копирование.

Панель администратора

Обзор состояния сервера
Обзор: сервисы, очередь, хранилище, статистика Rspamd

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

Рекомендации
Рекомендации: ошибки, предупреждения и советы

Мастер DNS. Для каждого домена панель формирует MX, A, PTR, SPF, DKIM, DMARC, TLS-RPT и SRV-записи для автонастройки почтовых программ, а кнопкой «Проверить DNS» сравнивает их с опубликованными. Мастер учитывает то, что уже есть:

  • если у домена уже есть SPF (например, redirect=_spf.mail.ru), он предлагает одну объединённую запись, а не вторую;
  • более строгую политику DMARC не ослабляет — добавляет только недостающий адрес отчётов;
  • если MX указывает на другой сервер, предупреждает, что менять его можно только после переноса ящиков.
Мастер DNS-записей
Записи DNS с подсказками и сравнением с опубликованным

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

Настройка SPF и DMARC
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.

Источники


  1. Dovecot 2.4: переход с 2.3 ↩︎ ↩︎ ↩︎

  2. Postfix SASL README ↩︎

  3. Dovecot 2.4: SQL-базы ↩︎

  4. Dovecot 2.4: userdb ↩︎

  5. Dovecot 2.4: квоты ↩︎

  6. Rspamd: модуль dkim_signing ↩︎

  7. Rspamd: быстрый старт ↩︎

Статья помогла?

Нашли ошибку или устаревшую команду? Выделите текст и нажмите Ctrl+Enter.