Files
selfpost/docs/specification.md
T
mix 048be22ded docs: specify CI image build and ghcr.io publishing
Add section 10.1 covering tag-triggered CI build, version from git tag
flowing into both ldflags and the image tag (enforcing the 7.5.A restore
invariant), and publishing to ghcr.io. Document Quay.io as an alternative
registry. Update 11.7 (GitHub is no longer a dumb mirror) and add the
workflow as deliverable 11.10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:45:04 +03:00

99 KiB
Raw Blame History

Техническое задание: SelfPost

Версия: 1.0 Тип проекта: self-hosted SMTP relay с веб-панелью управления Формат поставки: один Docker-образ


1. Цель и назначение

SelfPost — это self-hosted SMTP-релей для личного использования, предназначенный для исходящей отправки почты из скриптов, приложений и систем уведомлений. Релей отправляет почту напрямую в интернет с собственного IP, с подписью DKIM, и управляется через веб-панель.

Ключевой сценарий использования: пользователь настраивает релей один раз (домен, DKIM, разрешённые отправители), после чего его приложения/скрипты отправляют почту через SMTP-endpoint релея, а релей доставляет её получателям от имени настроенного домена.


2. Контекст и ограничения

Эти ограничения — результат предварительного анализа и не подлежат пересмотру без явного согласования:

  1. Развёртывание — VPS или домашний сервер (одноплатники вроде Raspberry Pi пока вне рассмотрения, но не исключены в будущем). Оба варианта равноправны — образ один и тот же. Инфраструктурные предпосылки для отправки с собственного IP считаются обеспеченными оператором (см. заметку ниже).
  2. Отправка с собственного IP (DIY), без промежуточного relay-провайдера. Требует, чтобы VPS-провайдер разблокировал порт 25 (обычно по тикету) и позволял настроить PTR/rDNS.
  3. Один контейнер. Postfix, OpenDKIM и панель управления работают внутри одного образа под управлением process-supervisor. Это осознанное решение в пользу простоты развёртывания.
  4. Панель управления публично доступна из интернета. Это накладывает обязательные требования к безопасности (раздел 7).

Предпосылки инфраструктуры (вне зоны ответственности проекта). SelfPost исходит из того, что площадка (VPS или домашний сервер) уже обеспечивает условия для отправки с собственного IP: разблокированный исходящий порт 25, статический IP, настраиваемый PTR/rDNS и приемлемая репутация IP. Обеспечение этих условий — задача оператора при развёртывании, а не функция SelfPost. Проект не пытается их детектировать, обходить или компенсировать; при их отсутствии почта просто не будет доставляться, и это ожидаемо.


3. Что НЕ входит в объём (Out of Scope)

