Почтовый сервер на FreeBSD 15.1: Exim и Dovecot

Материал из Bas Wiki
Версия от 01:49, 29 октября 2026; Bas (обсуждение | вклад) (Добавлена пошаговая настройка Rspamd, IMAPSieve, автоматического обучения INBOX ↔ Junk и необязательной сортировки)
Перейти к навигации Перейти к поиску

Почтовый сервер на FreeBSD 15.1: Exim и Dovecot

Самостоятельная инструкция для одного сервера: Exim принимает SMTP и submission, Dovecot предоставляет IMAPS, а доставка в Maildir выполняется через локальный LMTP-сокет. Пользователи и алиасы хранятся в файлах, SQL не требуется. Почта, очередь и дополнительные данные размещаются на отдельной UFS в /opt. Сертификат выпускается после подготовки конфигураций: до этого сетевые почтовые службы не запускаются.

Все имена и адреса ниже — примеры. Заменить example.org и example.net своими доменами, mail.example.org — своим FQDN, 192.0.2.10 — реальным публичным IPv4, 192.168.1.12 — адресом сервера в LAN. 192.0.2.10 зарезервирован для документации и в интернете не работает. Команды для чистой системы выполняются от root в /bin/sh; существующие конфигурации сначала сравнить, не заменять вслепую. Проверенная основа: FreeBSD 15.1, Exim 4.100, Dovecot 2.4.5. Для других веток Dovecot синтаксис отличается.

1. Схема и требования

Интернет → TCP 25 / STARTTLS → Exim
Клиент   → TCP 465 TLS или 587 STARTTLS + AUTH → Exim
Exim     → LMTP Unix socket → Dovecot → /opt/mail/vhosts/domain/user/Maildir
Exim     → AUTH Unix socket → Dovecot → файловый каталог пользователей
Клиент   → TCP 993 TLS → Dovecot
SnappyMail (необязательно) → те же IMAPS и SMTP AUTH
  • Выделенный пустой диск da1; системный диск — другой диск. Тип ФС — UFS.
  • Статический адрес сервера и действующий маршрут по умолчанию. При NAT пробросить TCP 25, 465, 587, 993 на сервер. Для SnappyMail понадобится HTTPS 443 или отдельный обратный прокси.
  • Провайдер должен разрешать исходящий и входящий TCP 25. PTR публичного IP заказывается у владельца адреса: mail.example.org. Прямая A-запись должна возвращать тот же IP.
  • Основной вариант IPv4. AAAA не публиковать, пока IPv6 не настроен полностью; в приведенном Exim IPv6 выключен.
  • Не публиковать MX нового сервера до запуска и проверки. Смена MX переключает новые письма, но не переносит старую почту.
  • Антиспам и фаервол не входят в базовую последовательность. Rspamd — отдельный этап, ClamAV описан ниже.

2. Проверить систему без изменений

id
freebsd-version -kru
uname -m
hostname -f
ifconfig -a
netstat -rn
cat /etc/resolv.conf
mount -p
df -hT
swapinfo -h
geom disk list da1
gpart show -p
gpart show -p da1
glabel status
fstyp /dev/da1
file -s /dev/da1
sockstat -46l
ls -ld /opt
cat /etc/fstab

При необходимости настроить имя и шлюз, подставив реальные параметры сети. Если интерфейс уже настроен, не применять ifconfig повторно через удалённый сеанс без необходимости.

sysrc hostname=mail.example.org
hostname mail.example.org
sysrc defaultrouter=192.168.1.1
# Только если в работающей таблице отсутствует default route:
route add default 192.168.1.1

3. Однократно создать UFS на пустом da1

ВНИМАНИЕ: gpart create и newfs — разрушающие команды. На диске с существующими данными их повторять нельзя. Проверить модель, размер, серийный номер, разделы, метки, mount, swap и GEOM-зависимости. Отсутствие распознанной ФС не доказывает, что диск пуст. /opt не должен содержать файлов, которые скроются после монтирования.

Для нового диска, который должен быть полностью нулевым, дополнительная проверка чтением всех байтов:

disk_bytes=$(diskinfo /dev/da1 | awk '{print $3}')
test -n "$disk_bytes" && test "$disk_bytes" -gt 0 || exit 1
cmp -s -n "$disk_bytes" /dev/da1 /dev/zero
printf 'cmp status: %s\n' "$?"

0 означает нулевое содержимое, 1 — ненулевые данные, больше 1 — ошибку проверки. В двух последних случаях остановиться и разобраться. На уже созданной UFS результат 1 нормален и не является поводом форматировать её.

Только после проверки действительно пустого выделенного диска:

gpart create -s gpt da1
gpart add -a 1m -t freebsd-ufs -l mailopt da1
gpart show -pl da1
newfs -U -L mailopt /dev/gpt/mailopt
install -d -o root -g wheel -m 0755 /opt
mount -o rw,noatime /dev/gpt/mailopt /opt
df -hT /opt

В /etc/fstab добавить ровно одну запись, предварительно исключив дубликаты:

/dev/gpt/mailopt /opt ufs rw,noatime 2 2

При повторной установке существующую UFS проверяют и монтируют, не создают заново.

4. Пакеты, пользователи и каталоги

pkg update
pkg install -n exim dovecot24 swaks ca_root_nss acme.sh
# После проверки плана:
pkg install -y exim dovecot24 swaks ca_root_nss acme.sh
sysrc exim_enable=NO dovecot_enable=NO
sysrc sendmail_enable=NONE sendmail_submit_enable=NO
sysrc sendmail_outbound_enable=NO sendmail_msp_queue_enable=NO
service sendmail onestop
/usr/local/sbin/exim -bV
/usr/local/sbin/dovecot --version
pw usershow mailnull
pw groupshow mail
pw groupshow dovecot

Ошибку «служба sendmail не запущена» можно принять только после проверки, что другой SMTP-процесс не занимает порт 25. Существующий рабочий почтовый сервер нельзя останавливать этой последовательностью без миграции.

В пакете Exim должны присутствовать OpenSSL, Dovecot authenticator, LMTP и Content_Scanning для необязательного ClamAV. Проверить, что UID и GID 5000 свободны. Если заняты, выбрать другие и согласованно заменить во всех дальнейших настройках.

pw usershow -u 5000
pw groupshow -g 5000
# Следующие две команды только если идентификаторы свободны:
pw groupadd vmail -g 5000
pw useradd vmail -u 5000 -g vmail -d /opt/mail -s /usr/sbin/nologin -w no
install -d -o root -g wheel -m 0755 /opt/mail
install -d -o root -g vmail -m 0750 /opt/mail/vhosts
install -d -o mailnull -g mail -m 0750 /opt/mail/exim-spool /var/log/exim
install -d -o root -g mail -m 0750 /usr/local/etc/exim
install -d -o root -g dovecot -m 0750 /usr/local/etc/virtual-mail
install -d -o root -g mail -m 0750 /usr/local/etc/exim/dkim /usr/local/etc/mail-certs
install -d -o root -g wheel -m 0755 /etc/rc.conf.d

Создать файлы, если они отсутствуют; не обнулять уже существующий каталог:

touch /usr/local/etc/virtual-mail/users
chown root:dovecot /usr/local/etc/virtual-mail/users
chmod 0640 /usr/local/etc/virtual-mail/users
for name in domains aliases mailboxes sender_owners; do
    touch /usr/local/etc/exim/$name
    chown root:mail /usr/local/etc/exim/$name
    chmod 0640 /usr/local/etc/exim/$name
done

В /etc/rc.conf.d/exim сохранить:

required_dirs="/opt/mail/exim-spool /opt/mail/vhosts"

В /etc/rc.conf.d/dovecot:

required_dirs="/opt/mail/vhosts"

5. Полная конфигурация Exim

Сохранить следующий текст как /usr/local/etc/exim/configure, владелец root:mail, режим 0640. Это конфигурация без ClamAV; антивирус добавляется отдельным этапом. На 465/587 обязательны TLS и AUTH; на 25 AUTH не предлагается. Межсерверный SMTP 25 использует STARTTLS по возможности. Relay без аутентификации запрещён, включая localhost и LAN. Получатели обслуживаемых доменов проверяются до приема письма; авторизованный пользователь может отправлять только со своих адресов и алиасов.

# FreeBSD 15.1 / Exim / virtual users / Dovecot LMTP and AUTH.
# Render placeholders, validate with exim -C ... -bV, then install as
# /usr/local/etc/exim/configure. Do not start before certificates exist.
# Runtime identity in the official FreeBSD package is mailnull:mail.

primary_hostname = mail.example.org
qualify_domain = example.org
keep_environment =
local_interfaces = 127.0.0.1 : 192.168.1.12
disable_ipv6 = true
domainlist local_domains = @ : /usr/local/etc/exim/domains

spool_directory = /opt/mail/exim-spool
log_file_path = /var/log/exim/%slog
never_users = root
message_size_limit = 50M

daemon_smtp_ports = 25 : 465 : 587
tls_on_connect_ports = 465

tls_advertise_hosts = *
tls_certificate = /usr/local/etc/mail-certs/fullchain.pem
tls_privatekey = /usr/local/etc/mail-certs/privkey.pem
# This option requires an OpenSSL build (the default FreeBSD port).
# Preserve the standard safety flags while disabling TLS 1.0 and 1.1.
openssl_options = +no_sslv2 +no_sslv3 +single_dh_use +no_ticket +no_renegotiation +no_tlsv1 +no_tlsv1_1
tls_require_ciphers = HIGH:!aNULL:!eNULL:!MD5:!RC4:!3DES

acl_smtp_auth = acl_check_auth
acl_smtp_mail = acl_check_mail
acl_smtp_rcpt = acl_check_rcpt
acl_smtp_data = acl_check_data
acl_smtp_vrfy = deny
acl_smtp_expn = deny

begin acl

acl_check_auth:
  deny
    condition = ${if !inlist{$received_port}{465:587}{yes}{no}}
    message = Authentication is available only on submission ports
  accept
    encrypted = *
  deny
    message = TLS is required before authentication

acl_check_mail:
  deny
    condition = ${if inlist{$received_port}{465:587}{yes}{no}}
    !encrypted = *
    message = TLS is required on submission ports
  deny
    condition = ${if inlist{$received_port}{465:587}{yes}{no}}
    !authenticated = *
    message = Authentication is required on submission ports
  deny
    authenticated = *
    condition = ${if !inlisti{${lc:$authenticated_id}}{${lookup{${lc:$sender_address}}lsearch{/usr/local/etc/exim/sender_owners}{$value}{}}}{yes}{no}}
    message = Envelope sender is not authorized for this account
  accept

acl_check_rcpt:
  # Validate hosted recipients even when the sender is authenticated.
  deny
    domains = +local_domains
    !verify = recipient
    message = Unknown local recipient
  accept
    authenticated = *
  accept
    domains = +local_domains
  # There is deliberately no localhost/LAN relay exemption.
  deny
    message = Relay not permitted

acl_check_data:
  # Only submitted authenticated mail is subjected to sender ownership.
  # Raw From preserves encoded display names containing commas.
  deny
    authenticated = *
    !verify = header_syntax
    message = Invalid message header syntax
  warn
    authenticated = *
    set acl_m_from_addresses = ${addresses:>:$rh_from:}
  deny
    authenticated = *
    condition = ${if !eq{${listcount:$acl_m_from_addresses}}{1}{yes}{no}}
    message = A single valid From address is required
  deny
    authenticated = *
    condition = ${if !inlisti{${lc:$authenticated_id}}{${lookup{${lc:$acl_m_from_addresses}}lsearch{/usr/local/etc/exim/sender_owners}{$value}{}}}{yes}{no}}
    message = Header From address is not authorized for this account
  accept

begin routers

service_hostname_postmaster:
  driver = redirect
  domains = @
  local_parts = root : postmaster
  data = alice@example.org
  forbid_file
  forbid_pipe

virtual_aliases:
  driver = redirect
  domains = +local_domains
  data = ${lookup{${lc:$local_part@$domain}}lsearch{/usr/local/etc/exim/aliases}{$value}}
  forbid_file
  forbid_pipe

virtual_mailboxes:
  driver = accept
  domains = +local_domains
  condition = ${lookup{${lc:$local_part@$domain}}lsearch{/usr/local/etc/exim/mailboxes}{yes}{no}}
  transport = dovecot_lmtp
  no_more
  cannot_route_message = Unknown local recipient

remote_dns:
  driver = dnslookup
  domains = ! +local_domains
  transport = remote_smtp
  ignore_target_hosts = 0.0.0.0 : 127.0.0.0/8 : ::1
  no_more

begin transports

remote_smtp:
  driver = smtp
  dkim_domain = ${lookup{${lc:$sender_address_domain}}lsearch,ret=key{/usr/local/etc/exim/domains}{$value}{}}
  dkim_selector = mail
  dkim_private_key = ${if exists{/usr/local/etc/exim/dkim/$dkim_domain.key}{/usr/local/etc/exim/dkim/$dkim_domain.key}{0}}
  dkim_hash = sha256
  dkim_canon = relaxed
  dkim_strict = true
  # Opportunistic STARTTLS is the normal Exim default for remote delivery.
  tls_verify_certificates = system

dovecot_lmtp:
  driver = lmtp
  socket = /var/run/dovecot/lmtp-exim
  user = mailnull
  group = mail
  batch_max = 20
  # LMTP RCPT contains the full canonical local_part@domain.

begin retry

* * F,2h,15m; G,16h,1h,1.5; F,4d,6h

begin rewrite

begin authenticators

dovecot_plain:
  driver = dovecot
  public_name = PLAIN
  server_socket = /var/run/dovecot/auth-exim
  server_set_id = $auth1
  server_advertise_condition = ${if and{{def:tls_in_cipher}{inlist{$received_port}{465:587}}}{yes}{no}}

dovecot_login:
  driver = dovecot
  public_name = LOGIN
  server_socket = /var/run/dovecot/auth-exim
  server_set_id = $auth1
  server_advertise_condition = ${if and{{def:tls_in_cipher}{inlist{$received_port}{465:587}}}{yes}{no}}

6. Полная конфигурация Dovecot 2.4

Сохранить как /usr/local/etc/dovecot/dovecot.conf, root:wheel, 0644. Не подключать одновременно пакетное дерево conf.d с повторяющимися настройками. Важен type = auth-legacy у сокета Exim в Dovecot 2.4. TCP 143 и стандартный открытый LMTP-сокет отключены.

dovecot_config_version = 2.4.4
dovecot_storage_version = 2.4.4

# FreeBSD 15.1, installed Dovecot CE 2.4.5_2, Exim on the same host.
# vmail has UID/GID 5000. Mail hostname is mail.example.org.
# Install as /usr/local/etc/dovecot/dovecot.conf, root:wheel 0644.
# Keep dovecot_enable=NO until certificates exist and all checks pass.
# No include of the package's example conf.d tree: this is a complete config.

base_dir = /var/run/dovecot
state_dir = /var/db/dovecot
listen = 0.0.0.0
protocols = imap lmtp
hostname = mail.example.org
postmaster_address = postmaster@example.org

# One canonical login per mailbox; preserve the domain and normalize case.
auth_mechanisms = plain login
auth_allow_cleartext = no
auth_username_format = %{user | lower}
auth_verbose = yes

# One service UID/GID for all virtual mailboxes.
first_valid_uid = 5000
last_valid_uid = 5000
first_valid_gid = 5000
last_valid_gid = 5000

mail_driver = maildir
mail_path = ~/Maildir
mail_uid = vmail
mail_gid = vmail

# Same file provides passdb and an existence-checking userdb. In particular,
# do not replace userdb with a static backend that accepts arbitrary names.
# File mode/owner: root:dovecot 0640. All entries use full user@domain.
passdb passwd-file {
  passwd_file_path = /usr/local/etc/virtual-mail/users
}
userdb passwd-file {
  passwd_file_path = /usr/local/etc/virtual-mail/users
}

# IMAP access is TLS-only on TCP 993. Never run the service before the last
# certificate-creation/installation step. These settings do not issue a cert.
ssl = required
ssl_min_protocol = TLSv1.2
ssl_server_cert_file = /usr/local/etc/mail-certs/fullchain.pem
ssl_server_key_file = /usr/local/etc/mail-certs/privkey.pem

service imap-login {
  inet_listener imap {
    port = 0
  }
  inet_listener imaps {
    port = 993
    ssl = yes
  }
}

# Exim's Dovecot authenticator currently needs auth-legacy with Dovecot 2.4.
# root creates the socket for Exim; mailnull is the FreeBSD Exim account.
service auth {
  unix_listener auth-exim {
    type = auth-legacy
    user = mailnull
    group = mail
    mode = 0600
  }
  # Local LMTP runs as vmail and uses this userdb socket for lookups.
  unix_listener auth-userdb {
    user = vmail
    group = vmail
    mode = 0600
  }
}

# Only the explicitly named Exim socket may receive mail. Disable the
# package/default LMTP listener, whose default mode is otherwise 0666.
service lmtp {
  user = vmail
  unix_listener lmtp {
    mode = 0
  }
  unix_listener lmtp-exim {
    user = mailnull
    group = mail
    mode = 0600
  }
}

# Standard folders let desktop clients and future SnappyMail agree on names.
# Junk may be added later with an optional spam workflow.
namespace inbox {
  inbox = yes
  separator = /
  mailbox Drafts {
    auto = subscribe
    special_use = \Drafts
  }
  mailbox Sent {
    auto = subscribe
    special_use = \Sent
  }
  mailbox Trash {
    auto = subscribe
    special_use = \Trash
  }
  mailbox Archive {
    auto = subscribe
    special_use = \Archive
  }
}

# Official references checked while preparing this template:
# https://doc.dovecot.org/2.4.4/howto/sasl/exim.html
# https://doc.dovecot.org/2.4.5/core/config/auth/databases/passwd_file.html
# https://doc.dovecot.org/2.4.5/core/config/delivery/lmtp.html
# https://doc.dovecot.org/2.4.4/core/config/namespaces.html
# https://doc.dovecot.org/latest/core/config/ssl.html

7. Управление доменами, ящиками, паролями и алиасами

Одна каноническая запись users имеет формат:

alice@example.org:{BLF-CRYPT}REPLACE_WITH_REAL_HASH:5000:5000::/opt/mail/vhosts/example.org/alice::

Это лишь пояснение формата, не рабочий пароль. Пароли вводятся интерактивно через doveadm; передавать их аргументом командной строки не следует. Полный адрес user@domain — логин и идентификатор ящика; alice@example.org и alice@example.net — разные пользователи.

Следующий готовый скрипт сохранить как /usr/local/sbin/mail-user (root:wheel, 0750). Он использует lockf, проверяет каталог, заменяет отдельные файлы атомарным rename и согласованно пересоздаёт recipient/sender maps. Алиас направляется только на существующего канонического пользователя того же домена и одновременно разрешает отправку с этого адреса. Удаление ящиков намеренно не автоматизировано.

#!/bin/sh
# mail-user: conservative file-catalog administration for Exim + Dovecot.
# Passwords are entered only into doveadm's terminal prompt.
# No delete operation, no automatic backups, no service startup.
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
export PATH
LC_ALL=C
export LC_ALL
umask 077

STATE=/usr/local/etc/virtual-mail
USERS=$STATE/users
EXIMDIR=/usr/local/etc/exim
DOMAINS=$EXIMDIR/domains
ALIASES=$EXIMDIR/aliases
MAILBOXES=$EXIMDIR/mailboxes
OWNERS=$EXIMDIR/sender_owners
MAILROOT=/opt/mail/vhosts
EXIM=/usr/local/sbin/exim

die() { printf '%s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Run with sudo."
[ -d "$STATE" ] && [ -d "$EXIMDIR" ] || die "Install the mail configuration directories first."
case "${1-}" in
  --locked) shift ;;
  *) exec /usr/bin/lockf -t 0 "$STATE/.mail-user.lock" "$0" --locked "$@" ;;
esac

[ "$#" -ge 1 ] || die "Usage: mail-user add-domain DOMAIN | add-user ADDRESS | set-password ADDRESS | add-alias ALIAS CANONICAL"
action=$1
shift
PASSWORD_HASH_FILE=
case "$action" in
  add-domain) [ "$#" -eq 1 ] || die "add-domain takes one domain." ;;
  add-user|set-password)
    if [ "$#" -eq 3 ] && [ "$2" = --hash-file ]; then
      PASSWORD_HASH_FILE=$3
    elif [ "$#" -ne 1 ]; then
      die "Use ADDRESS, optionally followed by --hash-file ROOT_ONLY_FILE."
    fi
    ;;
  add-alias) [ "$#" -eq 2 ] || die "add-alias takes ALIAS CANONICAL." ;;
  *) die "Unknown operation." ;;
esac

/sbin/mount -p | awk '$2 == "/opt" && $3 == "ufs" { found = 1 } END { exit !found }' ||
  die "/opt must be mounted as UFS before mail administration."

EXIMGROUP=$("$EXIM" -bP exim_group | awk -F ' = ' 'NR == 1 { print $2 }')
[ -n "$EXIMGROUP" ] || die "Cannot determine the Exim runtime group."
pw groupshow "$EXIMGROUP" >/dev/null || die "The Exim runtime group does not exist."
pw groupshow dovecot >/dev/null || die "The dovecot group does not exist."
VUID=$(id -u vmail)
VGID=$(id -g vmail)

for file in "$USERS" "$DOMAINS" "$ALIASES"; do
  [ ! -L "$file" ] || die "Refusing a symlink in the mail catalog."
  if [ ! -e "$file" ]; then
    : > "$file"
    case "$file" in
      "$USERS") chown root:dovecot "$file" ;;
      *) chown root:"$EXIMGROUP" "$file" ;;
    esac
    chmod 0640 "$file"
  fi
  [ -f "$file" ] || die "The mail catalog contains a non-regular file."
done

work=$(mktemp -d "$STATE/.mail-user.XXXXXX")
pending=
cleanup() {
  [ -z "$pending" ] || rm -f "$pending"
  rm -f "$work/users" "$work/domains" "$work/aliases" "$work/mailboxes" "$work/owners" "$work/user-keys" "$work/alias-records"
  rmdir "$work" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

normalize() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }

validate_domain() {
  vd=$1
  [ "${#vd}" -le 253 ] || die "Domain is too long."
  case "$vd" in
    ''|.*|*.|*..*|*[!a-z0-9.-]*) die "Invalid domain: $vd" ;;
  esac
  oldifs=$IFS
  IFS=.
  set -- $vd
  IFS=$oldifs
  for label do
    [ "${#label}" -ge 1 ] && [ "${#label}" -le 63 ] || die "Invalid domain label."
    case "$label" in -*|*-) die "Invalid domain label." ;; esac
  done
}

validate_address() {
  va=$1
  case "$va" in
    *@*) ;;
    *) die "Use a full email address." ;;
  esac
  vl=${va%%@*}
  vd=${va#*@}
  case "$vd" in *@*) die "Invalid email address." ;; esac
  [ "${#vl}" -ge 1 ] && [ "${#vl}" -le 64 ] || die "Invalid local part length."
  case "$vl" in
    [a-z0-9]*) ;;
    *) die "Local part must start with a letter or digit." ;;
  esac
  case "$vl" in *[!a-z0-9._+-]*|*.|*..*) die "Invalid local part." ;; esac
  validate_domain "$vd"
}

has_domain() { grep -Fx "$1" "$DOMAINS" >/dev/null; }
has_user() { awk -F: -v key="$1" '$0 !~ /^[[:space:]]*(#|$)/ && $1 == key { found = 1 } END { exit !found }' "$USERS"; }
has_alias() { awk -F: -v key="$1" '$0 !~ /^[[:space:]]*(#|$)/ { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); if ($1 == key) found = 1 } END { exit !found }' "$ALIASES"; }

replace_file() {
  source=$1
  destination=$2
  group=$3
  pending=$(mktemp "$(dirname "$destination")/.mail-user-file.XXXXXX")
  cat "$source" > "$pending"
  chown root:"$group" "$pending"
  chmod 0640 "$pending"
  mv -f "$pending" "$destination"
  pending=
}