Явно исключено, чтобы не было scope creep:

  • Приём входящей почты (IMAP/POP3, mailbox'ы, доставка в ящики)
  • Веб-почта (webmail)
  • Мультитенантность (организации, несколько пользователей панели, роли) — панель рассчитана на одного администратора. Управление несколькими отправляющими доменами — это НЕ мультитенантность и входит в объём (см. раздел 4.1).
  • Антиспам/антивирус для входящей почты (rspamd, ClamAV)
  • Реализация собственного SMTP-сервера или MTA — используется готовый Postfix
  • Dovecot и любой полноценный mail-стек ради SASL — аутентификация делается на лёгком Cyrus SASL (sasldb2), см. раздел 5.1

4. Архитектура

Единый Docker-образ на базе Debian slim (например, debian:bookworm-slim). Выбор зафиксирован в пользу простоты поддержки: glibc даёт более предсказуемое поведение DNS-резолвера при постоянных MX-lookup'ах (в отличие от musl в Alpine), а подавляющее большинство документации и примеров конфигов Postfix/OpenDKIM ориентировано на Debian/apt — что снижает риск ошибок, особенно при генерации кода агентом. Экономия размера на Alpine здесь непринципиальна (образ всё равно тянет Python ради supervisord, а деплой идёт на VPS/сервер, не на ресурсно-ограниченный одноплатник). Внутри — три процесса под управлением supervisord:

┌───────────────────────────────────────────────┐
│  Docker-контейнер SelfPost                     │
│                                                │
│  supervisord (PID 1)                           │
│   ├── postfix   (start-fg)                     │
│   ├── opendkim  (foreground)                   │
│   └── panel     (Go-бинарник, один процесс,     │
│        совмещает несколько ролей:               │
│        • HTTP-сервер панели       :8080         │
│        • journal-milter (приём    :internal     │
│          From/To/Subject/SASL-user на этапе      │
│          DATA, см. 7.3)                          │
│        • log-tailer — горутина, следит за        │
│          mail.log и обновляет статус доставки)   │
│                                                │
│  Общая файловая система:                       │
│   /etc/postfix/...    ← панель пишет            │
│   /etc/opendkim/keys  ← DKIM-ключи              │
│   /data/selfpost.db   ← SQLite: домены,          │
│        приложения, администратор, журнал         │
│        отправки (см. 7.3, 9)                     │
│                                                │
│  Порты:                                        │
│   465 (smtps, implicit TLS — основной, только    │
│        SASL-аутентификация)                     │
│   587 (submission, STARTTLS — опционально,       │
│        добавляется по необходимости, раздел 5)   │
│   25  (исходящий в интернет)                    │
│   8080 (панель, за reverse-proxy)               │
└───────────────────────────────────────────────┘

Взаимодействие панели и Postfix: панель пишет конфиг-файлы напрямую в локальную ФС (/etc/postfix/...) и вызывает postfix reload как sibling-процесс. Поскольку всё в одном контейнере, inotify-вотчер и shared volumes между контейнерами не нужны.

Milter-цепочка Postfix теперь состоит из двух шагов: OpenDKIM (подпись) и journal-milter (логирование для журнала отправки, раздел 7.3) — оба сконфигурированы в smtpd_milters/non_smtpd_milters. Это остаётся в рамках «одного контейнера, трёх процессов под supervisord»: journal-milter — не новый процесс, а дополнительная роль внутри уже существующего panel-бинарника.

Порядок старта — обязателен, чтобы исключить ошибки соединения при холодном старте контейнера. priority= в supervisord задаёт только порядок отправки команд на запуск, а не готовность сокетов — сама по себе она не гарантирует, что milter-сокеты уже слушают к моменту, когда Postfix попытается к ним подключиться. Поэтому:

  1. priority: OpenDKIM стартует первым, затем panel (включая journal-milter listener).
  2. Postfix оборачивается стартовым скриптом, который блокируется в цикле опроса (например, test -S <unix-сокет> для unix-сокетов OpenDKIM/journal-milter, с интервалом и таймаутом — например, до 30 секунд), и запускает postfix start-fg только после того, как оба milter-сокета подтверждённо готовы.
  3. Если готовность не наступает в пределах таймаута — скрипт завершается с ошибкой (не запускает Postfix «на всякий случай»), чтобы supervisord/Docker увидели явный сбой старта в логах, а не тихо продолжили с сервисом, который будет отклонять почту из-за недоступного milter'а.
  4. Это решает только проблему холодного старта (первые секунды жизни контейнера) и не отменяет уже принятое поведение для runtime-сбоев после успешного старта: если OpenDKIM или journal-milter упадёт во время работы (не при старте), продолжает действовать fail-open, описанный в разделах 7.3/7.4 — это два разных сценария с разной правильной реакцией: на старте лучше подождать и не запускать Postfix «в дырявом» состоянии, а во время работы — не ронять уже идущий приём почты из-за вспомогательного компонента.

Требование к supervisord: если любой из трёх процессов падает и не может быть перезапущен, контейнер должен завершаться (а не оставаться «живым» с мёртвым Postfix), чтобы Docker с restart: unless-stopped корректно его перезапустил.

4.1. Доменная модель (ядро продукта)

SelfPost — мультидоменный релей. Две связанные сущности:

  • Отправляющий домен — например, example.com. Имеет свой DKIM-ключ/селектор.
  • Приложение (учётная запись) — SASL-пара логин/пароль, привязанная к конкретному домену. У одного домена может быть несколько приложений (например, newsletter@example.com и alerts@example.com, или просто «прод-сервер» и «staging-сервер» под один и тот же домен) — каждое со своим отдельным логином/паролем, но все они имеют право слать только от имени того домена, к которому привязаны, никогда от имени другого домена.

Режим адресов отправки — настройка на уровне приложения, не домена (разные приложения одного домена могут выбирать разный режим независимо). При создании или редактировании приложения выбирается один из двух вариантов:

  1. Любой адрес домена — приложению разрешено указывать в From любой адрес в пределах своего домена (*@example.com). Удобно, когда одно приложение шлёт от разных адресов (например, noreply@, alerts@, billing@) без необходимости заводить под каждый отдельное приложение.
  2. Список конкретных адресов — приложению разрешены только явно перечисленные From-адреса в пределах своего домена (например, только alerts@example.com). Более строгий вариант; попытка отправить с адреса не из списка отклоняется, даже если адрес принадлежит тому же домену.

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

Для каждого добавленного домена система управляет:

  1. DKIM-ключ и селектор — свои для каждого домена (см. раздел 6). Общие для всех приложений этого домена.
  2. Одно или несколько приложений — у каждого своя SASL-пара логин/пароль и свой режим адресов (см. раздел 5.1). Приложения одного домена независимы: удаление/перевыпуск/смена режима одного не затрагивает другие.
  3. Разрешённые From-адреса — определяются режимом каждого приложения (см. выше). В любом случае приложение не может отправлять от имени чужого домена — попытка залогиниться кредами приложения из домена A и отправить с адреса домена B отклоняется (см. привязку в разделе 5.1).

Жизненный цикл домена (что делает панель при добавлении):

  • создаёт запись домена;
  • генерирует DKIM-ключ + селектор для домена;
  • показывает пользователю всё, что нужно внести в DNS для этого домена (DKIM TXT, а также напоминание про SPF/DMARC — см. раздел 10);
  • не создаёт приложение автоматически — добавление первого (и последующих) приложений для домена делается отдельным действием, см. ниже.

Жизненный цикл приложения (что делает панель при добавлении приложения к домену):

  • генерирует SASL-пару логин/пароль (пароль показывается один раз, см. 7.2);
  • принимает выбор режима адресов: «любой адрес домена» либо «список конкретных адресов» (если выбран список — принимает сами адреса, с валидацией, что каждый принадлежит домену приложения);
  • регистрирует привязку в smtpd_sender_login_maps соответственно режиму — либо wildcard-запись @example.com → логин, либо отдельная запись на каждый разрешённый адрес конкретный-адрес@example.com → логин (несколько логинов и/или несколько записей могут вести к одному домену — это штатный случай, а не исключение);
  • применяет изменения (postfix reload).

Изменение режима существующего приложения — доступно как отдельное действие (переключение «любой адрес» ↔ «список», редактирование списка адресов), с пересборкой соответствующих записей в smtpd_sender_login_maps и postfix reload.

При удалении домена — удаляются его DKIM-ключ и все приложения, привязанные к этому домену. При удалении приложения — удаляются только его SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.

Эта модель — не мультитенантность (панелью по-прежнему управляет один администратор); это управление несколькими отправляющими доменами одного владельца, каждый из которых может обслуживать несколько приложений с независимо настроенными правами на адреса отправки.


5. Компонент: Postfix

Конфигурация Postfix как исходящего релея:

  1. Приём почты — порт 465 (smtps, implicit TLS) как основной. TLS устанавливается сразу при подключении (wrapper mode, smtpd_tls_wrappermode = yes в master.cf для сервиса smtps), в отличие от STARTTLS — клиент не отправляет ни байта в открытом виде до установления TLS-сессии. Только для аутентифицированных клиентов (SASL, см. 5.1). mynetworks не используется как способ авторизации отправки — пускают креды, а не сеть. Категорически недопустима конфигурация open relay.
    • Порт 587 (submission, STARTTLS) — не входит в базовую поставку, добавляется по необходимости. Многие SMTP-библиотеки в приложениях по умолчанию ожидают именно 587 — при необходимости совместимости сервис submission в master.cf включается тем же способом, что и smtps (аналогичная SASL/milter/rate-limit конфигурация, единственное отличие — механизм установления TLS, см. 5.1 п.2). Это осознанно не включено в основной сценарий, чтобы не открывать лишнюю поверхность без необходимости.
  2. Отправка — напрямую в интернет (без relayhost), с MX-lookup и TLS (smtp_tls_security_level = may минимум).
  3. DKIM — подключён как milter к OpenDKIM (через unix-socket или inet-socket внутри контейнера). Подпись — per-domain (раздел 6).
  4. Ограниченияsmtpd_recipient_restrictions запрещает релей для неаутентифицированных (permit_sasl_authenticated, reject_unauth_destination). Обязательна привязка отправителя к логину (раздел 5.1).
  5. Базовый rate-limiting по client IP — встроенный механизм Postfix (демон anvil): smtpd_client_message_rate_limit + anvil_rate_time_unit ограничивают число сообщений с одного client IP за окно времени. Работает на уровне приёма соединения, независимо от journal-milter — это базовый backstop, который продолжает действовать даже при сбое milter'а (см. раздел 7.4). Значения — конфигурируемые (раздел 8), по умолчанию консервативные, особенно уместные на этапе прогрева IP (раздел 10). Применяется одинаково к обоим портам приёма (465 и, если включён, 587).
  6. Домены, приложения (креды) и привязки — управляются панелью (файлы конфигурации, которые панель редактирует, с последующим postfix reload).

Базовый образ-ориентир для понимания подхода: boky/postfix (env-driven relay), но здесь конфигурация собирается под DIY-отправку с SASL-аутентификацией и мультидоменом, а не под relay через провайдера.

5.1. SASL-аутентификация и привязка приложений к домену

  1. Бэкенд SASL — Cyrus SASL с локальной базой sasldb2 (лёгкий, без Dovecot и без полноценного mail-стека). Панель создаёт/удаляет учётные записи приложений (эквивалент saslpasswd2), Postfix проверяет их через smtpd_sasl_auth_enable = yes.
  2. TLS обязателен для аутентификации на обоих портах, но механизм различается:
    • На 465 (smtps) — TLS уже установлен к моменту аутентификации по определению wrapper mode, отдельная настройка не требуется сверх smtpd_tls_wrappermode = yes.
    • На 587 (submission, если включён)smtpd_tls_auth_only = yes, чтобы креды не могли быть отправлены до STARTTLS по незашифрованному каналу. В обоих случаях результат одинаков: креды никогда не передаются в открытом виде.
  3. Привязка логина к разрешённым адресам (критично). Настраивается smtpd_sender_login_maps + reject_sender_login_mismatch в smtpd_sender_restrictions. Без этого любой валидный аккаунт сможет слать от имени любого домена — это дыра, а не опция. Формат записи в карте зависит от режима адресов, выбранного для приложения (см. раздел 4.1):
    • режим «любой адрес домена» — wildcard-запись @example.com логин (Postfix поддерживает доменные wildcard-записи в sender_login_maps);
    • режим «список конкретных адресов» — отдельная запись на каждый разрешённый адрес: alerts@example.com логин, noreply@example.com логин и т.д. Обе формы одновременно допустимы в карте для разных приложений/доменов. Панель поддерживает карту в актуальном состоянии при добавлении/удалении/изменении доменов и приложений, генерируя нужный тип записи в зависимости от режима.
  4. Многие-ко-одному. У одного домена может быть несколько приложений (логинов), каждое со своим независимым режимом адресов; ни одно приложение не может получить право слать от имени домена, к которому оно не привязано.

5.2. TLS-сертификаты для Postfix

SelfPost всегда работает за reverse-proxy, и именно reverse-proxy отвечает за выпуск, хранение и автообновление TLS-сертификатов (ACME/Let's Encrypt). Проект не содержит собственного ACME-клиента и не выпускает сертификаты сам — он только потребляет готовые.

Модель простая:

  1. Источник — reverse-proxy (Caddy/Traefik) кладёт PEM-файлы (цепочка + приватный ключ) для почтового hostname в директорию на хосте, смонтированную в контейнер SelfPost bind mount'ом, только на чтение (та же схема, что и для остального персистентного состояния — раздел 9). Пути к файлам задаются переменными окружения (см. раздел 8).
  2. Потребление — Postfix настроен читать эти файлы (smtpd_tls_cert_file / smtpd_tls_key_file) для TLS на 465 (smtps, основной), 587 (submission, если включён) и opportunistic TLS на порту 25. Один и тот же сертификат обслуживает оба порта приёма.
  3. Соответствие hostname — CN/SAN сертификата должен совпадать с HELO-hostname (SELFPOST_HOSTNAME) и PTR/rDNS. Проще всего использовать общий hostname для панели и почты, тогда это один и тот же сертификат от reverse-proxy.
  4. Обновление — когда reverse-proxy обновляет сертификат, файлы в смонтированной директории меняются, и Postfix нужно перечитать их через postfix reload. Чтобы деплой оставался простым, применяется периодический postfix reload (например, раз в сутки) — этого достаточно, т.к. сертификаты обновляются раз в ~2-3 месяца, а суточная задержка применения некритична. Реализовать как отдельную периодическую задачу под supervisord (или cron внутри контейнера). Inotify-вотчер на файл сертификата допустим как альтернатива, но периодический reload проще и надёжнее.

Персистентность сертификатов (хранение, ACME-account key) — ответственность reverse-proxy, не SelfPost (см. раздел 9). Контейнер SelfPost хранит сертификаты только как read-only mount и не заботится об их выживании при рестарте.


6. Компонент: OpenDKIM

DKIM-подпись — строго per-domain. Каждый отправляющий домен имеет собственную пару ключей и селектор.

  1. Генерация при добавлении домена — панель создаёт для нового домена пару DKIM-ключей и селектор. Все ключи должны переживать перезапуск контейнера (см. раздел 9).
  2. Подпись — исходящая почта каждого домена подписывается его собственным ключом. OpenDKIM настраивается с KeyTable + SigningTable, которые панель поддерживает в актуальном состоянии (запись на каждый домен), с последующим reload OpenDKIM.
  3. Показ DNS-записи per-domain — панель показывает публичную часть ключа в формате DNS TXT-записи для каждого домена отдельно, чтобы пользователь внёс её в DNS соответствующего домена.
  4. Селектор — на домен; может быть общим значением по умолчанию (например, selfpost) для всех доменов, т.к. селекторы живут в пространстве имён каждого домена и не конфликтуют. Значение по умолчанию — конфигурируемое.
  5. При удалении домена — его ключ и записи в KeyTable/SigningTable удаляются, OpenDKIM перечитывается.

7. Компонент: Панель управления

7.1. Технологический стек

  • Язык: Go. Обоснование: статический бинарник (минимум рантайм-зависимостей в образе), компилятор ловит ошибки до развёртывания (важно, т.к. код пишет ИИ-агент), простая упаковка.
  • Зависимости — минимальные. Приоритет стандартной библиотеки: net/http, html/template, os/exec. Внешние зависимости допускаются только для того, что не покрыто stdlib (например, bcrypt из golang.org/x/crypto). Каждая внешняя зависимость должна быть обоснована.
  • HTTP-сервер слушает :8080 внутри контейнера (HTTPS терминируется reverse-proxy, см. раздел 10).

Front-end:

  • Server-rendered через html/template. Никакого SPA, JS-фреймворка (React/Vue/Svelte) или build-шага (webpack/vite/npm). Весь UI отдаётся тем же Go-бинарником — единая точка деплоя, ноль node_modules. Это требование, а не рекомендация: оно прямо вытекает из приоритета простоты поддержки.
  • HTMX — единственная фронтенд-зависимость, подключается одним статическим <script> (вендорится в бинарник/образ, без npm). Используется для:
    • частичных обновлений на страницах-настройках (добавить/удалить отправителя без полной перезагрузки);
    • автообновления экранов мониторинга (очередь, лог) через периодический polling — hx-trigger="every Ns" подтягивает свежий фрагмент раз в несколько секунд (интервал сделать конфигурируемым или разумно зашитым, порядка 2–5 сек).
  • Мониторинг реализуется через HTMX-polling, НЕ через SSE/WebSocket. Осознанный выбор в пользу максимальной простоты: обновление раз в несколько секунд достаточно для этой задачи, а polling не требует stream-инфраструктуры, управления соединениями и усложнения кода. Мгновенность не требуется.
  • Эндпоинты, отдающие фрагменты для polling (например, /queue/fragment, /log/fragment), возвращают готовый HTML-кусок для замены блока на странице, а не JSON.

7.2. Функциональные требования

Панель предоставляет ограниченный набор операций:

  1. Аутентификация — вход администратора по логину/паролю (раздел 7.6).
  2. Список доменов — показать все настроенные отправляющие домены, их статус (есть ли DKIM-ключ, показана ли DNS-запись) и количество привязанных приложений.
  3. Добавить домен — по имени домена панель (см. раздел 4.1):
    • создаёт запись домена;
    • генерирует DKIM-ключ + селектор;
    • применяет изменения (reload OpenDKIM);
    • показывает DKIM TXT-запись для DNS. Добавление домена не создаёт приложение — это отдельное действие (п. 5).
  4. Удалить домен — удаляет DKIM-ключ домена и все привязанные к нему приложения (SASL-креды и привязки), применяет изменения. Панель должна явно предупредить о каскадном удалении приложений перед подтверждением.
  5. Добавить приложение к домену — внутри карточки домена, за одну операцию:
    • генерирует SASL-пару логин/пароль;
    • принимает выбор режима адресов: «любой адрес домена» либо «список конкретных адресов» (при выборе списка — ввод одного или нескольких адресов, каждый валидируется на принадлежность домену приложения, см. 7.6);
    • регистрирует соответствующую(ие) запись(и) в smtpd_sender_login_maps (wildcard или по адресам — раздел 5.1);
    • применяет изменения (postfix reload);
    • показывает сгенерированный пароль ОДИН РАЗ (см. 7.6, п. про пароли).
  6. Список приложений домена — показать все приложения (логины), привязанные к конкретному домену, вместе с их текущим режимом адресов (для режима «список» — сами адреса).
  7. Редактировать режим адресов приложения — переключить «любой адрес» ↔ «список», либо изменить сам список адресов для существующего приложения; пересобирает соответствующие записи в smtpd_sender_login_maps и применяет изменения.
  8. Удалить приложение — удаляет SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
  9. Перевыпустить пароль приложения — сгенерировать новую SASL-пару для существующего приложения (старый пароль инвалидируется, режим адресов сохраняется), показать новый пароль один раз.
  10. Просмотр DKIM DNS-записи — для любого домена показать его TXT-запись для копирования в DNS (доступно в любой момент — это не секрет).
  11. Просмотр очереди — вывод mailq / postqueue -p в читаемом виде (сколько писем в очереди, статусы). Экран автообновляется через HTMX-polling раз в несколько секунд (см. 7.1).
  12. Reload — кнопка, применяющая изменения конфига вручную (postfix reload / reload OpenDKIM), если нужно.
  13. Просмотр хвоста почтового лога для диагностики — последние N строк, с автообновлением через polling.
  14. Журнал отправки писем — таблица отправленных писем с фильтрами по домену и приложению (подробности — раздел 7.3).
  15. Настройка лимитов отправки — для домена и/или приложения задать привязанные IP и лимит писем за окно времени (подробности — раздел 7.4).
  16. Скачать полную резервную копию — кнопка формирует и отдаёт архив всего персистентного состояния (подробности — раздел 7.5.А).
  17. Экспортировать домен / Импортировать домен — перенос одного домена на другой экземпляр SelfPost, включая рабочие пароли приложений (перевыпуск не требуется); файл экспорта содержит секреты и должен обрабатываться как чувствительный (подробности — раздел 7.5.Б).

7.3. Журнал отправки (Send Log)

Отдельный от «просмотра очереди»/«просмотра лога» экран: не сырой mailq/hвост файла, а структурированная, фильтруемая история отправленных писем.

Что фиксируется в журнале для каждого письма:

  • время приёма;
  • домен и приложение (SASL-логин), от имени которого отправлено;
  • From-адрес и To-адрес(а);
  • Subject (тема);
  • статус: в очереди / отправлено / отклонено получателем / отложено (bounced/deferred), с обновлением по мере продвижения по очереди Postfix;
  • queue-id Postfix (для сопоставления с сырыми логами при более глубокой диагностике).

Технический механизм получения данных:

  1. Тема письма не попадает в стандартные логи Postfix (mail.log содержит envelope-данные и статус доставки, но не заголовки письма). Чтобы получить Subject, From, To и SASL-логин на этапе приёма, panel-бинарник реализует лёгкий milter (например, через библиотеку github.com/emersion/go-milter или аналог), подключённый в smtpd_milters Postfix вместе с OpenDKIM. На этапе приёма письма (EOH/заголовки) milter читает нужные поля и создаёт запись в журнале со статусом «в очереди», привязанную к queue-id.
  2. Финальный статус доставки panel получает отдельно: горутина внутри того же бинарника хвостит mail.log (аналогично экрану из п. 13), парсит строки со статусами sent/bounced/deferred по queue-id и обновляет соответствующую запись журнала.
  3. Если письмо направлено нескольким получателям, Postfix может завершать доставку по каждому получателю с разным статусом и в разное время — журнал хранит отдельную запись на пару (queue-id, получатель), чтобы точно отражать различающиеся статусы; в UI такие записи можно опционально визуально группировать по queue-id, но это не обязательно для первой версии.

⚠️ Известный риск реализации: milter на Go — менее протоптанный путь, чем остальной проект. В отличие от конфигурации Postfix/OpenDKIM (где документации и готовых примеров множество, что снижает риск ошибок агента), библиотек реализации milter-протокола на Go немного, и по ним заметно меньше примеров и обсуждений edge-case'ов. Конкретные точки риска:

  • Протокол milter завязан на конкретные версии (v2/v6 и т.п.) и не всегда единообразно документирован в доступных Go-библиотеках — есть шанс несовместимости с версией Postfix в образе.
  • Обработка ошибок/таймаутов milter'а влияет на приём почты напрямую. Если journal-milter зависнет или упадёт некорректно, Postfix (в зависимости от milter_default_action) может либо пропускать письма без логирования, либо, что хуже, начать отклонять всю входящую почту. Это самый чувствительный узел: баг здесь не просто ломает журнал, а потенциально ломает сам релей.
  • Меньше протестированных Go-примеров milter-серверов, читающих именно Subject/From/To на этапе EOH, — агенту придётся писать эту часть с меньшей опорой на устоявшиеся паттерны, чем остальной проект.

Требование по снижению риска: milter_default_action для journal-milter должен быть настроен так, чтобы сбой journal-milter не блокировал приём и отправку почты (fail-open для этого конкретного milter’а — в отличие от OpenDKIM, где отказ в подписи можно и нужно трактовать строже). Журнал — вспомогательная функция мониторинга; релей не должен переставать работать из-за него. Этот компонент нуждается в более внимательном тестировании (в т.ч. поведения при падении/таймауте) перед тем, как считаться готовым.

Хранение: таблица в той же SQLite (см. раздел 9), не файл лога — чтобы поддерживались фильтры и запросы без парсинга на лету.

Retention (обязательно предусмотреть, иначе журнал растёт бесконечно): конфигурируемый срок хранения записей (например, SEND_LOG_RETENTION_DAYS, по умолчанию 90 дней) с периодической фоновой очисткой устаревших записей. Значение по умолчанию и наличие настройки — обязательны; исполнитель может уточнить конкретную реализацию очистки (периодическая задача в той же горутине, что и log-tailer).

UI:

  • Таблица: время, домен, приложение, From, To, Subject, статус.
  • Фильтры — обязательны: по домену (выпадающий список из существующих доменов) и по приложению (список приложений, опционально зависящий от выбранного домена). Фильтры применяются на сервере (запрос к SQLite с WHERE), не на клиенте.
  • Пагинация или ограничение количества строк на экран (журнал может быть большим).
  • Автообновление свежих записей — через HTMX-polling, тот же подход, что у очереди и лога (раздел 7.1), не обязательно, но желательно для консистентности UX.

Приватность и безопасность — важное замечание: журнал хранит адреса получателей и темы писем — это метаданные переписки, потенциально чувствительные. Он доступен только через панель (за логином/паролем администратора, раздел 7.6) — отдельного уровня доступа для журнала не предусмотрено, весь объём защищён общей аутентификацией панели. Тема и адреса должны экранироваться при рендере (раздел 7.6, п.7) как и остальной вывод из логов.

7.4. Rate limiting (лимиты отправки)

Двухуровневая защита от «взбесившегося» приложения или скомпрометированных кред — цель — оградить репутацию IP, общего для всех доменов проекта.

Уровень 1 — базовый, нативный Postfix, независимый от milter (backstop). Описан в разделе 5, п. 5: smtpd_client_message_rate_limit/anvil_rate_time_unit, ключ — client IP. Работает всегда, включая ситуацию, когда journal-milter недоступен. Это гарантирует, что даже при полном отказе дифференцированного уровня 2 грубая защита от резкого всплеска отправки с одного IP не исчезает.

Уровень 2 — дифференцированные лимиты «на домен» и «на приложение», настраиваемые в панели. Реализованы в journal-milter (том же компоненте, что и журнал отправки, раздел 7.3), поскольку milter уже разбирает каждое соединение и имеет доступ к client IP на этапе приёма.

  • Ключ лимита — client IP, а не SASL-логин. Причина: один логин/пароль потенциально может использоваться несколькими физическими отправителями (например, кластер серверов с общими кредами) — SASL-идентичность не гарантированно соответствует одному источнику, а IP — более надёжный практический прокси для «кто на самом деле шлёт».
  • Настройка: при добавлении/редактировании домена и приложения (раздел 7.2) админ может указать один или несколько ожидаемых IP-адресов и лимит (N писем за скользящее окно) — отдельно на уровне домена (суммарно по всем его приложениям и IP) и отдельно на уровне приложения (для его конкретных IP). Оба необязательны — если не заданы, работает только базовый уровень 1.
  • Проверка: при приёме письма milter сверяет client IP с зарегистрированными для соответствующего домена/приложения и запрашивает текущий счётчик из SQLite (переиспользует данные журнала отправки); при превышении — отклоняет письмо (временный отказ 4xx — семантически корректно для rate-limit, ожидается, что отправитель повторит попытку позже) и опционально фиксирует это как отдельную запись в журнале со статусом «отклонено по лимиту» — для видимости в UI.
  • Fail-open допустим на этом уровне. Поскольку уровень 1 не зависит от milter и продолжает работать самостоятельно, при сбое/недоступности journal-milter уровень 2 может безопасно отключаться (fail-open, тот же принцип, что и для журнала, раздел 7.3) — теряется точность «по домену/приложению», но грубая защита от IP не пропадает.

⚠️ Оговорка: дифференцированный уровень требует предсказуемого IP приложения. Если приложение шлёт из окружения с динамическими/меняющимися IP (например, serverless с ротацией адресов), привязка «IP → домен/приложение» не может быть настроена содержательно — для таких приложений реальной защитой остаётся только базовый уровень 1 (глобальный, без разбивки по домену/приложению). Панель должна явно допускать оставить IP-привязку пустой (тогда дифференцированный лимит для этого приложения просто не применяется), а не требовать её обязательно.

⚠️ Известный нюанс: IPv6 ослабляет точность IP-ключа. Весь дизайн уровней 1 и 2 держится на «IP как надёжный прокси для одного отправителя» — для IPv4 это разумное допущение, но для IPv6 не совсем так: провайдеры часто выдают целый префикс (обычно /64) одному клиенту, и адрес в его пределах может меняться чаще, чем у IPv4-адреса. Формально это может ослаблять точность обоих уровней лимитов именно в IPv6-сетях. Осознанно фиксируется как известное ограничение и оставляется как есть — не требует изменения дизайна на данном этапе.

7.5. Резервное копирование и миграция

Два разных сценария с разной механикой — не путать друг с другом ни в реализации, ни в UI.

А. Полный бэкап сервера (миграция на новую машину целиком).

  • Что входит в архив: консолидированное персистентное состояние — SQLite (selfpost.db: домены, приложения, администратор, журнал, настройки лимитов), DKIM-ключи всех доменов, база SASL (sasldb2), и манифест с версией SelfPost, которой создан бэкап (см. ниже). Рекомендация исполнителю: организовать все три под единым корневым путём (например, всё под /data/), чтобы бэкап буквально сводился к архивации одной директории, а не сборке путей из разных мест контейнера — это прямое следствие цели «переезд прост как архив».
  • Что НЕ входит: TLS-сертификаты (ответственность reverse-proxy, раздел 5.2) и очередь Postfix (/var/spool/postfix, транзитные недоставленные письма — не переносятся; это осознанный компромисс ради простоты, а не недосмотр).
  • Версионирование бэкапа — обязательно. Архив содержит файл-манифест (например, manifest.json) с версией SelfPost, зашитой в бинарник на этапе сборки (Go build-time ldflags, -X main.version=..., согласуется с тегом Docker-образа). Восстановление должно происходить в тот же самый Docker-образ той же версии, которым был создан бэкап — это устраняет риск несовместимости схемы SQLite, путей DKIM-ключей или формата sasldb2 между версиями, а не полагается на то, что миграции схемы «как-нибудь сработают» задним числом.
    • Проверка при восстановлении: при старте контейнера с распакованным бэкапом (или на отдельном явном шаге restore) panel сверяет версию из манифеста с версией собственного бинарника. При несовпадении — отказ от запуска/восстановления с понятным сообщением, каким именно тегом образа нужно воспользоваться (например: «бэкап создан версией 1.3.0, запущена версия 1.5.2 — используйте selfpost:1.3.0 для восстановления»), а не тихая попытка продолжить с риском повреждения состояния.
    • Эта версия не про автомиграцию на лету между версиями — если пользователь хочет перейти на более новую версию, это делается отдельно (обычное обновление образа на живом инстансе, вне контура backup/restore), а не как часть восстановления бэкапа.
    • Следствие для деплоя: docker-compose.yml должен использовать фиксированный тег версии образа, не :latest — иначе невозможно достоверно определить, какой версией был создан бэкап, и вся эта защита теряет смысл. Отметить это явно в разделе 10 и README.
  • Создание бэкапа — двумя равнозначными способами:
    1. Кнопка в панели «Скачать резервную копию» — аутентифицированное действие администратора, формирует архив на лету и отдаёт на скачивание.
    2. Эквивалентная CLI-утилита внутри контейнера (например, selfpost-backup, вызываемая через docker exec) — для скриптовых/cron-бэкапов без захода в веб-интерфейс.
  • Восстановление на новом сервере: поднять пустой контейнер SelfPost той же версии образа, что указана в манифесте бэкапа, с теми же путями bind mount, распаковать архив в них до первого старта (либо тем же шагом, что и обычная инициализация — специального «режима восстановления» не требуется), затем запустить контейнер как обычно. Panel/Postfix/OpenDKIM конфигурация перегенерируется из восстановленного SQLite-состояния тем же механизмом, что и при каждом обычном старте (не отдельная ветка кода для restore) — не нужно заново проходить secret-link, заново заводить домены или получать новые DKIM-ключи.
  • Прямое следствие для DNS: поскольку DKIM-ключи переносятся побитово, DKIM TXT-записи в DNS не нужно менять после переезда — только A/PTR-записи на новый IP сервера. Это существенно упрощает миграцию по сравнению с «начать с нуля».
  • Безопасность архива: архив содержит крайне чувствительные данные — приватные DKIM-ключи, хэш пароля администратора, хэши SASL-кредов. Скачивание через панель уже защищено аутентификацией (раздел 7.6), но сам файл после скачивания нужно хранить и передавать как секрет (не по HTTP, удалять после успешного восстановления) — отметить это в документации.

Б. Экспорт/импорт отдельного домена (перенос одного домена между двумя независимо работающими экземплярами SelfPost).

  • Экспорт домена формирует файл с: именем домена, DKIM-ключом и селектором, режимом адресов, списком приложений (логинами и их режимом/списком адресов) и соответствующими записями sasldb2 для приложений этого домена. Технически это возможно, потому что sasldb2 (в отличие от bcrypt-хэша пароля администратора, раздел 7.6) хранит секрет в форме, допускающей challenge-response механизмы (CRAM-MD5/DIGEST-MD5) — это обратимая/эквивалентная паролю форма, а не строгий необратимый хэш, и её записи можно выборочно переносить между экземплярами так же, как это уже происходит с файлом sasldb2 целиком при полном бэкапе (раздел 7.5.А). Не путать эту сущность с bcrypt-хэшем администратора — они устроены принципиально по-разному, и только последний действительно невосстановим.
  • Импорт на другом экземпляре восстанавливает домен, DKIM-ключ (DNS-запись остаётся той же — менять не нужно) и приложения с рабочими паролями без перевыпуска — по тому же принципу, что и полный бэкап.
  • Безопасность экспортного файла — как у полного бэкапа. Поскольку файл теперь содержит секреты приложений (записи sasldb2) и приватный DKIM-ключ домена, он настолько же чувствителен, как архив полного бэкапа (раздел 7.5.А), и должен передаваться/храниться так же — не по HTTP, удаляться после использования, не рассматриваться как «просто конфиг».

7.6. Нефункциональные требования — БЕЗОПАСНОСТЬ (обязательно)

Поскольку панель публична, следующее — не опционально:

  1. Первичная инициализация администратора — через одноразовую секретную ссылку, не через env-переменную с готовым хэшем:
    • При первом запуске (в персистентном состоянии ещё нет ни одного администратора) панель генерирует криптографически случайный токен и выводит ссылку вида https://<host>/setup/<token> в лог/stdout контейнера. Токен дополнительно пишется в файл в смонтированной директории (например, /data/setup-token) — на случай, если удобнее прочитать файл, чем логи.
    • Энтропия токена — не менее 128 бит (например, 16+ случайных байт из crypto/rand, представленные в hex/base64url). Это единственное, что делает подбор математически неосуществимым в принципе — комбинаторное пространство ~3.4×10^38 вариантов; остальные меры (короткое окно, rate limit) — defense-in-depth поверх этого, а не замена ему.
    • Срок жизни токена — 10 минут (сокращено с изначально предложенного часа). Если контейнер стартовал, а установка не завершена за это время, токен истекает; при следующем обращении к /setup (после истечения) или при рестарте без завершённой настройки панель перегенерирует токен и заново выводит его в лог.
    • Rate limiting на маршрут /setup/<token> — обязателен, отдельно от общего rate limiting на логин (п. 5): ограниченное число попыток обращения в единицу времени по IP (например, несколько в минуту), с отклонением/задержкой сверх лимита. При заданной энтропии токена это не является единственной защитой, но снижает шум в логах и защищает от тривиального автоматического перебора.
    • Сравнение токена — константное по времени (crypto/subtle.ConstantTimeCompare или аналог), чтобы исключить timing-атаку, которая могла бы подсказывать правильные префиксы токена по разнице во времени ответа.
    • Неудачные попытки НЕ инвалидируют и не перегенерируют токен досрочно. Это осознанное решение: если бы ошибочные попытки заставляли токен перевыпускаться, атакующий получил бы возможность DoS'ить легитимную настройку, постоянно обнуляя токен раньше, чем администратор успеет им воспользоваться. Раз энтропия уже делает подбор неосуществимым, довешивать авто-инвалидацию на неудачу — риск без пользы.
    • Переход по ссылке открывает одноразовую форму создания администратора (логин + пароль).
    • После успешного создания администратора токен инвалидируется навсегда (флаг в персистентном состоянии), маршрут /setup/* перестаёт быть доступен (404).
    • Пароль администратора, введённый на этой форме, хранится только в виде bcrypt-хэша (или argon2) в персистентном состоянии. Никакого plaintext, MD5, SHA1-без-соли.
    • PANEL_USERNAME/PANEL_PASSWORD_HASH как env-переменные не используются для основного сценария — см. обновлённый раздел 8.
    • SASL-пароли приложений — отдельная сущность (не путать с паролем администратора панели). Панель генерирует сильный случайный пароль сама при создании/перевыпуске приложения, показывает его пользователю ровно один раз, и НЕ хранит его в открытом виде для повторного показа (в sasldb2 он лежит в хэшированном виде, как того требует механизм SASL). Если пароль утерян — только перевыпуск (п. 8 раздела 7.2).
  2. Валидация ввода на стороне сервера (не только в UI). Для email/доменов — строгий whitelist допустимых символов (буквы, цифры, ., -, @). Клиентская валидация не считается защитой. Для режима «список конкретных адресов» (раздел 4.1) — отдельная обязательная проверка: каждый вводимый адрес должен строго принадлежать домену того приложения, к которому он добавляется (совпадение части после @ с доменом); адрес из чужого домена отклоняется до записи в конфиг, а не полагается на то, что это отловит smtpd_sender_login_maps уже во время доставки.
  3. postfix reload и любые вызовы os/exec — БЕЗ интерполяции пользовательского ввода в команду. Аргументы передаются как отдельные элементы (exec.Command("postfix", "reload")), никогда через shell-строку. Пользовательский ввод не должен попадать в аргументы команд вообще; он идёт только в конфиг-файлы (после валидации).
  4. Запись в конфиг-файлы — с экранированием/санитизацией, чтобы инъекция спецсимволов (перенос строки и т.п.) не могла добавить произвольную директиву в конфиг Postfix.
  5. Rate limiting на эндпоинт логина (защита от brute-force). Простая реализация (счётчик попыток по IP с временной блокировкой) достаточна.
  6. Сессии — токен криптографически случайный, cookie с флагами HttpOnly, Secure, SameSite.
  7. Экранирование вывода — данные из очереди/логов (тема письма, адреса) рендерятся через html/template с автоэкранированием (защита от XSS).
  8. Процесс панели не должен работать от root (в supervisord запускать под непривилегированным пользователем, с доступом только к нужным путям через группу/права).

8. Конфигурация (переменные окружения)

Минимальный набор env-переменных для настройки (исполнитель может расширить):

  • SELFPOST_HOSTNAME — hostname самого сервера (должен совпадать с PTR/rDNS и с CN/SAN TLS-сертификата). Это hostname сервера, не отправляющий домен — домены добавляются через панель динамически.
  • DKIM_SELECTOR_DEFAULT — селектор DKIM по умолчанию для новых доменов (например, selfpost)
  • TLS_CERT_FILE — путь к PEM-файлу сертификата (цепочка), поставляемому reverse-proxy через read-only bind mount
  • TLS_KEY_FILE — путь к PEM-файлу приватного ключа, поставляемому reverse-proxy
  • SEND_LOG_RETENTION_DAYS — срок хранения записей журнала отправки (раздел 7.3), по умолчанию 90
  • RATE_LIMIT_MESSAGES_PER_IP — базовый лимит сообщений с одного client IP за окно (уровень 1, раздел 5 п.5 и 7.4), консервативное значение по умолчанию
  • RATE_LIMIT_WINDOW_SECONDS — окно времени для базового лимита (anvil_rate_time_unit), по умолчанию 3600 (час)

Администратор панели не задаётся через env-переменные. Создаётся один раз через одноразовую secret-ссылку при первом запуске (см. раздел 7.6, п. 1). Это сделано осознанно: пароль/хэш в env-переменных виден через docker inspect, оркестраторы и логи окружения — secret-link избегает этой поверхности и не требует от пользователя вручную считать bcrypt-хэш до старта.

Отправляющие домены, их SASL-креды, DKIM-ключи и привязки — тоже не в env-переменных, а в персистентном состоянии, управляемом панелью (см. разделы 4.1 и 9).


9. Персистентность (bind mount на хосте)

Должны переживать перезапуск/пересоздание контейнера:

  • DKIM-ключи всех доменов (/etc/opendkim/keys + KeyTable/SigningTable) — критично, иначе при рестарте подписи перестанут совпадать с DNS.
  • SASL-база (sasldb2) — учётные записи всех приложений всех доменов; без неё после рестарта приложения не смогут аутентифицироваться.
  • Конфиги Postfix, изменяемые панелью: список доменов, карта привязки smtpd_sender_login_maps (логины приложений → домены) и т.п.
  • База состояния панели — SQLite (единый файл, например /data/selfpost.db), содержит: реестр доменов и приложений (домены, привязанные приложения, режим адресов, селекторы, метаданные), учётную запись администратора (логин + bcrypt-хэш), флаг «первичная настройка завершена» / текущий setup-токен, журнал отправки писем (раздел 7.3) с retention-политикой, и настройки дифференцированных лимитов отправки — привязанные IP и лимиты на домен/приложение (раздел 7.4). Формат зафиксирован как SQLite (не «на усмотрение исполнителя», так как журнал и лимиты требуют фильтруемых запросов). Должна переживать рестарт — иначе при каждом перезапуске контейнера пришлось бы заново создавать администратора, терялась бы история отправки и настройки лимитов.
  • Очередь Postfix (/var/spool/postfix) — чтобы недоставленные письма не терялись при рестарте.

TLS-сертификаты в этот список не входят — они поставляются reverse-proxy через read-only bind mount, и их хранение/выживание при рестарте — ответственность reverse-proxy (см. раздел 5.2).

Ротация mail.log — обязательна. В отличие от структурированного журнала отправки в SQLite (раздел 7.3), у которого есть retention-политика (SEND_LOG_RETENTION_DAYS), сырой лог Postfix (mail.log, который читает log-tailer для панели, раздел 7.2 п.13) ничем не ограничен по умолчанию и будет расти неограниченно на протяжении месяцев/лет работы — на небольшом диске (раздел 10) это реальный риск исчерпания места, в отличие от остального состояния, которое ограничено by design. Настроить logrotate внутри контейнера (ежедневная/еженедельная ротация, ограниченное число хранимых файлов, например 7–14) как часть образа.

Механизм — bind mount на хосте, не именованный Docker volume. Все перечисленные выше пути монтируются из директории на файловой системе хоста (например, ./data рядом с docker-compose.yml, или зафиксированный абсолютный путь вроде /opt/selfpost/data) в консолидированный корень внутри контейнера (тот же /data, что уже рекомендован в разделе 7.5.А). Причина — та же цель простоты бэкапа и миграции: с именованным volume для доступа к данным нужно либо идти через docker volume inspect/docker cp, либо временно монтировать volume в служебный контейнер; с bind mount данные — это просто директория на диске, видимая и доступная напрямую средствами хоста (tar, rsync, scp) без обращения к Docker вообще. docker-compose.yml должен использовать синтаксис bind mount (./data:/data), а не секцию volumes: с именованным томом.

Оговорка про прямое копирование данных хостовым tar (в обход панели): риск неконсистентного снимка SQLite (WAL-режим, незавершённая запись) существует, только если копировать директорию во время работы контейнера — тогда возможна гонка между записью и чтением файла. Если контейнер на момент копирования остановлен, наивный tar полностью безопасен и эквивалентен встроенному механизму — писать в SQLite в этот момент физически некому. Встроенный бэкап через кнопку панели/CLI-утилиту (раздел 7.5.А) остаётся предпочтительным способом именно потому, что не требует останавливать сервис — он использует корректный снимок SQLite (VACUUM INTO/Backup API) и безопасен на живом контейнере. В документации отразить оба варианта: «бэкап на лету — через панель/CLI» и «прямой tar директории — безопасен, если сервис перед этим остановлен».


10. Развёртывание

  1. Поставка — Dockerfile + docker-compose.yml + документация.

  2. Reverse-proxy обязателен и является единым источником TLS-сертификатов. Он:

    • терминирует HTTPS для панели (HTTPS не реализуется в коде панели);
    • выпускает и автообновляет сертификаты через ACME/Let's Encrypt;
    • поставляет PEM-файлы сертификата в контейнер SelfPost через bind mount с хоста (read-only для SelfPost, тот же принцип, что и для остального состояния — раздел 9), откуда их читает Postfix для TLS на портах 465/25 (и 587, если включён) — см. раздел 5.2.
  3. Проект не привязан к конкретному reverse-proxy. Документация должна давать примеры интеграции для нескольких распространённых вариантов, а не навязывать один. Основной (по умолчанию) — Apache; остальные — как альтернативные фрагменты:

    • Apache (httpd)основной сценарий, готовый docker-compose.yml из коробки. Терминация HTTPS для панели через mod_ssl + mod_proxy/mod_proxy_http. Сертификаты — через certbot (Apache-плагин) либо встроенный mod_md. При использовании certbot PEM-файлы лежат готовыми в /etc/letsencrypt/live/<hostname>/ на хосте и монтируются в SelfPost bind mount'ом напрямую (read-only) — прозрачный путь для потребления Postfix'ом, без промежуточного извлечения. Для mod_md показать, как отдать сертификат в PEM в смонтированную директорию.
    • nginx (+ certbot/acme.sh) — PEM-файлы также лежат на диске хоста готовыми, монтируются напрямую bind mount'ом. Близкий по прозрачности к Apache+certbot.
    • Caddy — простейшая автоматика ACME. Пишет сертификаты как PEM в своём data-каталоге на хосте; смонтировать его bind mount'ом read-only и указать пути в TLS_CERT_FILE/TLS_KEY_FILE. Нюанс: путь включает внутреннюю раскладку хранилища Caddy (с именем ACME-CA) — исполнителю проверить актуальный путь хранения в текущей версии Caddy.
    • Traefik — сертификаты в acme.json, потребуется шаг извлечения PEM.

    Для каждого варианта показать: связку в docker-compose.yml (или фрагмент конфига), какая директория хоста монтируется в SelfPost bind mount'ом и по каким путям (TLS_CERT_FILE/TLS_KEY_FILE). Отличия форматов хранения сертификатов у разных прокси — ключевой практический момент, который документация обязана прояснить.

  4. При использовании общего hostname для панели и почты это один сертификат, обслуживающий оба тракта — самый простой вариант, его стоит показать как основной сценарий в каждом примере.

  5. Рекомендуемый дефолт — Apache (готовый docker-compose.yml, заводящийся «из коробки»); остальные варианты — как альтернативные фрагменты. Обоснование: целевая площадка пользователя уже использует Apache, а связка Apache+certbot даёт готовые PEM-файлы без промежуточных шагов извлечения — минимум движущихся частей в потреблении сертификата Postfix'ом.

  6. В docker-compose для контейнера панели/приложения заложить hardening: непривилегированный запуск, при возможности cap_drop, ограничение доступной ФС.

  7. Документация должна включать раздел «Требования к площадке» — краткий чеклист инфраструктурных предпосылок (разблокированный порт 25, статический IP, PTR/rDNS), которые оператор обеспечивает до развёртывания. Без подробного разбора ограничений — только чеклист «что должно быть готово».

  8. Документация должна включать раздел «Настройка DNS», явно разделяя записи уровня сервера и уровня домена:

    • Уровень сервера (один раз): PTR/rDNS для IP сервера.
    • Уровень домена (для КАЖДОГО добавленного отправляющего домена): SPF-запись, указывающая на этот сервер; DKIM TXT-запись (берётся из панели, своя на каждый домен); DMARC-запись. Без корректных per-domain записей почта соответствующего домена будет попадать в спам. Подчеркнуть, что при добавлении нового домена в панели пользователь обязан внести его DNS-записи.
  9. Документация должна включать раздел «Прогрев IP» — предупреждение, что свежий IP требует постепенного наращивания объёма отправки и проверки блоклистов (Spamhaus и т.п.).

  10. docker-compose.yml должен использовать фиксированный тег версии образа (например, selfpost:1.3.0), не :latest. Это прямое следствие требования версионирования бэкапов (раздел 7.5.А) — без явного тега невозможно достоверно определить, какой версией был создан конкретный бэкап, и проверка совместимости при восстановлении теряет смысл.

  11. Документация должна указывать ориентировочные минимальные требования к машине: 1 vCPU, ~512МБ–1ГБ RAM (при простое стек занимает ориентировочно 100–150МБ, с запасом под нагрузку), 8–10ГБ диска — с оговоркой, что диск растёт в первую очередь за счёт журнала отправки (ограничен SEND_LOG_RETENTION_DAYS) и ротируемого mail.log (раздел 9), а не самого приложения. Отдельно упомянуть рекомендацию настроить небольшой swap на машинах с малым объёмом RAM — дешёвая страховка на случай одновременного всплеска (бэкап + фильтрация журнала + несколько TLS-хендшейков одновременно).

10.1. Сборка и публикация образа (CI)

Образ собирается автоматически в CI и публикуется в container registry как неизменяемый артефакт под тегом версии. Именно это делает реально работающими два уже принятых требования: проверку совместимости при восстановлении бэкапа (раздел 7.5.А) и фиксированный тег образа в docker-compose.yml (п. 10 выше). Если бы пользователь пересобирал образ локально из Dockerfile, две сборки «одной и той же версии» на разных машинах/в разное время не были бы идентичны (дрейф базового debian:bookworm-slim и версий apt-пакетов), и гарантия «восстанавливай в ту же версию» держалась бы на честном слове. Публикуемый по тегу immutable-образ снимает это: любой инстанс тянет побитово один и тот же образ.

Механика:

  1. Триггер — git-тег вида vX.Y.Z. Push тега запускает CI-сборку. Обычные коммиты в ветку образ не публикуют.
  2. Версия выводится из тега в одном месте и прокидывается в сборку двумя связанными способами: в бинарник панели через -ldflags "-X main.version=X.Y.Z" (раздел 11.1) и в тег самого образа. Это структурно гарантирует инвариант «тег образа == вшитая версия бинарника», на который опирается 7.5.А, — версия не задаётся вручную в двух местах, где легко разойтись.
  3. Публикация<registry>/<owner>/selfpost:X.Y.Z, тег иммутабельный. :latest для потребления в docker-compose.yml не используется (п. 10 выше).

CI и registry — GitHub. Workflow (GitHub Actions) лежит в самом репозитории (.github/workflows/), зеркалится на GitHub вместе с остальным кодом (раздел 11.7) и выполняется там; образ публикуется в GitHub Container Registry (ghcr.io). Обоснование: для public-образов ghcr бесплатен и без rate-limit на анонимный pull — в отличие от Docker Hub, где анонимный pull лимитирован, а образ тянут на голом VPS при деплое, часто без docker login. Связка Actions + ghcr не добавляет внешних сервисов сверх уже используемого GitHub-зеркала.

Альтернатива — Quay.io (задокументировать, но по умолчанию не реализовывать). Нейтральный registry, тоже без лимитов на анонимный pull для public-образов, со встроенным сканированием уязвимостей, и не завязанный на конкретного CI-вендора (пушить в него можно из любого CI, включая тот же GitHub Actions). Зафиксирован как готовый путь отхода на случай, если в будущем захочется отвязать хранение образа от GitHub или уйти от моновендорности, — переезд не затрагивает остальной дизайн (меняется только адрес registry в workflow и в примере docker-compose.yml). На текущем этапе выбран ghcr ради минимума движущихся частей.


11. Deliverables (что должно быть на выходе)

  1. Dockerfile — сборка единого образа (Debian slim). Версия SelfPost зашивается в бинарник панели на этапе сборки (go build -ldflags "-X main.version=...") и должна совпадать с тегом самого Docker-образа — используется для проверки совместимости при восстановлении бэкапа (раздел 7.5.А).
  2. supervisord.conf — конфигурация процессов (Postfix, OpenDKIM, панель) с корректным priority= и стартовым скриптом-обёрткой для Postfix, ожидающим готовности milter-сокетов перед запуском (раздел 4).
  3. Конфигурационные шаблоны Postfix (включая SASL, smtpd_sender_login_maps, milter-цепочку smtpd_milters с OpenDKIM + journal-milter) и OpenDKIM (KeyTable/SigningTable).
  4. Исходный код панели на Go (структурированный проект), с вендоренным HTMX. Включает: HTTP-сервер панели, journal-milter (приём From/To/Subject/SASL-user), log-tailer (обновление статусов доставки), rate-limit проверку, работу с SQLite, логику полного бэкапа/восстановления и экспорта/импорта домена (раздел 7.5).
  5. docker-compose.yml — основной с Apache как reverse-proxy; альтернативные фрагменты для nginx/Caddy/Traefik.
  6. CLI-утилита резервного копирования (например, selfpost-backup) внутри образа, вызываемая через docker exec, — эквивалент кнопки бэкапа в панели, для скриптовых/cron-сценариев (раздел 7.5.А).
  7. README.md — установка, требования к площадке, per-domain настройка DNS, прогрев IP, эксплуатация, процедура полного бэкапа/восстановления и процедура экспорта/импорта домена (с явным указанием, что оба типа файлов содержат секреты и требуют бережного обращения, как пароль). Также ссылка на репозиторий: Codeberg — основной, GitHub — зеркало (зеркалирование кода настраивается push-зеркалированием средствами Codeberg; поверх зеркала на стороне GitHub работает CI-workflow сборки и публикации образа — см. раздел 10.1 и п. 10 ниже). README также указывает, откуда тянуть образ (ghcr.io), и что docker-compose.yml использует фиксированный тег версии (раздел 10, п. 10).
  8. Первичная инициализация — реализована как secret-link при первом запуске (раздел 7.6, п. 1), отдельного скрипта для задания пароля администратора не требуется. DKIM-ключи генерируются панелью per-domain при добавлении домена, а не на этом шаге.
  9. LICENSE — AGPL-3.0. Выбор осознанный: в отличие от GPL, AGPL закрывает «SaaS-лазейку» — обязывает раскрывать исходники изменённой версии, если её разворачивают как сервис, доступный через сеть, а не только при распространении копии кода. Файл лицензии — полный текст AGPL-3.0, без сокращений. В README.md — явное упоминание лицензии и её смысла в двух-трёх предложениях.
  10. CI-workflow сборки и публикации образа (GitHub Actions) — файл в .github/workflows/, запускаемый по git-тегу vX.Y.Z: собирает образ, прокидывает версию из тега в -ldflags "-X main.version=..." и в тег образа, публикует в ghcr.io как ghcr.io/<owner>/selfpost:X.Y.Z (раздел 10.1). Workflow зеркалится на GitHub и выполняется там. Публикация в Quay.io — как задокументированная альтернатива (раздел 10.1), не как основной путь.

12. Инструкции исполняющему агенту

  1. Не делать git-коммитов без явной инструкции в промпте. Изменения вносятся в рабочее дерево; коммит — только по прямому указанию.
  2. После написания Go-кода — обязательно выполнять go build и go vet; исправлять все ошибки компиляции и предупреждения до завершения задачи. При наличии тестов — go test.
  3. Проверять, что образ собирается (docker build) и контейнер стартует, прежде чем считать задачу выполненной.
  4. Двигаться итеративно: сначала минимальный работающий скелет (сборка образа, запуск трёх процессов, пустая панель с логином), затем наращивать функциональность.
  5. Требования безопасности из раздела 7.6 — не откладывать «на потом», закладывать сразу при написании соответствующих эндпоинтов.
  6. Любое отклонение от ограничений раздела 2 или добавление сущностей из раздела 3 (Out of Scope) — согласовывать, не реализовывать по своей инициативе.
  7. Перед тем как приступать к реализации, агент должен предложить конкретный план (список задач/этапов) на основе этого ТЗ и дать возможность свериться/скорректировать план до начала написания кода — не начинать кодить сразу по первому сообщению без явного подтверждения плана.
  8. Лицензии внешних Go-зависимостей (в частности, библиотеки для milter-протокола, раздел 7.3) — проверять перед добавлением. Postfix/OpenDKIM/Cyrus SASL/supervisord запускаются отдельными процессами и не ограничивают лицензию проекта (раздел 11, п.9, AGPL-3.0) независимо от своих лицензий. Библиотеки, которые компилируются непосредственно в Go-бинарник панели, должны быть permissive (MIT/BSD/Apache-2.0) либо GPL-семейства (GPL/LGPL — совместимы с AGPL-3.0 по построению); избегать зависимостей с иными, не проверенными на совместимость лицензиями.