# Validate the existing canonical catalog before deriving Exim recipient maps.
validate_catalog() {
  awk -F: -v expected_uid="$VUID" -v expected_gid="$VGID" -v mailroot="$MAILROOT" '
    /^[[:space:]]*(#|$)/ { next }
    NF != 8 || $1 == "" || $2 !~ /^\{[A-Z0-9-]+\}/ || seen[$1]++ { exit 1 }
    {
      split($1, address, "@")
      if ($3 != expected_uid || $4 != expected_gid || $6 != mailroot "/" address[2] "/" address[1]) exit 1
    }
    { print $1 }
  ' "$USERS" > "$work/user-keys" || die "Malformed or duplicate canonical user record."
  while IFS= read -r key; do
    validate_address "$key"
    [ "$(normalize "$key")" = "$key" ] || die "Canonical addresses must be lowercase."
    has_domain "${key#*@}" || die "A canonical user's domain is missing from domains."
  done < "$work/user-keys"

  awk -F: '
    /^[[:space:]]*(#|$)/ { next }
    NF != 2 { exit 1 }
    {
      gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
      gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
      if ($1 == "" || $2 == "" || seen[$1]++) exit 1
      print $1 ":" $2
    }
  ' "$ALIASES" > "$work/alias-records" || die "Malformed or duplicate alias record."
  while IFS=: read -r alias target; do
    validate_address "$alias"
    validate_address "$target"
    [ "$(normalize "$alias")" = "$alias" ] && [ "$(normalize "$target")" = "$target" ] ||
      die "Alias addresses must be lowercase."
    [ "${alias#*@}" = "${target#*@}" ] || die "An alias crosses domains."
    has_user "$target" || die "An alias target is not an existing canonical user."
    if has_user "$alias"; then die "An alias conflicts with a canonical user."; fi
  done < "$work/alias-records"
}

regenerate_maps() {
  validate_catalog
  awk '{ print $0 ": yes" }' "$work/user-keys" > "$work/mailboxes"
  awk '{ print $0 ": " $0 }' "$work/user-keys" > "$work/owners"
  awk -F: '{ print $1 ": " $2 }' "$work/alias-records" >> "$work/owners"
  replace_file "$work/mailboxes" "$MAILBOXES" "$EXIMGROUP"
  replace_file "$work/owners" "$OWNERS" "$EXIMGROUP"
}

new_password_hash() {
  if [ -n "$PASSWORD_HASH_FILE" ]; then
    case "$PASSWORD_HASH_FILE" in /*) ;; *) die "Hash file path must be absolute." ;; esac
    [ -f "$PASSWORD_HASH_FILE" ] && [ ! -L "$PASSWORD_HASH_FILE" ] || die "Invalid hash file."
    [ "$(stat -f %u "$PASSWORD_HASH_FILE")" -eq 0 ] || die "Hash file must belong to root."
    case "$(stat -f %Lp "$PASSWORD_HASH_FILE")" in 400|600) ;; *) die "Hash file must have mode 0400 or 0600." ;; esac
    NEW_HASH=$(cat "$PASSWORD_HASH_FILE")
  else
    [ -t 0 ] || die "Run interactively in PuTTY to enter the mailbox password."
    DOVEADM=$(command -v doveadm) || die "doveadm is unavailable."
    if "$DOVEADM" -O pw -l | tr ' ' '\n' | grep -Fx ARGON2ID >/dev/null; then
      NEW_HASH=$("$DOVEADM" -O pw -s ARGON2ID)
    else
      NEW_HASH=$("$DOVEADM" -O pw -s BLF-CRYPT -r 12)
    fi
  fi
  case "$NEW_HASH" in
    \{ARGON2ID\}*|\{BLF-CRYPT\}*) ;;
    *) die "doveadm did not return a supported password hash." ;;
  esac
  case "$NEW_HASH" in *:*|*'
'*) die "Invalid password hash format." ;; esac
}

validate_catalog

case "$action" in
  add-domain)
    domain=$(normalize "$1")
    validate_domain "$domain"
    if has_domain "$domain"; then die "Domain already exists."; fi
    cat "$DOMAINS" > "$work/domains"
    printf '%s\n' "$domain" >> "$work/domains"
    replace_file "$work/domains" "$DOMAINS" "$EXIMGROUP"
    regenerate_maps
    printf 'Domain added: %s\n' "$domain"
    ;;
  add-user)
    address=$(normalize "$1")
    validate_address "$address"
    domain=${address#*@}
    localpart=${address%%@*}
    has_domain "$domain" || die "Add the domain first."
    if has_user "$address" || has_alias "$address"; then die "Address already exists."; fi
    new_password_hash
    home=$MAILROOT/$domain/$localpart
    install -d -o root -g vmail -m 0750 "$MAILROOT" "$MAILROOT/$domain"
    install -d -o vmail -g vmail -m 0750 "$home" "$home/Maildir" "$home/Maildir/cur" "$home/Maildir/new" "$home/Maildir/tmp"
    cat "$USERS" > "$work/users"
    printf '%s:%s:%s:%s::%s::\n' "$address" "$NEW_HASH" "$VUID" "$VGID" "$home" >> "$work/users"
    replace_file "$work/users" "$USERS" dovecot
    unset NEW_HASH
    regenerate_maps
    printf 'Mailbox added: %s\n' "$address"
    ;;
  set-password)
    address=$(normalize "$1")
    validate_address "$address"
    has_user "$address" || die "No such canonical user."
    new_password_hash
    # awk receives the hash through a root-only temporary input file,
    # never through a command-line argument or an environment variable.
    printf '%s\n' "$NEW_HASH" > "$work/users"
    unset NEW_HASH
    awk -F: -v OFS=: -v key="$address" '
      NR == FNR { password = $0; next }
      $1 == key { $2 = password }
      { print }
    ' "$work/users" "$USERS" > "$work/domains"
    replace_file "$work/domains" "$USERS" dovecot
    regenerate_maps
    printf 'Password changed: %s\n' "$address"
    ;;
  add-alias)
    alias=$(normalize "$1")
    target=$(normalize "$2")
    validate_address "$alias"
    validate_address "$target"
    [ "${alias#*@}" = "${target#*@}" ] || die "Alias and canonical user must be in the same domain."
    has_user "$target" || die "Target must be an existing canonical user."
    if has_user "$alias" || has_alias "$alias"; then die "Alias address already exists."; fi
    cat "$ALIASES" > "$work/aliases"
    printf '%s: %s\n' "$alias" "$target" >> "$work/aliases"
    replace_file "$work/aliases" "$ALIASES" "$EXIMGROUP"
    regenerate_maps
    printf 'Alias added: %s -> %s (including permission to send)\n' "$(normalize "$1")" "$(normalize "$2")"
    ;;
esac

# Dovecot's default authentication cache is disabled. If cache is enabled
# later, flush it after password changes; do not start services here.
exit 0
chown root:wheel /usr/local/sbin/mail-user
chmod 0750 /usr/local/sbin/mail-user
/usr/local/sbin/exim -bV
mail-user add-domain example.org
mail-user add-domain example.net
mail-user add-user alice@example.org
mail-user add-user alice@example.net
for domain in example.org example.net; do
    for alias in root webmaster admin postmaster; do
        mail-user add-alias "$alias@$domain" "alice@$domain"
    done
done

Каждый add-user запрашивает новый пароль дважды. Во время первоначального наполнения службы ещё выключены. Для последующего обслуживания:

mail-user set-password alice@example.org
mail-user add-alias support@example.org alice@example.org
mail-user add-user bob@example.org

Алиасы не получают собственного пароля и не используются как независимые IMAP-логины. При стандартном отключённом auth cache смена пароля действует для новых входов без рестарта Dovecot. Уже открытая IMAP-сессия при этом не закрывается.

8. DKIM и DNS до запуска

Для каждого домена создать отдельный RSA-2048 ключ с селектором mail. Команда отказывается перезаписывать существующий ключ. Закрытые ключи не копируются в wiki и DNS.

umask 077
for domain in example.org example.net; do
    key=/usr/local/etc/exim/dkim/$domain.key
    test ! -e "$key" || { echo "Key already exists: $domain" >&2; exit 1; }
    openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$key"
    chown root:mail "$key"
    chmod 0640 "$key"
    echo "DNS owner: mail._domainkey.$domain"
    openssl pkey -in "$key" -pubout -outform DER | openssl base64 -A
    printf '\n'
done

Опубликовать по одному TXT mail._domainkey в каждой зоне со значением v=DKIM1; k=rsa; p= и соответствующим полученным base64 без пробелов и переносов. В интерфейсе DNS вводится одно TXT-значение; в BIND длинная строка делится на строки не более 255 байт внутри одной записи. Селектор Exim уже настроен на mail.

Начальные примеры для новой инфраструктуры:

mail.example.org. 300 IN A 192.0.2.10
example.org. 300 IN TXT "v=spf1 ip4:192.0.2.10 ~all"
example.net. 300 IN TXT "v=spf1 ip4:192.0.2.10 ~all"
_dmarc.example.org. 300 IN TXT "v=DMARC1; p=none"
_dmarc.example.net. 300 IN TXT "v=DMARC1; p=none"

Если SPF уже существует, объединить разрешённые источники в одной записи; второй SPF не добавлять. DMARC ужесточают после проверки всех легитимных источников. Для уже работающего домена не заменять его текущую политику этими начальными примерами.

MX публикуется в финале после проверки службы:

example.org. 300 IN MX 10 mail.example.org.
example.net. 300 IN MX 10 mail.example.org.

9. Проверки до сертификатов

/usr/local/sbin/exim -bV
/usr/local/sbin/exim -bP primary_hostname spool_directory exim_user exim_group
/usr/local/bin/doveconf -o ssl=no -o ssl_server_cert_file= -o ssl_server_key_file= -n
/usr/local/sbin/exim -bt alice@example.org
/usr/local/sbin/exim -bt admin@example.net
/usr/local/sbin/exim -bt does-not-exist@example.org
mount -p | awk '$2 == "/opt"'

Последняя маршрутизация должна завершиться отказом для неизвестного пользователя. ssl=no выше действует только на процесс проверки doveconf и не изменяет рабочий конфиг. Пустые пути сертификатов используются лишь для этой проверки до выпуска. Почтовые службы всё ещё выключены.

10. Последний этап базовой установки: сертификат, TLS и запуск

Сначала убедиться, что A mail.example.org существует в публичном DNS. Ручной DNS-01 не требует входящего TCP 80. Если домен имеет CAA, он должен разрешать выбранный центр сертификации. Самоподписанный сертификат для обычных почтовых клиентов здесь не используется.

10.1. Hook проверки и активации

Сохранить следующий скрипт как /usr/local/sbin/mail-activate-tls, root:wheel, 0750. Он проверяет UFS, права, цепочку доверия, имя, срок и соответствие ключа; лишь затем включает и запускает/перечитывает Dovecot и Exim. Если включен Nginx, проверяется и перечитывается он. На первом запуске до появления сертификата hook должен завершаться отказом.

#!/bin/sh
# Activate or renew trusted TLS for mail.example.org.
# Suitable as an ACME reload hook. It never creates certificates or keys.
# All checks finish before boot settings or running daemons are changed.
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
export PATH
LC_ALL=C
export LC_ALL
umask 077

HOST=mail.example.org
CERTDIR=/usr/local/etc/mail-certs
CERT=$CERTDIR/fullchain.pem
KEY=$CERTDIR/privkey.pem
CAFILE=/etc/ssl/cert.pem
EXIM=/usr/local/sbin/exim
DOVECONF=/usr/local/bin/doveconf
OPENSSL=/usr/bin/openssl
SERVICE=/usr/sbin/service

die() { printf 'mail-activate-tls: %s\n' "$*" >&2; exit 1; }

[ "$(id -u)" -eq 0 ] || die "Run with sudo or as the root ACME hook."
case "${1-}" in
  --locked) [ "$#" -eq 1 ] || die "No arguments are supported."; shift ;;
  '') exec /usr/bin/lockf -t 0 /var/run/mail-activate-tls.lock "$0" --locked ;;
  *) die "No arguments are supported." ;;
esac

# Fail closed if the mail filesystem is unavailable or read-only.
/sbin/mount -p | awk '
  $2 == "/opt" && $3 == "ufs" && $4 !~ /(^|,)ro(,|$)/ { found = 1 }
  END { exit !found }
' || die "/opt is not mounted as a writable UFS filesystem."

[ -d "$CERTDIR" ] || die "Certificate directory is missing."
[ -f "$CERT" ] && [ -r "$CERT" ] && [ -s "$CERT" ] ||
  die "Trusted fullchain.pem is missing, empty or unreadable; services were not changed."
[ -f "$KEY" ] && [ -r "$KEY" ] && [ -s "$KEY" ] ||
  die "privkey.pem is missing, empty or unreadable; services were not changed."
[ ! -L "$CERT" ] && [ ! -L "$KEY" ] ||
  die "Install the certificate and key as real files in mail-certs, not symlinks into a private ACME directory."
[ -f "$CAFILE" ] && [ -r "$CAFILE" ] && [ -s "$CAFILE" ] ||
  die "System CA bundle /etc/ssl/cert.pem is unavailable."

mail_gid=$(pw groupshow mail | awk -F: 'NR == 1 { print $3 }')
[ -n "$mail_gid" ] || die "Cannot determine the mail group."

# The fixed deployment directory is readable by the configured Exim group.
[ "$(stat -L -f '%u:%g:%Lp' "$KEY")" = "0:$mail_gid:640" ] ||
  die "The private key must be owned by root:mail with mode 0640."
[ "$(stat -L -f '%u:%g:%Lp' "$CERTDIR")" = "0:$mail_gid:750" ] ||
  die "The certificate directory must be root:mail with mode 0750."
[ "$(stat -L -f %u "$CERT")" -eq 0 ] ||
  die "The certificate must belong to root."
case "$(stat -L -f %Lp "$CERT")" in
  640|440)
    [ "$(stat -L -f %g "$CERT")" = "$mail_gid" ] ||
      die "A group-readable-only certificate must belong to the mail group."
    ;;
  644|444) ;;
  *) die "The certificate must be readable and not writable by group or others." ;;
esac

[ -x "$OPENSSL" ] && [ -x "$EXIM" ] && [ -x "$DOVECONF" ] ||
  die "OpenSSL, Exim or doveconf is unavailable."

work=$(mktemp -d /var/run/mail-tls-check.XXXXXX)
cleanup() {
  rm -f "$work/chain.p7b" "$work/cert.pub.pem" "$work/cert.pub.der" "$work/key.pub.der"
  rmdir "$work" 2>/dev/null || true
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

# Parse every certificate in the supplied PEM chain.
"$OPENSSL" crl2pkcs7 -nocrl -certfile "$CERT" -outform DER -out "$work/chain.p7b" >/dev/null 2>&1 ||
  die "The supplied fullchain.pem is not a valid PEM certificate chain."

"$OPENSSL" x509 -in "$CERT" -noout -checkhost "$HOST" >/dev/null 2>&1 ||
  die "The certificate does not match mail.example.org."
"$OPENSSL" x509 -in "$CERT" -noout -checkend 0 >/dev/null 2>&1 ||
  die "The certificate is expired."

# Also checks notBefore, issuer chain, trust, server purpose and hostname.
# A self-signed or untrusted certificate never triggers service activation.
"$OPENSSL" verify -CAfile "$CAFILE" -purpose sslserver -verify_hostname "$HOST" -untrusted "$CERT" "$CERT" >/dev/null 2>&1 ||
  die "Certificate verification failed: check system trust, full chain, dates and server name."

# Empty passphrase prevents an unattended hook from blocking on a prompt.
# Nothing from the private key is printed or written into temporary files.
"$OPENSSL" pkey -in "$KEY" -passin pass: -check -noout >/dev/null 2>&1 ||
  die "The private key is invalid, encrypted or unreadable."
"$OPENSSL" x509 -in "$CERT" -pubkey -noout > "$work/cert.pub.pem" 2>/dev/null ||
  die "Cannot read the certificate public key."
"$OPENSSL" pkey -pubin -in "$work/cert.pub.pem" -outform DER -out "$work/cert.pub.der" >/dev/null 2>&1 ||
  die "Cannot normalize the certificate public key."
"$OPENSSL" pkey -in "$KEY" -passin pass: -pubout -outform DER -out "$work/key.pub.der" >/dev/null 2>&1 ||
  die "Cannot derive the private key's public component."
cmp -s "$work/cert.pub.der" "$work/key.pub.der" ||
  die "The certificate and private key do not match."

# Read the real installed production configurations with real certificates.
"$EXIM" -bV >/dev/null || die "Exim configuration check failed."
"$DOVECONF" -n >/dev/null || die "Dovecot configuration check failed."

# SnappyMail shares this certificate through nginx's root master process.
# Check nginx before reloading any certificate consumer when webmail is enabled.
reload_nginx=no
if [ -x /usr/local/sbin/nginx ] &&
   [ "$(/usr/sbin/sysrc -n nginx_enable 2>/dev/null || true)" = YES ]; then
  /usr/local/sbin/nginx -t || die "nginx configuration check failed."
  reload_nginx=yes
fi

# No daemon, rc setting, certificate or key was changed before this point.
/usr/sbin/sysrc dovecot_enable=YES exim_enable=YES >/dev/null ||
  die "Could not enable the two mail services at boot."

start_or_reload() {
  srv=$1
  if "$SERVICE" "$srv" onestatus >/dev/null 2>&1; then
    "$SERVICE" "$srv" onereload ||
      die "$srv reload failed; no forced restart was attempted."
  else
    "$SERVICE" "$srv" onestart ||
      die "$srv start failed."
  fi
}

# Dovecot owns both the SMTP AUTH and LMTP sockets, so it goes first.
start_or_reload dovecot
ready=no
for attempt in 1 2 3 4 5; do
  if "$SERVICE" dovecot onestatus >/dev/null 2>&1 &&
     [ -S /var/run/dovecot/auth-exim ] &&
     [ -S /var/run/dovecot/lmtp-exim ]; then
    ready=yes
    break
  fi
  sleep 1
done
[ "$ready" = yes ] || die "Dovecot did not become ready with both Exim sockets; Exim was not changed."

start_or_reload exim
ready=no
for attempt in 1 2 3 4 5; do
  if "$SERVICE" exim onestatus >/dev/null 2>&1; then
    ready=yes
    break
  fi
  sleep 1
done
[ "$ready" = yes ] || die "Exim did not become ready."

if [ "$reload_nginx" = yes ]; then
  start_or_reload nginx
fi

printf 'Trusted TLS activated or reloaded for %s; Dovecot and Exim are running.\n' "$HOST"
if [ "$reload_nginx" = yes ]; then
  printf 'SnappyMail nginx also reloaded with the same certificate.\n'
fi
chmod 0750 /usr/local/sbin/mail-activate-tls
chown root:wheel /usr/local/sbin/mail-activate-tls

10.2. Ручной выпуск DNS-01

Выбрать свой реальный административный email. ACME хранится в отдельном root-only каталоге. Команды ниже вводятся на сервере от root; выданный TXT берется из фактического ответа acme.sh, а не из чужой инструкции.

install -d -o root -g wheel -m 0700 /usr/local/etc/virtual-mail/acme
acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --register-account --server letsencrypt -m postmaster@example.org
acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --issue --server letsencrypt --dns -d mail.example.org --keylength ec-256 --yes-I-know-dns-manual-mode-enough-go-ahead-please

Создать TXT с именем _acme-challenge.mail.example.org и значением, показанным acme.sh. Дождаться ответа авторитетных DNS, затем подтвердить проверку:

acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --renew -d mail.example.org --ecc --yes-I-know-dns-manual-mode-enough-go-ahead-please

После успешного выпуска установить копии сертификата в рабочий каталог. Hook выставляет права до строгой проверки:

acme.sh --home /usr/local/etc/virtual-mail/acme --config-home /usr/local/etc/virtual-mail/acme --cert-home /usr/local/etc/virtual-mail/acme/certs --install-cert -d mail.example.org --ecc --fullchain-file /usr/local/etc/mail-certs/fullchain.pem --key-file /usr/local/etc/mail-certs/privkey.pem --reloadcmd 'chown root:mail /usr/local/etc/mail-certs/fullchain.pem /usr/local/etc/mail-certs/privkey.pem && chmod 0640 /usr/local/etc/mail-certs/fullchain.pem /usr/local/etc/mail-certs/privkey.pem && /usr/local/sbin/mail-activate-tls'

Ручной DNS-01 не обеспечивает автоматическое продление. До истечения срока повторить процедуру, опубликовав новое значение TXT; после успешного продления снова выполнить install-cert. Для автоматизации впоследствии перейти на DNS API с минимально необходимыми правами. Один сертификат mail.example.org используется для всех ящиков; почтовые клиенты всех доменов подключаются именно к этому имени.

10.3. Проверка клиента и переключение MX

service dovecot status
service exim status
sockstat -46l
openssl s_client -connect mail.example.org:993 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem
openssl s_client -connect mail.example.org:465 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem
openssl s_client -starttls smtp -connect mail.example.org:587 -servername mail.example.org -verify_hostname mail.example.org -verify_return_error -CAfile /etc/ssl/cert.pem
exim -bpc

После просмотра TLS нажать Ctrl-D. Проверить реальный вход alice@example.org и alice@example.net: IMAP mail.example.org:993 SSL/TLS; SMTP mail.example.org:465 SSL/TLS либо 587 STARTTLS, обязательный AUTH и полный логин. Неверный пароль и relay без AUTH должны отклоняться. Проверить доставку на канонический адрес и алиас, отсутствие неизвестных получателей; затем опубликовать MX из раздела DNS. Проверка из LAN через публичный IP может использовать NAT reflection: внешнюю доступность отдельно проверяют из независимой сети.

Для локальной системной почты после проверки настроить /etc/mail/mailer.conf:

sendmail /usr/local/sbin/exim
send-mail /usr/local/sbin/exim
mailq /usr/local/sbin/exim
newaliases /usr/local/sbin/exim
hoststat /usr/local/sbin/exim
purgestat /usr/local/sbin/exim

Локальный sendmail-совместимый вызов и SMTP AUTH — разные пути. Приведенные проверки отправителя в ACL относятся к SMTP.

11. Необязательно: SnappyMail на этом же сервере

Этот этап выполняется после запуска почты с доверенным TLS. Nginx обслуживает веб-клиент, отдельный PHP-FPM pool работает от snappymail. Пароли почты и Maildir веб-процессу не передаются как файлы: он использует IMAPS/SMTP AUTH вошедшего пользователя.

11.1. Пакеты и каталоги

pkg install -n snappymail-php84 nginx php84-opcache
pkg install -y snappymail-php84 nginx php84-opcache
sysrc nginx_enable=NO php_fpm_enable=NO
pw groupadd snappymail
pw useradd snappymail -g snappymail -d /nonexistent -s /usr/sbin/nologin -w no
install -d -o root -g wheel -m 0755 /opt/webmail /opt/webmail/nginx /opt/webmail/snappymail
install -d -o www -g www -m 0700 /opt/webmail/nginx/client_body /opt/webmail/nginx/fastcgi
install -d -o root -g wheel -m 0750 /opt/webmail/nginx/log
install -d -o snappymail -g snappymail -m 0700 /opt/webmail/snappymail/data /opt/webmail/snappymail/tmp /opt/webmail/snappymail/sessions
install -d -o root -g snappymail -m 0750 /opt/webmail/snappymail/log
install -o snappymail -g snappymail -m 0600 /dev/null /opt/webmail/snappymail/log/php-error.log

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

Сохранить /usr/local/www/snappymail/include.php как root:wheel 0644:

<?php
define('APP_DATA_FOLDER_PATH', '/opt/webmail/snappymail/data/');

Код /usr/local/www/snappymail остается root-owned. Не делать его доступным на запись PHP-пользователю.

В /etc/hosts добавить единственное разрешение имени mail.example.org в 127.0.0.1, предварительно исправив конфликтующие записи этого имени:

127.0.0.1 mail.example.org

Это сохраняет локальный маршрут и проверку TLS по FQDN.

11.2. PHP-FPM и PHP

Установить php.ini-production как /usr/local/etc/php.ini, если своего файла ещё нет. Сохранить /usr/local/etc/php-fpm.conf (все конфиги PHP/Nginx ниже — root:wheel 0644):

[global]
pid = /var/run/php-fpm.pid
error_log = /opt/webmail/snappymail/log/php-fpm-master.log
log_level = notice
include = /usr/local/etc/php-fpm.d/snappymail.conf

Полный pool /usr/local/etc/php-fpm.d/snappymail.conf:

[snappymail]
user = snappymail
group = snappymail
listen = /var/run/snappymail-php-fpm.sock
listen.owner = www
listen.group = www
listen.mode = 0660
pm = ondemand
pm.max_children = 4
pm.process_idle_timeout = 30s
pm.max_requests = 500
chdir = /usr/local/www/snappymail
clear_env = yes
catch_workers_output = yes
security.limit_extensions = .php
env[TMPDIR] = /opt/webmail/snappymail/tmp
php_admin_value[upload_tmp_dir] = /opt/webmail/snappymail/tmp
php_admin_value[sys_temp_dir] = /opt/webmail/snappymail/tmp
php_admin_value[session.save_path] = /opt/webmail/snappymail/sessions
php_admin_value[error_log] = /opt/webmail/snappymail/log/php-error.log
php_admin_flag[log_errors] = on
php_admin_flag[display_errors] = off

Сохранить /usr/local/etc/php/99-snappymail.ini:

date.timezone = Europe/Moscow
expose_php = Off
display_errors = Off
log_errors = On
memory_limit = 128M
max_execution_time = 90
post_max_size = 32M
upload_max_filesize = 25M
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
cgi.fix_pathinfo = 0
openssl.cafile = /etc/ssl/cert.pem
curl.cainfo = /etc/ssl/cert.pem
opcache.enable = 1
opcache.memory_consumption = 64
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60

В /etc/rc.conf.d/php_fpm:

required_dirs="/opt/webmail/snappymail/data /opt/webmail/snappymail/tmp /opt/webmail/snappymail/sessions"

11.3. Инициализация приложения без публикации пароля

Сохранить следующий одноразовый PHP-скрипт в /root/bootstrap-webmail.php (root:wheel, 0600). Он предназначен для новой установки и отказывается заменять существующую конфигурацию. Запустить интерактивно с отключенным эхом терминала; задать новый длинный уникальный пароль администратора дважды. Сам пароль в файл или аргументы не записывается.

<?php
declare(strict_types=1);
umask(0077);
$base = '/opt/webmail/snappymail/data/_data_/_default_';
if (is_file($base.'/configs/application.ini')) {
    throw new RuntimeException('Already configured; do not reset it.');
}
fwrite(STDERR, "New SnappyMail administrator password: ");
$first = rtrim((string)fgets(STDIN), "\r\n");
fwrite(STDERR, "\nRepeat password: ");
$second = rtrim((string)fgets(STDIN), "\r\n");
fwrite(STDERR, "\n");
if (strlen($first) < 20 || !hash_equals($first, $second)) {
    throw new RuntimeException('Passwords differ or are too short.');
}
$_ENV['SNAPPYMAIL_INCLUDE_AS_API'] = 1;
require '/usr/local/www/snappymail/index.php';
$config = \RainLoop\Api::Config();
$config->Set('webmail', 'title', 'Webmail');
$config->Set('webmail', 'language', 'ru');
$config->Set('webmail', 'attachment_size_limit', 25);
$config->Set('security', 'allow_admin_panel', true);
$config->Set('security', 'admin_login', 'admin');
$config->Set('security', 'force_https', true);
$config->Set('admin_panel', 'allow_update', false);
$config->Set('ssl', 'verify_certificate', true);
$config->Set('ssl', 'allow_self_signed', false);
$config->Set('ssl', 'cafile', '/etc/ssl/cert.pem');
$config->Set('contacts', 'enable', true);
$config->Set('contacts', 'type', 'sqlite');
$config->Set('contacts', 'sqlite_global', false);
$config->SetPassword(new \SnappyMail\SensitiveString($first));
if (!$config->Save()) { throw new RuntimeException('Save failed.'); }
unset($first, $second);
$ssl = ['verify_peer'=>true, 'verify_peer_name'=>true,
        'allow_self_signed'=>false, 'cafile'=>'/etc/ssl/cert.pem'];
$settings = [
 'IMAP' => ['host'=>'mail.example.org','port'=>993,'type'=>1,
            'shortLogin'=>false,'lowerLogin'=>true,'ssl'=>$ssl],
 'SMTP' => ['host'=>'mail.example.org','port'=>465,'type'=>1,
            'shortLogin'=>false,'lowerLogin'=>true,'useAuth'=>true,
            'setSender'=>false,'usePhpMail'=>false,'authPlainLine'=>false,'ssl'=>$ssl],
 'Sieve' => ['host'=>'mail.example.org','port'=>4190,'type'=>1,
             'enabled'=>false,'shortLogin'=>false],
 'whiteList' => ''
];
$dir = $base.'/domains';
if (!is_dir($dir)) { mkdir($dir, 0700, true); }
foreach (['default.json','gmail.com.json','hotmail.com.json','mail.example.org.json','disabled'] as $sample) {
    if (is_file($dir.'/'.$sample)) { unlink($dir.'/'.$sample); }
}
foreach (['example.org','example.net'] as $domain) {
    \RainLoop\Model\Domain::fromArray($domain, $settings);
    file_put_contents($dir.'/'.$domain.'.json',
      json_encode($settings, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n");
    chmod($dir.'/'.$domain.'.json', 0600);
}
if (is_file($base.'/admin_password.txt')) { unlink($base.'/admin_password.txt'); }
echo "Configured.\n";

Выполнить в интерактивном /bin/sh:

php -l /root/bootstrap-webmail.php
stty -echo
trap 'stty echo' EXIT HUP INT TERM
php /root/bootstrap-webmail.php
result=$?
stty echo
trap - EXIT HUP INT TERM
test "$result" -eq 0 || exit "$result"
chown -R snappymail:snappymail /opt/webmail/snappymail/data
find /opt/webmail/snappymail/data -type d -exec chmod 0700 {} +
find /opt/webmail/snappymail/data -type f -exec chmod 0600 {} +
rm /root/bootstrap-webmail.php

При ошибке не запускать веб-сервер до исправления и проверки настроек администратора. Панель после запуска — https://mail.example.org/?admin, логин admin, заданный вами пароль. В FreeBSD регистр имени API важен: используется RainLoop\Api, не RainLoop\API.

11.4. Nginx и запуск

Полный минимальный /usr/local/etc/nginx/nginx.conf для сервера, на котором еще нет других сайтов:

user www;
worker_processes auto;
pid /var/run/nginx.pid;
error_log /opt/webmail/nginx/log/error.log warn;

events {
    worker_connections 1024;
}

http {
    include mime.types;
    default_type application/octet-stream;
    server_tokens off;
    access_log /opt/webmail/nginx/log/access.log;
    sendfile on;
    tcp_nopush on;
    keepalive_timeout 65;
    client_body_temp_path /opt/webmail/nginx/client_body;
    fastcgi_temp_path /opt/webmail/nginx/fastcgi;
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/javascript application/json image/svg+xml;

    server {
        listen 127.0.0.1:80;
        listen 192.168.1.12:80;
        server_name mail.example.org;
        return 308 https://mail.example.org$request_uri;
    }

    server {
        listen 127.0.0.1:443 ssl;
        listen 192.168.1.12:443 ssl;
        server_name mail.example.org;
        ssl_certificate /usr/local/etc/mail-certs/fullchain.pem;
        ssl_certificate_key /usr/local/etc/mail-certs/privkey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_session_cache shared:SSL:4m;
        ssl_session_timeout 1h;
        ssl_session_tickets off;

        root /usr/local/www/snappymail;
        index index.php;
        client_max_body_size 32m;
        add_header Strict-Transport-Security "max-age=15552000" always;
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options SAMEORIGIN always;
        add_header Referrer-Policy no-referrer always;

        location = /data { return 404; }
        location ^~ /data/ { return 404; }
        location = /include.php { return 404; }
        location = /_include.php { return 404; }
        location ~ (^|/)\. { return 404; }
        location ~ ^/snappymail/v/[^/]+/(app|vendor)/ { return 404; }
        location = /webmail { return 302 /; }
        location = /webmail/ { return 302 /; }
        location / {
            try_files $uri $uri/ /index.php?$query_string;
        }
        location = /index.php {
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
            fastcgi_param HTTPS $https if_not_empty;
            fastcgi_param HTTP_PROXY "";
            fastcgi_pass unix:/var/run/snappymail-php-fpm.sock;
            fastcgi_hide_header X-Powered-By;
            fastcgi_read_timeout 90s;
            fastcgi_buffer_size 32k;
            fastcgi_buffers 16 16k;
        }
        location ~* \.php(?:/|$) { return 404; }
    }
}

Если сайты уже есть, добавить server-блоки в существующий http, согласовав глобальные параметры. В /etc/rc.conf.d/nginx:

required_dirs="/opt/webmail/nginx/client_body /opt/webmail/nginx/log"

Проверить и включить службы:

php-fpm -t
nginx -t
sysrc php_fpm_enable=YES nginx_enable=YES
service php_fpm start
service nginx start
fetch -o /dev/null https://mail.example.org/

Проверить в браузере вход полным адресом, список папок, отправку самому себе и наличие письма в INBOX/Sent. Файлы данных, include.php и произвольные .php должны возвращать 404. При добавлении почтового домена через mail-user также добавить его в SnappyMail через админ-панель с теми же параметрами IMAP/SMTP и полной формой логина.

11.5. Если публичный 443 занимает другой Nginx

На внешнем Nginx добавить следующие server-блоки внутри http. Использовать доверенный сертификат mail.example.org; приватный ключ на frontend принадлежит root, режим 0600. Сертификат нужно обновлять на обоих серверах при продлении. HTTPS от frontend до backend тоже проверяется:

# Add these server blocks inside the FRONT nginx http {} context.
# Copy the existing mail.example.org certificate from the MAIL server:
# /usr/local/etc/mail-certs/fullchain.pem -> /usr/local/etc/nginx/ssl/mail.example.org/fullchain.pem
# /usr/local/etc/mail-certs/privkey.pem   -> /usr/local/etc/nginx/ssl/mail.example.org/privkey.pem
# Keep the private key root-owned, mode 0600, on the front server.
# The front nginx must be able to reach 192.168.1.12 TCP 443.

server {
    listen 80;
    server_name mail.example.org;
    return 308 https://mail.example.org$request_uri;
}

server {
    listen 443 ssl;
    server_name mail.example.org;

    ssl_certificate /usr/local/etc/nginx/ssl/mail.example.org/fullchain.pem;
    ssl_certificate_key /usr/local/etc/nginx/ssl/mail.example.org/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    client_max_body_size 32m;

    location / {
        proxy_pass https://192.168.1.12;
        proxy_http_version 1.1;
        proxy_set_header Host mail.example.org;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Connection "";

        proxy_ssl_server_name on;
        proxy_ssl_name mail.example.org;
        proxy_ssl_verify on;
        # FreeBSD system CA bundle; on Debian/Ubuntu use
        # /etc/ssl/certs/ca-certificates.crt if /etc/ssl/cert.pem is absent.
        proxy_ssl_trusted_certificate /etc/ssl/cert.pem;
        proxy_ssl_verify_depth 3;
        proxy_connect_timeout 10s;
        proxy_read_timeout 90s;
        proxy_send_timeout 90s;
        proxy_buffer_size 32k;
        proxy_buffers 16 16k;
    }
}

Перед reload выполнить nginx -t. В этой схеме NAT 443 указывает на frontend, а он должен достигать 192.168.1.12:443 по LAN. Порты SMTP/IMAP идут непосредственно на почтовый сервер.

12. Необязательно: ClamAV и проверка SMTP-писем

Антивирус подключается после проверки базовой доставки. Exim передает содержимое clamd потоком по loopback; сканеру не нужен доступ к Maildir, очереди и закрытым ключам.

pkg install -n clamav
pkg install -y clamav
sysrc clamav_clamd_enable=NO clamav_freshclam_enable=NO
# Пакет может включить clamav в дополнительную группу mail.
# Для потоковой проверки это не нужно:
case " $(id -Gn clamav) " in *' mail '*) pw groupmod mail -d clamav ;; esac
install -d -o root -g clamav -m 0750 /opt/clamav /opt/clamav/log
install -d -o clamav -g clamav -m 0750 /opt/clamav/db /var/run/clamav
install -d -o clamav -g clamav -m 0700 /opt/clamav/tmp
install -o clamav -g clamav -m 0640 /dev/null /opt/clamav/log/clamd.log
install -o clamav -g clamav -m 0640 /dev/null /opt/clamav/log/freshclam.log

Штатные rc-скрипты FreeBSD проверяют базы в /var/db/clamav. Для новой установки пустой каталог можно заменить ссылкой; существующие базы не удалять:

if [ -L /var/db/clamav ]; then
    test "$(readlink /var/db/clamav)" = /opt/clamav/db || exit 1
elif [ -d /var/db/clamav ]; then
    rmdir /var/db/clamav || exit 1
    ln -s /opt/clamav/db /var/db/clamav
elif [ ! -e /var/db/clamav ]; then
    ln -s /opt/clamav/db /var/db/clamav
else
    echo 'Unexpected database path' >&2
    exit 1
fi
chown -h root:wheel /var/db/clamav

/usr/local/etc/clamd.conf:

LogFile /opt/clamav/log/clamd.log
LogFileMaxSize 0
LogTime yes
LogRotate no
LogSyslog yes
LogFacility LOG_DAEMON
PidFile /var/run/clamav/clamd.pid
DatabaseDirectory /opt/clamav/db
TemporaryDirectory /opt/clamav/tmp
User clamav
TCPSocket 3310
TCPAddr 127.0.0.1
EnableShutdownCommand no
MaxThreads 4
MaxConnectionQueueLength 30
ReadTimeout 120
CommandReadTimeout 30
StreamMaxLength 64M
MaxFileSize 64M
MaxScanSize 256M
MaxScanTime 45000
MaxRecursion 17
MaxFiles 10000
AlertExceedsMax yes
ScanMail yes
ScanArchive yes
SelfCheck 3600
ConcurrentDatabaseReload yes

/usr/local/etc/freshclam.conf:

DatabaseDirectory /opt/clamav/db
DatabaseOwner clamav
UpdateLogFile /opt/clamav/log/freshclam.log
LogFileMaxSize 0
LogTime yes
LogRotate no
LogSyslog yes
LogFacility LOG_DAEMON
PidFile /var/run/clamav/freshclam.pid
DatabaseMirror database.clamav.net
ScriptedUpdates yes
Checks 12
ConnectTimeout 30
ReceiveTimeout 90
TestDatabases yes
NotifyClamd /usr/local/etc/clamd.conf

В /etc/rc.conf.d/clamav_clamd и /etc/rc.conf.d/clamav_freshclam добавить по строке:

required_dirs="/opt/clamav/db /opt/clamav/tmp /opt/clamav/log"

Первую загрузку баз выполнить до запуска clamd:

freshclam --config-file=/usr/local/etc/freshclam.conf
sigtool --info /opt/clamav/db/main.cvd
sigtool --info /opt/clamav/db/daily.cvd
sigtool --info /opt/clamav/db/bytecode.cvd
sysrc clamav_clamd_enable=YES clamav_freshclam_enable=YES
service clamav_clamd start
service clamav_freshclam start
service clamav_clamd status
sockstat -4l | grep 3310

При первом bootstrap сообщение NotifyClamd о еще не запущенном clamd допустимо только после успешной загрузки и проверки баз. До подключения Exim clamd должен работать. Checks 12 означает проверку обновлений 12 раз в сутки. Если официальный CDN отвечает 403/429, не повторять запросы в цикле: разобраться с ограничением или использовать подходящее HTTPS-зеркало официальных подписанных баз. Например DatabaseMirror https://mirror.truenetwork.ru/clamav и ScriptedUpdates no загружают полные CVD; сохраняются DNS-проверка актуальности и проверка подписи. Доступность зеркала и свежесть баз проверяются в момент установки.

В главной части конфигурации Exim, до begin acl, добавить:

av_scanner = clamd:127.0.0.1 3310

В acl_check_data после существующих проверок From и перед завершающим accept добавить:

  deny
    malware = * / tmo=60s
    message = Message rejected by antivirus policy ($malware_name)
    log_message = ClamAV detected $malware_name

Затем:

exim -bV
service exim reload
exim -bP av_scanner
tail -20 /opt/clamav/log/freshclam.log

Обнаруженное содержимое отклоняется после DATA с 5xx, включая AUTH-порты 465/587 и SnappyMail. Без defer_ok сбой сканера дает временный 451. AlertExceedsMax yes делает превышение лимитов видимым как Heuristics.Limits.Exceeded. Существующая локальная отправка через sendmail-совместимый вызов не проходит SMTP DATA ACL; ее отдельно учитывают при расширении политики.

Проверить контролируемым письмом обычную доставку, затем стандартным тестом EICAR и ZIP с ним: вредоносное тестовое вложение не должно попасть в ящик. Использовать отдельный тестовый ящик и удалить только его тестовые данные. Сам EICAR брать с официального сайта; не включать его тело в общие журналы и документацию.

12.1. Ротация журналов

Установить newsyslog-правила, чтобы журналы не заполнили UFS. Для Exim пример /usr/local/etc/newsyslog.conf.d/virtual-mail.conf:

/var/log/exim/mainlog mailnull:mail 0640 7 1024 * JC
/var/log/exim/rejectlog mailnull:mail 0640 7 1024 * JC
/var/log/exim/paniclog mailnull:mail 0640 7 1024 * JC
/opt/clamav/log/clamd.log clamav:clamav 0640 7 1024 * JC /var/run/clamav/clamd.pid 1
/opt/clamav/log/freshclam.log clamav:clamav 0640 7 1024 * JC /var/run/clamav/freshclam.pid 1

Проверить newsyslog -n -f /usr/local/etc/newsyslog.conf.d/virtual-mail.conf. Правила Exim относятся и к базовой установке без ClamAV; строки ClamAV добавлять только после его установки. Веб-журналы также включить в newsyslog: nginx требует сигнал USR1 для открытия новых файлов, PHP-FPM — штатный сигнал переоткрытия своих журналов. После пакетного обновления проверять владельцев, режимы и членство clamav в группах.

13. Необязательно: Grafana Alloy

Alloy устанавливается отдельно при наличии центральных Loki/Prometheus и их CA. Не выставлять его API на публичный интерфейс. Стандартный набор: /var/log/auth.log, /var/log/messages, CPU/RAM, версии kernel/userland. Состояние и WAL разместить в /opt/alloy, конфигурацию — в /usr/local/etc/alloy.flow, запуск от отдельного пользователя alloy.

pkg install alloy
pw groupshow alloy >/dev/null 2>&1 || pw groupadd alloy
pw usershow alloy >/dev/null 2>&1 || pw useradd alloy -g alloy -d /opt/alloy -s /usr/sbin/nologin -w no
install -d -o alloy -g alloy -m 0750 /opt/alloy
sysrc alloy_enable=NO alloy_user=alloy alloy_group=alloy
sysrc alloy_storage_path=/opt/alloy alloy_listen_address=127.0.0.1:9200

Полная интеграция зависит от адресов приёмников и способа аутентификации организации. В конфигурации использовать password_file с отдельным root:alloy 0640 файлом, проверку CA и уникальную метку host. Для чтения auth/messages предоставить группе alloy доступ 0640 и сохранить те же владельца/группу в newsyslog; членство в широких системных группах не требуется. Проверить alloy validate, доставку контрольных событий, появление CPU/RAM и версий в центральной системе; только затем включить alloy_enable=YES. Этот раздел — параметры подключения, а не готовая конфигурация неизвестного центрального сервера.

14. Обслуживание и диагностика

service exim status
service dovecot status
exim -bpc
exim -bp
tail -50 /var/log/exim/mainlog
tail -50 /var/log/exim/rejectlog
tail -50 /var/log/maillog
doveadm user alice@example.org
df -hT / /opt
openssl x509 -in /usr/local/etc/mail-certs/fullchain.pem -noout -dates -subject

Не публиковать users, приватные DKIM-ключи, TLS-ключ, секреты Alloy и содержимое пользовательских писем. Следить за пространством отдельно на / и /opt. Запланировать продление сертификата до срока истечения; после обновления проверить все его потребители, включая внешний HTTPS frontend. Перед заменой конфигураций сравнивать действующее состояние. Настройка Rspamd с автоматическим обучением при перемещениях INBOX ↔ Junk приведена в разделе 15; этот дополнительный этап выполняется после проверки базовой доставки.

15. Rspamd: оценка почты и автоматическое обучение через IMAP

Этот дополнительный этап выполняется после работоспособной базовой установки Exim и Dovecot 2.4. Пакеты: Rspamd 4.1, Redis, dovecot24-pigeonhole и Python 3.12. Пользователи продолжают храниться в файлах; Redis используется для статистики Rspamd, а не как каталог почтовых ящиков. Данные и очередь обратной связи размещаются на UFS /opt.

Схема рассчитана на общую Bayes-базу всех доменов. Любой пользователь, имеющий доступ к ящику, влияет на её обучение. Настройка autolearn = false отключает обучение по собственным оценкам Rspamd; обучение по явно выполненному пользователем IMAP-действию выполняет отдельная служба.

  • Exim очищает внешние X-Spam-заголовки, оценивает только неаутентифицированную SMTP-почту и добавляет локальную оценку.
  • Действие Rspamd reject здесь является рекомендацией: по антиспаму нет SMTP-отказов, карантина или удаления. Недоступность Rspamd не блокирует доставку.
  • Уже установленный ClamAV продолжает работать по прежней политике: обнаружение вируса отклоняет письмо, отказ сканера даёт временный SMTP-ответ.
  • INBOX → Junk создаёт spam-событие; Junk → INBOX — ham-событие. IMAPSieve использует причину COPY как для IMAP MOVE, так и для прямого IMAP COPY между этими папками. APPEND, изменение флагов, Junk → Trash и другие папки не обучают фильтр.
  • IMAP-процесс только пишет ограниченное событие на диск. Пароль контроллера и сетевой вызов rspamc находятся в отдельном root-процессе.

15.1. Установить пакеты и подготовить данные

Команды ниже относятся к новой установке выделенных Redis/Rspamd. Если Redis уже обслуживает другие приложения, не заменять его параметры: выделить отдельный экземпляр и согласованно изменить адрес подключения. Выполнять от root в /bin/sh. Проверить доступность /opt и план пакетного менеджера.

mount -p | awk '$2 == "/opt" && $3 == "ufs" { ok=1 } END { exit !ok }'
pkg install -n rspamd redis dovecot24-pigeonhole python312
pkg install -y rspamd redis dovecot24-pigeonhole python312
sysrc redis_enable=NO rspamd_enable=NO rspamd_feedback_enable=NO
service rspamd onestop
service redis onestop
pkg info rspamd redis dovecot24-pigeonhole python312
install -d -o root -g wheel -m 0755 /opt/mail/log

Если новые службы ещё не запущены, onestop может сообщить об отсутствии процесса; проверить sockstat. Следующее перемещение делается ровно один раз: целевые каталоги должны отсутствовать. Пакетные данные сохраняются на новом разделе, а штатные пути становятся ссылками. На уже настроенной системе этот блок не повторять.

test -d /var/db/rspamd && test ! -L /var/db/rspamd || exit 1
test -d /var/log/rspamd && test ! -L /var/log/rspamd || exit 1
test ! -e /opt/mail/rspamd && test ! -e /opt/mail/log/rspamd || exit 1
mv /var/db/rspamd /opt/mail/rspamd
mv /var/log/rspamd /opt/mail/log/rspamd
ln -s /opt/mail/rspamd /var/db/rspamd
ln -s /opt/mail/log/rspamd /var/log/rspamd
chown -h root:wheel /var/db/rspamd /var/log/rspamd
chown rspamd:rspamd /opt/mail/rspamd /opt/mail/log/rspamd
chmod 0750 /opt/mail/rspamd /opt/mail/log/rspamd
install -d -o rspamd -g rspamd -m 0700 /opt/mail/rspamd/tmp
install -d -o redis -g redis -m 0700 /opt/mail/redis
install -d -o redis -g redis -m 0750 /opt/mail/log/redis

В /etc/rc.conf.d/redis добавить required_dirs="/opt/mail/redis /opt/mail/log/redis", в /etc/rc.conf.d/rspamd — required_dirs="/opt/mail/rspamd /opt/mail/log/rspamd". Не стирать другие действующие параметры этих файлов.

15.2. Настроить Redis и Rspamd

Сохранить следующий одноразовый скрипт как /root/setup-rspamd.py, root:wheel 0600. Он предназначен для новой установки, отказывается заменять существующие файлы секретов и не выводит их содержимое. Redis и контроллер слушают только loopback. Локальным процессам также нужен пароль контроллера: secure_ip очищен. Вызов rspamadm для хеша использует сгенерированный пароль как кратковременный аргумент процесса; не запускать этот шаг в среде с недоверенными локальными shell-пользователями. Пароль не записывается в историю команд.

#!/usr/local/bin/python3.12
from pathlib import Path
import grp, os, pwd, secrets, subprocess
assert os.geteuid() == 0
os.umask(0o077)
conf = Path('/usr/local/etc/rspamd')
local = conf / 'local.d'
local.mkdir(exist_ok=True)

def write(path, value, group='wheel', mode=0o640):
    path = Path(path)
    path.write_text(value, encoding='utf-8')
    os.chown(path, 0, grp.getgrnam(group).gr_gid)
    os.chmod(path, mode)

redis_secret = conf / 'redis.password'
controller_secret = conf / 'controller.password'
assert not redis_secret.exists() and not controller_secret.exists()
redis_password = secrets.token_urlsafe(36)
controller_password = secrets.token_urlsafe(36)
write(redis_secret, redis_password + '\n', mode=0o600)
write(controller_secret, controller_password + '\n', mode=0o600)
hashed = subprocess.check_output(
    ['/usr/local/bin/rspamadm', 'pw', '-q', '-p', controller_password], text=True
).strip()
assert hashed.startswith('$'), 'Unexpected password hash format'
redis_conf = Path('/usr/local/etc/redis.conf')
with redis_conf.open('a') as output:
    output.write('\n# Dedicated Redis for the mail Rspamd service.\n'
                 'bind 127.0.0.1\nprotected-mode yes\n'
                 'dir /opt/mail/redis\n'
                 'logfile /opt/mail/log/redis/redis.log\nloglevel notice\n'
                 'appendonly yes\nappendfsync everysec\n'
                 'maxmemory 256mb\nmaxmemory-policy noeviction\n'
                 f'requirepass {redis_password}\n')
os.chown(redis_conf, 0, grp.getgrnam('redis').gr_gid)
os.chmod(redis_conf, 0o640)
write(local / 'redis.conf',
      f'servers = "127.0.0.1:6379";\npassword = "{redis_password}";\n', group='rspamd')
write(local / 'worker-normal.inc', 'bind_socket = "127.0.0.1:11333";\n')
write(local / 'worker-controller.inc',
      'bind_socket = "127.0.0.1:11334";\nsecure_ip = [];\n'
      f'password = "{hashed}";\nenable_password = "{hashed}";\n', group='rspamd')
write(local / 'worker-proxy.inc', 'count = 0;\n')
write(local / 'options.inc',
      'check_all_filters = true;\ntempdir = "/opt/mail/rspamd/tmp";\n')
write(local / 'logging.inc', 'level = "info";\n')
write(local / 'classifier-bayes.conf',
      'autolearn = false;\nper_user = false;\nmin_learns = 200;\n')
for module in ('greylist', 'ratelimit', 'neural', 'force_actions', 'fuzzy_check'):
    write(local / (module + '.conf'), 'enabled = false;\n')
print('Configuration written; secrets are stored in root-only files.')
chmod 0600 /root/setup-rspamd.py
python3.12 /root/setup-rspamd.py
rspamadm configtest
sysrc redis_enable=YES rspamd_enable=YES
service redis start
service rspamd start
rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat
sockstat -46l | egrep '6379|11333|11334'
rm /root/setup-rspamd.py

Проверить отсутствие Redis/Rspamd на внешних адресах. В local.d/options.inc не задан чужой DNS-сервер: используется системный резолвер; для DNSBL нужен разрешённый их политикой резолвер. Пароли не печатать через cat и не включать дамп полного конфигурационного файла в общие логи. При ошибке не подключать Exim, пока configtest, запуск и авторизованный stat не проходят. Значение min_learns = 200 означает, что для работы Bayes-оценок требуются примеры обоих классов: по 200 spam и ham; остальные проверки могут работать до накопления этих примеров.

15.3. Подключить Exim без антиспам-отказов

В главной части /usr/local/etc/exim/configure, до begin acl, добавить одну строку:

spamd_address = 127.0.0.1 11333 variant=rspamd tmo=20s

В acl_check_data после всех существующих проверок отправителя и после ClamAV, если он установлен, но перед последним accept, добавить следующий блок. Не заменять существующий malware-блок и не добавлять deny на основе $spam_action.

  # Rspamd: annotate unauthenticated incoming mail; keep antivirus policy.
  warn
    remove_header = X-Spam-Score : X-Spam-Flag : X-Spam-Status : X-Spam-Report : X-Rspamd-Action
  warn
    !authenticated = *
    spam = nobody:true/defer_ok
    add_header = X-Spam-Score: $spam_score
    add_header = X-Rspamd-Action: $spam_action
    add_header = X-Spam-Flag: ${if inlist{$spam_action}{add header:rewrite subject:reject:quarantine:discard}{YES}{NO}}
exim -bV
service exim reload
exim -bP spamd_address

Проверка true возвращает результат оценки независимо от порога, а defer_ok позволяет продолжить SMTP при недоступном Rspamd. Очистка заголовков применяется ко всем SMTP-сообщениям, оценка — только к неаутентифицированным. Отправка через AUTH 465/587 не сканируется Rspamd и не может оставить собственную поддельную метку. Защита ClamAV действует независимо. Имена X-Spam-заголовков должны совпадать с необязательным правилом доставки ниже.

15.4. Создать защищённую очередь обратной связи

install -d -o root -g vmail -m 0750 /opt/mail/rspamd-feedback
install -d -o vmail -g vmail -m 0700 /opt/mail/rspamd-feedback/pending
install -d -o root -g wheel -m 0700 /opt/mail/rspamd-feedback/processed
install -d -o root -g wheel -m 0755 /usr/local/libexec/rspamd-feedback
install -d -o root -g wheel -m 0755 /usr/local/etc/dovecot/rspamd

Ниже приведены три обязательных файла: сборщик, административная библиотека/CLI и фоновый обработчик. Worker импортирует административный модуль, тот импортирует сборщик: пропускать feedback-admin.py нельзя, даже если ручное обучение не используется. Все три файла принадлежат root:wheel, режим 0755, каталог не доступен на запись vmail. Сборщик принимает только фиксированные переходы INBOX/Junk, сообщение не более 50 MiB и ограниченную локальную очередь; к Rspamd он не обращается.

/usr/local/libexec/rspamd-feedback/feedback.py

#!/usr/local/bin/python3.12
"""IMAPSieve collector: bounded local spool only; never contacts Rspamd.

Usage: feedback.py [--queue DIR] spam|ham USER SOURCE DESTINATION
The queue must already exist and be owned by the IMAP mail UID, mode 0700.
Programs/configuration must be root-owned and not writable by that UID.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
from pathlib import Path
import secrets
import shutil
import stat
import sys
import time
from contextlib import contextmanager
from datetime import datetime, timezone

MAX_MESSAGE = 50 * 1024 * 1024
DEFAULT_QUEUE = "/opt/mail/rspamd-feedback/pending"
MAX_PENDING_BYTES = 512 * 1024 * 1024
MAX_PENDING_EVENTS = 1024
MIN_FREE_BYTES = 1024 * 1024 * 1024


class FeedbackError(Exception):
    pass


def utc_now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def fsync_directory(path):
    if os.name == "posix":
        fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
        try:
            os.fsync(fd)
        finally:
            os.close(fd)


def validate_event(label, user, source, destination):
    expected = {"spam": ("INBOX", "Junk"), "ham": ("Junk", "INBOX")}
    if label not in expected or (source, destination) != expected[label]:
        raise FeedbackError("event is outside the configured INBOX/Junk transitions")
    if not user or len(user) > 320 or any(ord(c) < 32 or ord(c) == 127 for c in user):
        raise FeedbackError("invalid IMAP user identity")


@contextmanager
def collection_lock(queue):
    """At most two seconds waiting; IMAP must not wait indefinitely on a lock."""
    fd = os.open(queue / ".collector.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
    try:
        if os.name == "posix":
            import fcntl
            until = time.monotonic() + 2
            while True:
                try:
                    fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                    break
                except BlockingIOError:
                    if time.monotonic() >= until:
                        raise FeedbackError("queue lock is busy")
                    time.sleep(0.02)
        yield
    finally:
        os.close(fd)


def pending_usage(queue, max_events=MAX_PENDING_EVENTS):
    count = total = 0
    with os.scandir(queue) as entries:
        for entry in entries:
            if entry.name == ".collector.lock":
                continue
            count += 1
            if count >= max_events:
                raise FeedbackError("pending event limit reached")
            if not entry.is_dir(follow_symlinks=False):
                raise FeedbackError("unexpected item in pending queue")
            flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
            try:
                directory = os.open(entry.path, flags) if os.name == "posix" else None
            except FileNotFoundError:
                continue
            try:
                for name in ("message.eml", "metadata.json"):
                    try:
                        info = (os.stat(name, dir_fd=directory, follow_symlinks=False)
                                if directory is not None else (Path(entry.path) / name).lstat())
                    except FileNotFoundError:
                        continue
                    if not stat.S_ISREG(info.st_mode):
                        raise FeedbackError("unexpected file type in pending event")
                    total += info.st_size
            finally:
                if directory is not None:
                    os.close(directory)
    return total


def collect(stream, queue, label, user, source, destination, *, max_bytes=MAX_MESSAGE,
            max_pending_bytes=MAX_PENDING_BYTES, max_events=MAX_PENDING_EVENTS,
            min_free_bytes=MIN_FREE_BYTES):
    validate_event(label, user, source, destination)
    queue = Path(queue)
    info = queue.lstat()
    if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode):
        raise FeedbackError("queue must be a real directory")
    if os.name == "posix" and (info.st_uid != os.geteuid() or info.st_mode & 0o077):
        raise FeedbackError("queue must belong to this UID and have mode 0700")
    with collection_lock(queue):
        current_bytes = pending_usage(queue, max_events)
        # Reserve metadata and preserve 1 GiB even for a maximum-sized message.
        if current_bytes + 16384 >= max_pending_bytes:
            raise FeedbackError("pending byte limit reached")
        if shutil.disk_usage(queue).free < min_free_bytes + max_bytes + 16384:
            raise FeedbackError("insufficient free disk space")
        return _write_event(stream, queue, label, user, source, destination,
                            max_bytes, min(max_bytes, max_pending_bytes - current_bytes - 16384))


def _write_event(stream, queue, label, user, source, destination, max_bytes, available_bytes):
    event_id = secrets.token_hex(16)
    temporary = queue / (".tmp-" + event_id)
    final = queue / event_id
    temporary.mkdir(mode=0o700)
    total = 0
    digest = hashlib.sha256()
    try:
        fd = os.open(temporary / "message.eml", os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600)
        with os.fdopen(fd, "wb") as output:
            while True:
                block = stream.read(min(65536, max_bytes + 1 - total))
                if not block:
                    break
                total += len(block)
                if total > max_bytes:
                    raise FeedbackError("message exceeds the 50 MiB collection limit")
                if total > available_bytes:
                    raise FeedbackError("pending byte limit reached")
                digest.update(block)
                output.write(block)
            if total == 0:
                raise FeedbackError("empty message")
            output.flush()
            os.fsync(output.fileno())
        metadata = {
            "schema": 1, "id": event_id, "created_utc": utc_now(),
            "created_ns": time.time_ns(),
            "label": label, "user": user, "source": source,
            "destination": destination, "bytes": total,
            "sha256": digest.hexdigest(),
        }
        fd = os.open(temporary / "metadata.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600)
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as output:
            json.dump(metadata, output, ensure_ascii=True, sort_keys=True)
            output.write("\n")
            output.flush()
            os.fsync(output.fileno())
        fsync_directory(temporary)
        os.rename(temporary, final)
        fsync_directory(queue)
        return metadata
    except Exception:
        # Remove only our two known partial files, never recurse into a directory.
        for name in ("message.eml", "metadata.json"):
            try:
                (temporary / name).unlink()
            except FileNotFoundError:
                pass
        try:
            temporary.rmdir()
        except FileNotFoundError:
            pass
        raise


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--queue", default=DEFAULT_QUEUE)
    parser.add_argument("label", choices=("spam", "ham"))
    parser.add_argument("user")
    parser.add_argument("source")
    parser.add_argument("destination")
    args = parser.parse_args(argv)
    os.umask(0o077)
    try:
        collect(sys.stdin.buffer, args.queue, args.label, args.user, args.source, args.destination)
        return 0
    except (FeedbackError, OSError) as exc:
        # No message data, addresses, paths supplied by users, or tracebacks in logs.
        detail = str(exc) if isinstance(exc, FeedbackError) else "local IO error errno=" + str(exc.errno)
        message = "rspamd-feedback: collection failed: " + detail + "; no training attempted"
        try:
            import syslog
            syslog.openlog("rspamd-feedback", syslog.LOG_PID, syslog.LOG_MAIL)
            syslog.syslog(syslog.LOG_ERR, message)
        except ImportError:
            pass
        print(message, file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

/usr/local/libexec/rspamd-feedback/feedback-admin.py

Это общая проверка событий, защищённые снимки, журнал результатов и диагностические команды. Ручная learn-команда сохранена для исключительных случаев; обычные IMAP-события обрабатывает worker из следующего файла.

#!/usr/local/bin/python3.12
"""Root-only review and explicit training of IMAPSieve feedback.

No command trains automatically. list/show are local reads. learn ID spam|ham
is the only command that invokes rspamc. reject ID archives without training.
Queue root and processed must be root-owned, non-writable by mail users;
pending is separately owned by vmail, mode 0700. Archived originals remain in
processed/ID/source; only root-owned snapshots are used for training or review.
"""
from __future__ import annotations

import argparse
from contextlib import contextmanager
from email import policy
from email.parser import BytesHeaderParser
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys

spec = importlib.util.spec_from_file_location("feedback_collector", Path(__file__).with_name("feedback.py"))
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
MAX_MESSAGE = collector.MAX_MESSAGE
FeedbackError = collector.FeedbackError
ID_RE = re.compile(r"[0-9a-f]{32}\Z")
DEFAULT_ROOT = "/opt/mail/rspamd-feedback"
DEFAULT_PASSWORD_FILE = "/usr/local/etc/rspamd/controller.password"
HEADERS = ("From", "To", "Date", "Subject", "Message-ID", "X-Spam-Status", "X-Spam-Score", "X-Rspamd-Action")


def emit(value):
    print(json.dumps(value, ensure_ascii=True, sort_keys=True))


def require_root():
    if not hasattr(os, "geteuid") or os.geteuid() != 0:
        raise FeedbackError("this administrative command requires root")


def check_id(event_id):
    if not ID_RE.fullmatch(event_id):
        raise FeedbackError("ID must be exactly 32 lowercase hexadecimal characters")
    return event_id


class Directory:
    """Nofollow directory descriptor on FreeBSD; portable fallback for local tests."""
    def __init__(self, path):
        self.path = Path(path)
        info = self.path.lstat()
        if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode):
            raise FeedbackError("expected a real directory")
        self.fd = None
        if os.name == "posix":
            self.fd = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)

    def __enter__(self):
        return self

    def __exit__(self, *args):
        if self.fd is not None:
            os.close(self.fd)

    def read(self, name, limit):
        if "/" in name or "\\" in name or name in (".", ".."):
            raise FeedbackError("invalid internal filename")
        if self.fd is not None:
            fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=self.fd)
        else:
            path = self.path / name
            if path.is_symlink():
                raise FeedbackError("symlink is not an event file")
            fd = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0))
        with os.fdopen(fd, "rb") as source:
            info = os.fstat(source.fileno())
            if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size > limit:
                raise FeedbackError("event file is not a bounded, single-link regular file")
            result = source.read(limit + 1)
            if len(result) > limit:
                raise FeedbackError("event file exceeded the size limit")
            return result


def read_event(directory, event_id):
    check_id(event_id)
    # On POSIX open the event relative to the already opened parent descriptor.
    # The parent path is deployment-owned, and a symlink event is rejected.
    with Directory(Path(directory) / event_id) as entry:
        metadata = json.loads(entry.read("metadata.json", 16384))
        message = entry.read("message.eml", MAX_MESSAGE)
    if not isinstance(metadata, dict) or not isinstance(metadata.get("user"), str):
        raise FeedbackError("invalid event metadata")
    collector.validate_event(metadata.get("label"), metadata.get("user", ""),
                             metadata.get("source"), metadata.get("destination"))
    if metadata.get("id") != event_id or metadata.get("schema") != 1:
        raise FeedbackError("invalid event metadata")
    if metadata.get("bytes") != len(message) or metadata.get("sha256") != hashlib.sha256(message).hexdigest():
        raise FeedbackError("event content does not match its metadata")
    return metadata, message


def write_private(path, value, *, json_value=False):
    flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
    fd = os.open(path, flags, 0o600)
    with os.fdopen(fd, "wb") as output:
        if json_value:
            value = (json.dumps(value, ensure_ascii=True, sort_keys=True) + "\n").encode()
        output.write(value)
        output.flush()
        os.fsync(output.fileno())


def update_receipt(directory, receipt):
    temporary = directory / ".admin-new.json"
    write_private(temporary, receipt, json_value=True)
    os.replace(temporary, directory / "admin.json")
    collector.fsync_directory(directory)


def check_root_directory(path):
    with Directory(path):
        info = Path(path).stat()
        if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o022):
            raise FeedbackError("administrative directories must be root-owned and not group/other writable")


@contextmanager
def admin_lock(root):
    check_root_directory(root)
    processed = root / "processed"
    if not processed.exists():
        processed.mkdir(mode=0o700)
    check_root_directory(processed)
    fd = os.open(root / ".admin.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
    try:
        if os.name == "posix":
            import fcntl
            fcntl.flock(fd, fcntl.LOCK_EX)
        yield
    finally:
        os.close(fd)


def receipt_for(directory):
    with Directory(directory) as entry:
        return json.loads(entry.read("admin.json", 16384))


def find_prior(root, sha256):
    processed = root / "processed"
    if not processed.exists():
        return None
    for entry in processed.iterdir():
        if not ID_RE.fullmatch(entry.name):
            continue
        try:
            receipt = receipt_for(entry)
        except (OSError, ValueError, FeedbackError):
            # A damaged journal must never silently allow repeated training.
            raise FeedbackError("processed journal is incomplete; inspect it before more training")
        if receipt.get("sha256") == sha256 and receipt.get("status") in ("preparing", "learning", "learned", "learn_uncertain"):
            return receipt
    return None


def prepare_archive(root, event_id, metadata, message, status, label=None, reason=None):
    if shutil.disk_usage(root).free < collector.MIN_FREE_BYTES + len(message) + 32768:
        raise FeedbackError("insufficient free disk space to preserve the reviewed snapshot")
    archive = root / "processed" / event_id
    archive.mkdir(mode=0o700)
    receipt = {"schema": 1, "id": event_id, "sha256": metadata["sha256"],
               "reviewed_utc": collector.utc_now(), "status": "preparing",
               "selected_class": label, "reason": reason}
    write_private(archive / "admin.json", receipt, json_value=True)
    write_private(archive / "message.eml", message)
    write_private(archive / "metadata.json", json.dumps(metadata, ensure_ascii=True).encode())
    # Keep the complete original event. Never traverse or execute files in source/.
    os.rename(root / "pending" / event_id, archive / "source")
    receipt["status"] = status
    update_receipt(archive, receipt)
    collector.fsync_directory(root / "pending")
    collector.fsync_directory(root / "processed")
    return archive, receipt


def run_learning(message, label, *, executable, controller, password_file=None, timeout=30):
    executable = Path(executable)
    if not executable.is_absolute():
        raise FeedbackError("rspamc executable must be an absolute path")
    command = [str(executable), "-h", controller]
    if password_file:
        secret = Path(password_file)
        info = secret.lstat()
        if not secret.is_absolute() or not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
            raise FeedbackError("password file must be an absolute regular file")
        if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o077):
            raise FeedbackError("password file must be root-owned, mode 0600 or stricter")
        # rspamc reads the file itself; the secret never appears in argv or logs.
        command.extend(["-P", str(secret)])
    command.append("learn_" + label)
    # No -u/-d: one shared Bayes classifier. No shell and no automatic retry.
    return subprocess.run(command, input=message, stdout=subprocess.DEVNULL,
                          stderr=subprocess.DEVNULL, timeout=timeout, check=False).returncode


def learn_event(root, event_id, label, *, executable, controller, password_file=None, timeout=30):
    check_id(event_id)
    if label not in ("spam", "ham"):
        raise FeedbackError("invalid training class")
    with admin_lock(root):
        metadata, message = read_event(root / "pending", event_id)
        prior = find_prior(root, metadata["sha256"])
        if prior:
            raise FeedbackError("this content has a prior learned or uncertain event; inspect it and reject the duplicate")
        archive, receipt = prepare_archive(root, event_id, metadata, message, "learning", label)
        try:
            rc = run_learning(message, label, executable=executable, controller=controller,
                              password_file=password_file, timeout=timeout)
        except subprocess.TimeoutExpired:
            receipt["status"] = "learn_uncertain"
            receipt["error"] = "client timeout; server may have learned; do not automatically retry"
        except (OSError, FeedbackError):
            receipt["status"] = "learn_failed"
            receipt["error"] = "client could not start or local configuration was invalid"
        else:
            receipt["returncode"] = rc
            receipt["status"] = "learned" if rc == 0 else "learn_uncertain"
            if rc:
                receipt["error"] = "client returned failure; inspect server before any retry"
        receipt["finished_utc"] = collector.utc_now()
        update_receipt(archive, receipt)
        return receipt


def reject_event(root, event_id, reason="review rejected"):
    check_id(event_id)
    if len(reason) > 500:
        raise FeedbackError("reason is too long")
    with admin_lock(root):
        metadata, message = read_event(root / "pending", event_id)
        _, receipt = prepare_archive(root, event_id, metadata, message, "rejected", reason=reason)
        return receipt


def list_events(root, state, limit):
    count = 0
    for name in (("pending", "processed") if state == "all" else (state,)):
        directory = root / name
        if not directory.exists():
            continue
        with Directory(directory):
            for entry in directory.iterdir():
                if not ID_RE.fullmatch(entry.name):
                    continue
                if count >= limit:
                    emit({"notice": "limit reached", "limit": limit})
                    return
                try:
                    with Directory(entry) as item:
                        metadata = json.loads(item.read("metadata.json", 16384))
                    row = {key: metadata.get(key) for key in ("id", "created_utc", "label", "user", "source", "destination", "bytes")}
                    row["state"] = name
                    if name == "processed":
                        row["review"] = receipt_for(entry)
                    emit(row)
                except (OSError, ValueError, FeedbackError):
                    emit({"id": entry.name, "state": name, "error": "invalid event; manual inspection required"})
                count += 1


def show_event(root, event_id, state, path_only):
    metadata, message = read_event(root / state, check_id(event_id))
    if path_only:
        emit({"id": event_id, "message_path": str(root / state / event_id / "message.eml")})
        return
    headers = BytesHeaderParser(policy=policy.default).parsebytes(message[:min(len(message), 262144)])
    emit({"metadata": metadata, "headers": {name: [str(v)[:2000] for v in headers.get_all(name, [])[:10]] for name in HEADERS}})
    if state == "processed":
        emit({"review": receipt_for(root / state / event_id)})


def warn_processed_storage(root):
    processed = root / "processed"
    if not processed.exists():
        return
    total = count = 0
    for event in processed.iterdir():
        if not ID_RE.fullmatch(event.name) or event.is_symlink() or not event.is_dir():
            continue
        count += 1
        for prefix in (event, event / "source"):
            if prefix.is_symlink():
                continue
            for name in ("message.eml", "metadata.json", "admin.json"):
                try:
                    info = (prefix / name).lstat()
                    if stat.S_ISREG(info.st_mode):
                        total += info.st_size
                except FileNotFoundError:
                    pass
    if total >= collector.MAX_PENDING_BYTES or count >= collector.MAX_PENDING_EVENTS:
        print("rspamd-feedback-admin: WARNING processed archive reached 512 MiB or 1024 events; review retention manually; nothing deleted", file=sys.stderr)


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--queue-root", default=DEFAULT_ROOT)
    sub = parser.add_subparsers(dest="command", required=True)
    listing = sub.add_parser("list", help="JSON lines, metadata only")
    listing.add_argument("--state", choices=("pending", "processed", "all"), default="pending")
    listing.add_argument("--limit", type=int, default=100)
    showing = sub.add_parser("show", help="selected headers only; never dumps the body")
    showing.add_argument("id")
    showing.add_argument("--state", choices=("pending", "processed"), default="pending")
    showing.add_argument("--path", action="store_true")
    learning = sub.add_parser("learn", help="explicitly train one reviewed event, then archive it")
    learning.add_argument("id")
    learning.add_argument("label", choices=("spam", "ham"))
    learning.add_argument("--rspamc-bin", default="/usr/local/bin/rspamc")
    learning.add_argument("--controller", default="127.0.0.1:11334")
    learning.add_argument("--password-file", default=DEFAULT_PASSWORD_FILE,
                          help="root-only plaintext secret file; use --no-password only for an intentionally trusted local controller")
    learning.add_argument("--no-password", action="store_true")
    learning.add_argument("--timeout", type=float, default=30)
    rejecting = sub.add_parser("reject", help="archive one event without training or deleting it")
    rejecting.add_argument("id")
    rejecting.add_argument("--reason", default="review rejected")
    args = parser.parse_args(argv)
    os.umask(0o077)
    try:
        require_root()
        root = Path(args.queue_root)
        if not root.is_absolute():
            raise FeedbackError("queue root must be absolute")
        check_root_directory(root)
        warn_processed_storage(root)
        if args.command == "list":
            if not 1 <= args.limit <= 10000:
                raise FeedbackError("limit must be between 1 and 10000")
            list_events(root, args.state, args.limit)
        elif args.command == "show":
            show_event(root, args.id, args.state, args.path)
        elif args.command == "reject":
            emit(reject_event(root, args.id, args.reason))
        else:
            if not 0 < args.timeout <= 300:
                raise FeedbackError("timeout must be between 0 and 300 seconds")
            result = learn_event(root, args.id, args.label, executable=args.rspamc_bin,
                                 controller=args.controller, password_file=None if args.no_password else args.password_file,
                                 timeout=args.timeout)
            emit(result)
            return 0 if result["status"] == "learned" else 2
        return 0
    except (FeedbackError, OSError, ValueError, TypeError):
        print("rspamd-feedback-admin: operation refused or failed; inspect configuration/event locally", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

/usr/local/libexec/rspamd-feedback/feedback-worker.py

Служба обрабатывает события по точному времени, сохраняет намерение до запроса обучения и исключает повтор одного и того же класса для того же содержимого. Обратный переход передаёт противоположный класс. Недоступный до запроса контроллер оставляет событие pending. Неопределённый результат уже отправленного запроса автоматически не повторяется.

#!/usr/local/bin/python3.12
"""Root-only background learning from the bounded IMAPSieve feedback queue.

The collector remains a local spool writer. This process owns controller
credentials, learns only validated INBOX/Junk events, preserves every original,
and never retries a training operation with an uncertain result. Run foreground
under FreeBSD daemon(8), or use --once for one bounded batch.
"""
from __future__ import annotations

import argparse
from datetime import datetime
import importlib.util
import json
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import threading
import time

_spec = importlib.util.spec_from_file_location("feedback_worker_admin", Path(__file__).with_name("feedback-admin.py"))
admin = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(admin)
FeedbackError = admin.FeedbackError
SHA_RE = re.compile(r"[0-9a-f]{64}\Z")
MAX_JOURNAL_EVENTS = 20000
UNCERTAIN = {"preparing", "learning", "learn_uncertain", "quarantined_uncertain", "quarantined_ordering"}


def log_event(kind, **fields):
    # Callers pass only fixed diagnostics, numeric counts, event IDs and classes.
    print(json.dumps({"event": kind, **fields}, ensure_ascii=True, sort_keys=True), flush=True)


def _timestamp_ns(value):
    if not isinstance(value, str):
        raise ValueError("missing timestamp")
    parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    if parsed.tzinfo is None:
        raise ValueError("timestamp must have timezone")
    return int(parsed.timestamp() * 1_000_000_000)


def order_events(root):
    """Read bounded metadata, ordered by collector created_ns, then event ID.

    Legacy second-resolution timestamps are permitted only when the same
    content has no contradictory labels at an identical timestamp. Such a tie
    cannot be resolved from random event IDs and is quarantined without learning.
    """
    pending = Path(root) / "pending"
    rows = []
    count = 0
    with admin.Directory(pending):
        with os.scandir(pending) as entries:
            for entry in entries:
                if not admin.ID_RE.fullmatch(entry.name):
                    continue
                count += 1
                if count > admin.collector.MAX_PENDING_EVENTS:
                    raise FeedbackError("pending queue scan bound exceeded")
                try:
                    with admin.Directory(pending / entry.name) as directory:
                        metadata = json.loads(directory.read("metadata.json", 16384))
                    if not isinstance(metadata, dict):
                        raise ValueError("invalid metadata")
                    metadata = dict(metadata)
                    if metadata.get("id") != entry.name or metadata.get("schema") != 1:
                        raise ValueError("invalid event identity")
                    if not isinstance(metadata.get("sha256"), str) or not SHA_RE.fullmatch(metadata["sha256"]):
                        raise ValueError("invalid digest")
                    admin.collector.validate_event(metadata.get("label"), metadata.get("user", ""),
                                                   metadata.get("source"), metadata.get("destination"))
                    timestamp = metadata.get("created_ns")
                    if isinstance(timestamp, int) and not isinstance(timestamp, bool) and timestamp > 0:
                        metadata["_order_ns"] = timestamp
                    elif "created_ns" not in metadata:
                        metadata["_order_ns"] = _timestamp_ns(metadata.get("created_utc"))
                    else:
                        metadata["_order_ns"] = 0
                        metadata["_ordering_invalid"] = True
                    rows.append(metadata)
                except FileNotFoundError:
                    # A manual administrator may have archived this candidate.
                    continue
                except (OSError, ValueError, TypeError, FeedbackError):
                    rows.append({"id": entry.name, "_invalid": True, "_order_ns": 0})
    tied = {}
    for row in rows:
        if not row.get("_invalid"):
            tied.setdefault((row["_order_ns"], row["sha256"]), set()).add(row["label"])
    for row in rows:
        if not row.get("_invalid") and len(tied[(row["_order_ns"], row["sha256"])]) > 1:
            row["_ordering_invalid"] = True
    return sorted(rows, key=lambda row: (row["_order_ns"], row["id"]))


def _journal(root):
    """Read root-owned receipts; successful classes use durable worker sequence."""
    processed = Path(root) / "processed"
    latest, blocked = {}, set()
    max_sequence = used_bytes = count = 0
    if not processed.exists():
        return latest, blocked, max_sequence, used_bytes, count
    admin.check_root_directory(processed)
    with os.scandir(processed) as entries:
        for entry in entries:
            if not admin.ID_RE.fullmatch(entry.name):
                continue
            count += 1
            if count > MAX_JOURNAL_EVENTS:
                raise FeedbackError("processed journal scan bound exceeded")
            directory = processed / entry.name
            receipt = admin.receipt_for(directory)
            if not isinstance(receipt, dict) or receipt.get("id") != entry.name:
                raise FeedbackError("invalid processed receipt")
            digest = receipt.get("sha256")
            if not isinstance(digest, str) or not SHA_RE.fullmatch(digest):
                raise FeedbackError("invalid processed digest")
            sequence = receipt.get("worker_sequence", 0)
            if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
                raise FeedbackError("invalid processed sequence")
            max_sequence = max(max_sequence, sequence)
            status = receipt.get("status")
            if status in UNCERTAIN:
                blocked.add(digest)
            if status == "learned":
                label = receipt.get("selected_class")
                if label not in ("spam", "ham"):
                    raise FeedbackError("invalid learned class")
                # Existing manual receipts predate automatic processing; the old
                # admin refuses further training of a SHA once it has a receipt.
                fallback = _timestamp_ns(receipt.get("finished_utc", receipt.get("reviewed_utc")))
                position = (bool(sequence), sequence if sequence else fallback, entry.name)
                if digest not in latest or position > latest[digest][0]:
                    latest[digest] = (position, label)
            # Count snapshots and preserved originals, never recursively walk
            # arbitrary files in the mail-user-owned source/ directory.
            for part in (directory, directory / "source"):
                try:
                    with admin.Directory(part) as safe:
                        for name in ("message.eml", "metadata.json", "admin.json"):
                            try:
                                if safe.fd is not None:
                                    info = os.stat(name, dir_fd=safe.fd, follow_symlinks=False)
                                else:
                                    info = (part / name).lstat()
                                if stat.S_ISREG(info.st_mode):
                                    used_bytes += info.st_size
                            except FileNotFoundError:
                                continue
                except FileNotFoundError:
                    continue
    return latest, blocked, max_sequence, used_bytes, count


def _check_trusted_parent_chain(path):
    if os.name != "posix":
        return
    for parent in Path(path).parents:
        link_info = parent.lstat()
        info = parent.stat()
        if (link_info.st_uid != 0 or not stat.S_ISDIR(info.st_mode)
                or info.st_uid != 0 or info.st_mode & 0o022):
            raise FeedbackError("executable parent directories must be controlled by root")


def resolve_executable(executable):
    """Allow FreeBSD package symlinks, but pin both operations to their target."""
    source = Path(executable)
    if not source.is_absolute():
        raise FeedbackError("rspamc executable must be absolute")
    source_info = source.lstat()
    if os.name == "posix" and source_info.st_uid != 0:
        raise FeedbackError("rspamc entry must be owned by root")
    _check_trusted_parent_chain(source)
    target = source.resolve(strict=True)
    info = target.lstat()
    if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
        raise FeedbackError("rspamc target must be a regular file")
    if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o022):
        raise FeedbackError("rspamc target must be root-owned and not writable by mail users")
    _check_trusted_parent_chain(target)
    return str(target)


def controller_ready(executable, controller, password_file, timeout):
    """Authenticated read-only controller probe; no candidate is archived yet."""
    command = [resolve_executable(executable), "-h", controller]
    if password_file:
        secret = Path(password_file)
        info = secret.lstat()
        if not secret.is_absolute() or not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
            raise FeedbackError("unsafe controller password file")
        if os.name == "posix" and (info.st_uid != 0 or info.st_mode & 0o077):
            raise FeedbackError("controller password file must be root-owned mode 0600 or stricter")
        command.extend(["-P", str(secret)])
    command.append("stat")
    try:
        return subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                              stderr=subprocess.DEVNULL, check=False, timeout=min(timeout, 5)).returncode == 0
    except (subprocess.TimeoutExpired, OSError):
        return False


def process_pending(root, *, executable="/usr/local/bin/rspamc", controller="127.0.0.1:11334",
                    password_file=admin.DEFAULT_PASSWORD_FILE, timeout=30, batch_size=20,
                    preflight=None, learner=None, stop_event=None):
    """Process at most batch_size candidates. Inject probes/learner for unit tests.

    One admin lock serializes manual and background operations. A crash after a
    durable learning intent leaves status=learning, which blocks this SHA on the
    next pass; subsequent candidates are preserved as quarantined_uncertain.
    """
    root = Path(root)
    preflight = controller_ready if preflight is None else preflight
    if learner is None:
        # FreeBSD pkg installs rspamc as a root-owned versioned symlink. Resolve
        # it once so preflight and training invoke the same trusted executable.
        executable = resolve_executable(executable)
        learner = admin.run_learning
    summary = {"learned": 0, "duplicates": 0, "quarantined": 0, "invalid": 0,
               "pending": 0, "controller_unavailable": False, "storage_blocked": False,
               "archive_warning": False}
    with admin.admin_lock(root):
        events = order_events(root)
        summary["pending"] = len(events)
        if not events:
            return summary
        latest, blocked, sequence, used_bytes, archive_count = _journal(root)
        summary["archive_warning"] = archive_count >= admin.collector.MAX_PENDING_EVENTS
        if used_bytes >= admin.collector.MAX_PENDING_BYTES:
            summary["storage_blocked"] = True
            return summary
        if not preflight(executable, controller, password_file, timeout):
            summary["controller_unavailable"] = True
            return summary
        handled = 0
        for candidate in events:
            if handled >= batch_size or (stop_event is not None and stop_event.is_set()):
                break
            if candidate.get("_invalid"):
                summary["invalid"] += 1
                continue
            event_id = candidate["id"]
            if (root / "processed" / event_id).exists():
                # Crash before prepare_archive moved pending/ID: its root-owned
                # intent is authoritative. Preserve the pending original for
                # manual recovery and continue independent content, never retry.
                summary["invalid"] += 1
                continue
            try:
                metadata, message = admin.read_event(root / "pending", event_id)
            except FileNotFoundError:
                continue
            except (OSError, ValueError, TypeError, FeedbackError):
                summary["invalid"] += 1
                continue
            # Do not act if the queued ordering/identity changed since enumeration.
            if any(metadata.get(key) != candidate.get(key) for key in
                   ("sha256", "label", "created_ns", "created_utc")):
                summary["invalid"] += 1
                continue
            if used_bytes + 2 * len(message) + 32768 > admin.collector.MAX_PENDING_BYTES:
                summary["storage_blocked"] = True
                break
            label, digest = metadata["label"], metadata["sha256"]
            if candidate.get("_ordering_invalid"):
                status, reason = "quarantined_ordering", "event ordering is ambiguous"
            elif digest in blocked:
                status, reason = "quarantined_uncertain", "a prior learning operation has an uncertain result"
            elif digest in latest and latest[digest][1] == label:
                status, reason = "duplicate", "same class as the latest successful learning operation"
            else:
                status, reason = "learning", "automatic explicit IMAP feedback"
            try:
                archive, receipt = admin.prepare_archive(root, event_id, metadata, message, status, label, reason)
            except (OSError, ValueError, FeedbackError):
                # prepare_archive writes a root journal before moving the source;
                # stop instead of retrying an incompletely prepared operation.
                summary["storage_blocked"] = True
                break
            sequence += 1
            receipt.update({"mode": "automatic_imap_feedback", "worker_sequence": sequence,
                            "processed_ns": time.time_ns(), "source_created_ns": candidate["_order_ns"]})
            if digest in latest:
                receipt["previous_successful_class"] = latest[digest][1]
            # Durable intent exists before the only call that may change Bayes.
            admin.update_receipt(archive, receipt)
            if status == "learning":
                try:
                    returncode = learner(message, label, executable=executable, controller=controller,
                                         password_file=password_file, timeout=timeout)
                except (subprocess.TimeoutExpired, OSError, FeedbackError):
                    receipt["status"] = "learn_uncertain"
                    receipt["error"] = "client failure; server may have learned; no automatic retry"
                else:
                    receipt["returncode"] = returncode
                    receipt["status"] = "learned" if returncode == 0 else "learn_uncertain"
                    if returncode != 0:
                        receipt["error"] = "client returned failure; no automatic retry"
                receipt["finished_utc"] = admin.collector.utc_now()
                admin.update_receipt(archive, receipt)
                if receipt["status"] == "learned":
                    latest[digest] = ((True, sequence, event_id), label)
                    summary["learned"] += 1
                else:
                    blocked.add(digest)
                    summary["quarantined"] += 1
            elif status == "duplicate":
                summary["duplicates"] += 1
            else:
                blocked.add(digest)
                summary["quarantined"] += 1
            handled += 1
            used_bytes += 2 * len(message) + 32768
            archive_count += 1
            summary["pending"] -= 1
            summary["archive_warning"] = archive_count >= admin.collector.MAX_PENDING_EVENTS
            log_event("feedback_processed", id=event_id, label=label, status=receipt["status"])
            if receipt["status"] == "learn_uncertain":
                # The controller may have failed after the preflight. Leave all
                # other candidates pending until a fresh read-only probe passes.
                break
    return summary


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--queue-root", default=admin.DEFAULT_ROOT)
    parser.add_argument("--rspamc-bin", default="/usr/local/bin/rspamc")
    parser.add_argument("--controller", default="127.0.0.1:11334")
    parser.add_argument("--password-file", default=admin.DEFAULT_PASSWORD_FILE)
    parser.add_argument("--poll", type=float, default=2)
    parser.add_argument("--timeout", type=float, default=30)
    parser.add_argument("--batch-size", type=int, default=20)
    parser.add_argument("--once", action="store_true")
    args = parser.parse_args(argv)
    os.umask(0o077)
    admin.require_root()
    root = Path(args.queue_root)
    if not root.is_absolute() or not 0.2 <= args.poll <= 60 or not 0 < args.timeout <= 300 or not 1 <= args.batch_size <= 100:
        raise FeedbackError("invalid worker options")
    admin.check_root_directory(root)
    stopping = threading.Event()
    for signum in (signal.SIGTERM, signal.SIGINT):
        signal.signal(signum, lambda *_: stopping.set())
    lockfd = os.open(root / ".worker.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
    try:
        if os.name == "posix":
            import fcntl
            fcntl.flock(lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        previous_state = None
        while not stopping.is_set():
            try:
                summary = process_pending(root, executable=args.rspamc_bin, controller=args.controller,
                                          password_file=args.password_file, timeout=args.timeout,
                                          batch_size=args.batch_size, stop_event=stopping)
                state = {key: value for key, value in summary.items() if key not in ("learned", "duplicates", "quarantined")}
                if state != previous_state or any(summary[key] for key in ("learned", "duplicates", "quarantined")):
                    log_event("worker_state", **summary)
                previous_state = state
                if args.once:
                    return 2 if summary["controller_unavailable"] or summary["storage_blocked"] else 0
            except (OSError, ValueError, TypeError, FeedbackError):
                if previous_state != "local_error":
                    log_event("worker_error", reason="local queue, journal or configuration failure; no automatic retry of uncertain training")
                previous_state = "local_error"
                if args.once:
                    return 1
            stopping.wait(args.poll)
        return 0
    finally:
        os.close(lockfd)


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (OSError, ValueError, TypeError, FeedbackError):
        log_event("worker_start_failed", reason="root, configuration or worker lock required")
        raise SystemExit(1)

Обёртки запуска и права

Создать /usr/local/libexec/rspamd-feedback/collector:

#!/bin/sh
exec /usr/local/bin/python3.12 /usr/local/libexec/rspamd-feedback/feedback.py "$@"

Создать /usr/local/sbin/rspamd-feedback:

#!/bin/sh
exec /usr/local/bin/python3.12 /usr/local/libexec/rspamd-feedback/feedback-admin.py "$@"
chown root:wheel /usr/local/libexec/rspamd-feedback/* /usr/local/sbin/rspamd-feedback
chmod 0755 /usr/local/libexec/rspamd-feedback/* /usr/local/sbin/rspamd-feedback
python3.12 -m py_compile /usr/local/libexec/rspamd-feedback/feedback.py /usr/local/libexec/rspamd-feedback/feedback-admin.py /usr/local/libexec/rspamd-feedback/feedback-worker.py

15.5. Подключить IMAPSieve

Сохранить /usr/local/etc/dovecot/rspamd-feedback.conf как root:wheel 0644. Блок inbox дополняет существующий namespace, не заменяет его.

# Dovecot CE 2.4.5 / Pigeonhole 2.4: local IMAP feedback collection for the background worker.
# Include explicitly from the end of the existing monolithic dovecot.conf.
# Install root:wheel 0644. Precompile the two administrator scripts with sievec.
# No Sieve plugin is enabled for LMTP/LDA and no incoming mail is moved.

protocol imap {
  mail_plugins {
    imap_sieve = yes
  }
}

sieve_plugins {
  sieve_imapsieve = yes
  sieve_extprograms = yes
}

# External commands are available only to administrator-controlled scripts.
sieve_global_extensions {
  vnd.dovecot.pipe = yes
  vnd.dovecot.environment = yes
}

sieve_pipe_bin_dir = /usr/local/libexec/rspamd-feedback
sieve_pipe_exec_timeout = 3s

# The existing inbox namespace is private, has no prefix, and uses '/' as its
# separator. Exact names deliberately exclude shared namespaces, subfolders,
# Trash/Sent/Archive, and all movements other than these two directions.
namespace inbox {
  mailbox Junk {
    auto = subscribe
    special_use = \Junk

    imapsieve_from INBOX {
      sieve_script rspamd-report-spam {
        type = before
        cause = copy
        path = /usr/local/etc/dovecot/rspamd/report-spam.sieve
      }
    }
  }

  mailbox INBOX {
    imapsieve_from Junk {
      sieve_script rspamd-report-ham {
        type = before
        cause = copy
        path = /usr/local/etc/dovecot/rspamd/report-ham.sieve
      }
    }
  }
}

# imapsieve_url is intentionally unset: no user-uploaded IMAPSieve scripts.
# COPY is also the IMAPSieve cause for a client-issued IMAP MOVE command.
# The collector spools reports; the separate root worker trains Bayes automatically.

В конце основного /usr/local/etc/dovecot/dovecot.conf добавить ровно одну строку:

!include /usr/local/etc/dovecot/rspamd-feedback.conf

/usr/local/etc/dovecot/rspamd/report-spam.sieve:

require ["vnd.dovecot.pipe", "copy", "vnd.dovecot.imapsieve",
         "vnd.dovecot.environment", "environment", "variables"];

# Recheck the event as well as the Dovecot configuration filters. This excludes
# APPEND/FLAG, shared-folder paths, subfolders and non-INBOX source mailboxes.
if not allof (
    environment :is "imap.cause" "COPY",
    environment :is :comparator "i;octet" "vnd.dovecot.mailbox-from" "INBOX",
    environment :is :comparator "i;octet" "vnd.dovecot.mailbox-to" "Junk"
) {
    stop;
}

if environment :matches "imap.user" "*" {
    set "username" "${1}";
}
if string :is "${username}" "" {
    stop;
}

# Fixed program name; all metadata are separate argv entries, never shell text.
# :copy preserves the message; :try keeps feedback failures non-fatal.
pipe :copy :try "collector" ["spam", "${username}", "INBOX", "Junk"];

/usr/local/etc/dovecot/rspamd/report-ham.sieve:

require ["vnd.dovecot.pipe", "copy", "vnd.dovecot.imapsieve",
         "vnd.dovecot.environment", "environment", "variables"];

# Only restoring a message from Junk to INBOX constitutes a ham report.
# Moving to Trash/Sent/Archive, APPEND, FLAG, and shared folders are ignored.
if not allof (
    environment :is "imap.cause" "COPY",
    environment :is :comparator "i;octet" "vnd.dovecot.mailbox-from" "Junk",
    environment :is :comparator "i;octet" "vnd.dovecot.mailbox-to" "INBOX"
) {
    stop;
}

if environment :matches "imap.user" "*" {
    set "username" "${1}";
}
if string :is "${username}" "" {
    stop;
}

# The collector stores a pending report; the background root worker learns it.
pipe :copy :try "collector" ["ham", "${username}", "Junk", "INBOX"];
chown root:wheel /usr/local/etc/dovecot/rspamd-feedback.conf /usr/local/etc/dovecot/rspamd/*.sieve
chmod 0644 /usr/local/etc/dovecot/rspamd-feedback.conf /usr/local/etc/dovecot/rspamd/*.sieve
doveconf -n
sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/report-spam.sieve
sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/report-ham.sieve
chown root:wheel /usr/local/etc/dovecot/rspamd/*.svbin
chmod 0644 /usr/local/etc/dovecot/rspamd/*.svbin
service dovecot restart
service dovecot status

Рестарт Dovecot кратковременно разрывает существующие IMAP-сеансы; клиенты подключатся заново. Sieve :copy сохраняет письмо, :try не делает ошибку сборщика причиной отказа пользовательского действия. Выполнение внешнего сборщика ограничено 3 секундами, ожидание его блокировки — 2 секундами. LMTP-сортировка не нужна для обучения и на этом шаге ещё не включена.

15.6. Запустить автоматический обработчик

Сохранить /usr/local/etc/rc.d/rspamd_feedback как root:wheel 0555:

#!/bin/sh

# PROVIDE: rspamd_feedback
# REQUIRE: LOGIN redis rspamd
# KEYWORD: shutdown
#
# Install as /usr/local/etc/rc.d/rspamd_feedback, root:wheel 0555.
# Enable with: sysrc rspamd_feedback_enable=YES
# Uses FreeBSD 15.1 daemon(8): rc.subr targets the supervisor PID (-P),
# not the child PID (-p), so stop cannot accidentally restart the worker.

. /etc/rc.subr

name="rspamd_feedback"
desc="Apply user spam/ham feedback to the local Rspamd Bayes classifier"
rcvar="rspamd_feedback_enable"

load_rc_config "${name}"
: ${rspamd_feedback_enable:="NO"}

command="/usr/sbin/daemon"
procname="/usr/sbin/daemon"
pidfile="/var/run/rspamd_feedback.pid"
child_pidfile="/var/run/rspamd_feedback.child.pid"
worker="/usr/local/libexec/rspamd-feedback/feedback-worker.py"
python="/usr/local/bin/python3.12"
logdir="/opt/mail/log/rspamd-feedback"
logfile="${logdir}/worker.log"

required_files="${python} ${worker} /usr/local/libexec/rspamd-feedback/feedback-admin.py /usr/local/etc/rspamd/controller.password"
required_dirs="/opt/mail/rspamd-feedback/pending /opt/mail/rspamd-feedback/processed /opt/mail/rspamd"

# Worker handles transient controller errors in its polling loop. Unexpected
# exits get at most five supervised restarts, spaced 15 seconds apart.
# -H reopens the log on SIGHUP without interrupting an in-flight learn request.
command_args="-c -f -H -R 15 -C 5 -P ${pidfile} -p ${child_pidfile} -t ${name} -o ${logfile} ${python} -u ${worker} --poll 2"
start_precmd="rspamd_feedback_prestart"
extra_commands="reload"
sig_reload="HUP"

rspamd_feedback_prestart()
{
    if [ "$(id -u)" -ne 0 ]; then
        warn "${name} requires root to read the local controller credential"
        return 1
    fi

    # Refuse to write queue/log data to the root filesystem when /opt failed
    # to mount. Directory existence alone does not establish the mount state.
    if ! /sbin/mount -p | /usr/bin/awk '$2 == "/opt" { found = 1 } END { exit !found }'; then
        warn "${name} requires the mail filesystem mounted at /opt"
        return 1
    fi

    if [ -L "${logdir}" ] || [ -L "${logfile}" ]; then
        warn "${name} refuses a symlink at its private log path"
        return 1
    fi
    /usr/bin/install -d -o root -g wheel -m 0700 "${logdir}" || return 1
    if [ ! -e "${logfile}" ]; then
        /usr/bin/install -o root -g wheel -m 0600 /dev/null "${logfile}" || return 1
    fi
    /usr/sbin/chown root:wheel "${logfile}" || return 1
    /bin/chmod 0600 "${logfile}" || return 1
    umask 077
}

run_rc_command "$1"

Сохранить /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf как root:wheel 0644:

# Install /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf root:wheel 0644.
# FreeBSD daemon -H receives SIGHUP through its supervisor pidfile and reopens
# the file. Keep seven compressed 1 MiB archives; active and rotated logs 0600.
/opt/mail/log/rspamd-feedback/worker.log root:wheel 600 7 1024 * JC /var/run/rspamd_feedback.pid 1
chown root:wheel /usr/local/etc/rc.d/rspamd_feedback /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf
chmod 0555 /usr/local/etc/rc.d/rspamd_feedback
chmod 0644 /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf
sh -n /usr/local/etc/rc.d/rspamd_feedback
newsyslog -n -f /usr/local/etc/newsyslog.conf.d/rspamd-feedback.conf
rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat
sysrc rspamd_feedback_enable=YES
service rspamd_feedback start
service rspamd_feedback status
tail -50 /opt/mail/log/rspamd-feedback/worker.log

Супервизор daemon имеет отдельные PID-файлы родителя и ребёнка: stop завершает супервизор, не вызывает немедленный перезапуск worker. После неожиданного выхода предусмотрено не более пяти перезапусков с паузой 15 секунд. Проверка монтирования /opt выполняется до открытия журналов.

15.7. Необязательно: автоматическая доставка отмеченного спама в Junk

Этот небольшой отдельный этап помещает новые письма с локальной X-Spam-Flag: YES в Junk. Остальная почта сохраняется во Входящих. Уже лежащие сообщения не перемещаются. Если нужна только обратная связь при ручных перемещениях, этот этап можно пропустить. Автоматическая LMTP-доставка в Junk сама по себе не создаёт IMAP COPY и не обучает фильтр; исправление пользователем Junk → INBOX создаст ham-событие.

Сохранить /usr/local/etc/dovecot/rspamd-spam-delivery.conf как root:wheel 0644:

# Dovecot CE / Pigeonhole 2.4.5: sort marked mail during LMTP delivery only.
# Include explicitly from the existing monolithic dovecot.conf.
# Install this fragment and spam-delivery.sieve root:wheel 0644, and precompile
# the script with sievec -c /usr/local/etc/dovecot/dovecot.conf; make the resulting
# .svbin root:wheel 0644 so the vmail LMTP process can read it.

protocol lmtp {
  mail_plugins {
    sieve = yes
  }

  sieve_script rspamd-spam-delivery {
    type = before
    cause = delivery
    path = /usr/local/etc/dovecot/rspamd/spam-delivery.sieve
  }
}

# Existing IMAP COPY feedback scripts remain separate. LMTP fileinto does not
# produce an IMAP COPY event, so automatic filing cannot train the classifier.

Сохранить /usr/local/etc/dovecot/rspamd/spam-delivery.sieve как root:wheel 0644:

require ["fileinto", "mailbox"];

# Exim removes untrusted X-Spam-Flag and adds the local Rspamd result.
# This administrator script is used only for LMTP delivery, not IMAP events.
if header :is "X-Spam-Flag" "YES" {
    fileinto :create "Junk";
    stop;
}

# Unmarked messages use implicit keep (INBOX in the current configuration).

В конец основного dovecot.conf добавить единственную строку:

!include /usr/local/etc/dovecot/rspamd-spam-delivery.conf
doveconf -n
sievec -c /usr/local/etc/dovecot/dovecot.conf /usr/local/etc/dovecot/rspamd/spam-delivery.sieve
chown root:wheel /usr/local/etc/dovecot/rspamd/spam-delivery.svbin
chmod 0644 /usr/local/etc/dovecot/rspamd/spam-delivery.svbin
service dovecot restart
service dovecot status

15.8. Сквозная проверка обучения

Использовать отдельный тестовый ящик из уже настроенного домена; войти в него полным адресом через IMAP или SnappyMail. В клиенте папка Junk должна иметь специальный флаг \Junk и может отображаться как «Спам». Если папки нет в списке — обновить список/подписку.

  1. Проверить доставку обычного письма и локальные заголовки X-Spam-Score/X-Rspamd-Action. Поддельный X-Spam-Flag внешнего отправителя должен быть очищен и заменён результатом сервера.
  2. Отправить безвредное тестовое письмо себе. Переместить INBOX → Junk. В worker.log должен появиться feedback_processed с label=spam и status=learned, а событие перейти из pending в processed.
  3. Вернуть то же письмо Junk → INBOX: появится новое событие ham. Для проверки повторного исправления можно пройти spam → ham → spam → ham.
  4. Повтор той же копии с тем же классом должен иметь status=duplicate, не увеличивая вес примера повторно.
  5. Junk → Trash, изменение флагов и добавление письма сразу в папку не должны создавать обучающие события.
  6. Если включена сортировка, новая доставка в Junk должна обходиться без обратной связи; ручной возврат такого письма во Входящие должен дать ровно один ham-event.
  7. Отдельно проверить, что обычная почта доставляется при недоступном Rspamd, а ClamAV по-прежнему отклоняет EICAR. Остановку служб для этой проверки выполнять в согласованное окно или на тестовом экземпляре.

Для тестов, которые не должны обучать рабочую Bayes-базу, нужен отдельный экземпляр Rspamd с изолированной Redis-базой. Примеры обычных пользователей в тестовую базу не копировать. Синтетические события после теста удаляют только вместе с явно выделенным тестовым ящиком/изолированной очередью.

service rspamd_feedback status
rspamd-feedback list
rspamd-feedback list --state processed
rspamc -h 127.0.0.1:11334 -P /usr/local/etc/rspamd/controller.password stat
tail -50 /opt/mail/log/rspamd-feedback/worker.log
exim -bpc

Просмотр одного события, подставив его фактический ID из list:

rspamd-feedback show ID --state processed

15.9. Ограничения, очередь и ошибки

Очередь pending ограничена 1024 событиями и 512 MiB; одно письмо — не более 50 MiB. Сборщик сохраняет резерв свободного места 1 GiB плюс максимальное письмо и метаданные. Архив processed содержит root-only снимок и сохранённый исходный event; архив сам не очищается. После 1024 записей выдаётся предупреждение, при 512 MiB worker приостанавливает дальнейшую обработку до обслуживания архива. Нельзя без разбора удалять журнал processed: он нужен для подавления повторов и сохранения неопределённых результатов.

При controller_unavailable проверяются служба Rspamd, loopback 11334, файл controller.password и права. Пока контроллер заведомо недоступен, события остаются pending и могут быть обработаны после восстановления. Status learn_uncertain означает, что запрос уже мог изменить Bayes: автоматического повтора нет; последующие события того же содержимого изолируются для ручного разбора. Не переименовывать такие события обратно в pending и не запускать learn повторно вслепую.

Обратная связь не подтверждает, что класс письма объективно верен: это пользовательский сигнал общей статистической базе. Не выставлять контроллер Rspamd или Redis в интернет и не передавать пароль контроллера Dovecot, PHP либо vmail.

15.10. Отключение без удаления почты и статистики

Остановить только автоматическое обучение:

service rspamd_feedback stop
sysrc rspamd_feedback_enable=NO

Оценка Rspamd, доставка и IMAPSieve-сбор событий продолжат работать, уже обученная Bayes-база сохраняется. Пока worker выключен, следить за размером pending.

Чтобы отключить только автоматическую сортировку, убрать из основного dovecot.conf строку !include /usr/local/etc/dovecot/rspamd-spam-delivery.conf, выполнить doveconf -n и service dovecot restart. Обучение по пользовательским перемещениям сохранится.

Для полного отключения обратной связи остановить worker и убрать include rspamd-feedback.conf из dovecot.conf, проверить конфигурацию и перезапустить Dovecot. Для отключения оценки убрать только добавленный блок Rspamd из acl_check_data и spamd_address, проверить exim -bV и перечитать Exim. Если оставляется правило LMTP-сортировки, очистку чужих X-Spam-заголовков необходимо сохранить; проще сначала отключить сортировку. Правила ClamAV, пользовательский каталог и Maildir не удалять. Redis/Rspamd останавливать лишь после исключения зависимых функций.

Источники к настройке Rspamd

Первичные источники