Files
reactive-mail-server/README.md
T
Liosha84andClaude Opus 5.5 c765c51cf0
continuous-integration/drone/pr Build is passing
Несколько ящиков у одного логина, общие ящики, кто ответил, вход ящик*логин
- V7: users.is_shared, mailbox_access (MEMBER от админа/Jambotron, ATTACHED по паролю ящика), messages.answered_by
- /api/mailboxes: мои ящики с непрочитанными, сессия другого ящика (JWT с claim by), подключить по паролю
  (LoginGuard), убрать из меню
- общий ящик: без своего входа (веб/SMTP/IMAP), участники открывают; «прочитано» общее; ответ из веба → answered_by
- SMTP/IMAP: ящик*логин + пароль логина (как master user в Dovecot)
- открытый чужой ящик (общий, подключённый, OPEN админа): профиль и пароль не меняются (403)
- админ: создать shared, участники ящика (PUT /{id}/members); служебное API: PUT /shared/{address};
  выключение аккаунта убирает и из общих ящиков
- UI: MAILBOXES в левом меню (переключение, ⊕ подключить, × убрать), shared/участники в настройках,
  «Answered by» у письма
- 88 тестов; живой прогон на Postgres 17 (апгрейд V6→V7 на данных), скриншоты UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:27:11 +01:00

30 KiB
Raw Blame History

Reactive Mail Server

Почтовый сервер на Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.

Назначение — наша настоящая почта https://mail.jambocheck.com (prod Jambotron, системная ВМ, адрес 103.109.235.154) и тестовый проект на Jambotron.

Забыл пароль — где взять

Регистрации нет: ящики создаёт администратор admin@jambocheck.com — веб-почта → Настройки → MAILBOXES.

Ловушка у ящика (TRAP): Настройки → MAILBOXES → кнопка outbox у ящика. Всё, что такой ящик отправляет (SMTP или веб) на чужие адреса, никому не доставляется: одна копия ложится в его же INBOX. Письма на наши адреса и рассылки (alerts-staging@) доставляются как обычно. Для staging@ — тестовые письма staging-стенда не попадают к живым людям, а алерты staging доходят до команды.

Папки по +метке: письмо на bob+prod@jambocheck.com ложится в ящик bob@ в папку prod (создаётся сама). В имени ящика и рассылки + нельзя. Метка не может быть системной папкой (Spam, Trash …) — тогда INBOX.

Рассылки (LISTS): Настройки → LISTS. Письмо на адрес рассылки (alerts-prod@) → каждому участнику своя копия (своё «прочитано») в папку рассылки. Закрытая рассылка (по умолчанию) принимает письма только от вошедших отправителей (Kuma через monitor@, Jambotron) — снаружи 550. Адрес рассылки не может совпадать с ящиком.

Несколько ящиков у одного логина: слева в меню MAILBOXES — свой ящик, общие, в которых ты участник, и подключённые (кнопка ⊕: адрес + пароль того ящика). Клик — переключиться; «×» — убрать из своего меню.

Общий ящик (support@, billing@): Настройки → MAILBOXES → галочка «shared mailbox» при создании, кнопка group — кто может его открывать. Своего пароля и входа у него нет; «прочитано» общее (командный ящик); у письма видно «Answered by …» — кто ответил из веб-почты. В профиле открытого чужого ящика пароль/имя не меняются.

В почтовой программе (Thunderbird, телефон) общий или подключённый ящик — логин ящик*свой_логин (например support@jambocheck.com*jambotronpw@jambocheck.com) и свой пароль; так и для IMAP, и для SMTP (письма уходят от имени общего ящика).

Служебное API для Jambotron (/api/service/**, заголовок Authorization: Bearer <SERVICE_TOKEN>; токен генерирует secrets-init в томе секретов — файл SERVICE_TOKEN, без токена API выключено): Общие ящики: PUT /shared/{address} {displayName, members[]} — создать при отсутствии, точный состав участников; выключение аккаунта убирает его и из общих ящиков. PUT /accounts/{email} {displayName, active} — создать (201 + пароль один раз) или изменить; active:false — выключить и убрать из всех рассылок · POST /accounts/{email}/password — новый пароль один раз · GET /accounts/{email} · GET /lists · PUT /lists/{address} {folder, description, openToOutside, members[]} — точный состав · DELETE /lists/{address}.

Администратор: admin@jambocheck.com, пароль eK-6oj3TMtZgoXVxlvtCWFI6 (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).

Пароли остальных ящиков (staging@, no-reply@, monitor@) — в репозитории Jambotron: INFRASTRUCTURE/SECRETS.md, раздел «Почта mail.jambocheck.com».

Ситуация Что делать
Пароль admin@ ещё не меняли файл /app/data/initial-passwords.txt внутри контейнера mail на ВМ почты (в этом репозитории его нет; лежит в томе data, передеплой его не стирает). С компьютера: ssh root@103.109.235.150, затем incus exec --project jambotron-prod incust-prod-react-mail-server -- docker exec jambotron-109cf3bb-mail-1 cat /app/data/initial-passwords.txt. В логе mail строка Mailbox created… есть только до первого передеплоя
Пароль admin@ сменили и забыли новый пароль прямо в базе почты, ящик и письма остаются — попросить α/Ω; команда — Jambotron, INFRASTRUCTURE/test-stands.md, раздел «Почта mail.jambocheck.com»
Забыт пароль другого ящика (staging@, no-reply@, monitor@, свой) войти под admin@ → Настройки → MAILBOXES → ключик у ящика → новый пароль (показывается один раз) и вписать его туда, где ящик настроен

Всё о prod-почте (порты, DNS, кто через неё шлёт) — Jambotron, INFRASTRUCTURE/test-stands.md.

Как устроено

 SMTP-клиент / чужой сервер ──► SmtpServer (Netty, порт 3025) ──┐
                                                               ├─► MailRouter ─┬─► LocalDelivery → свой ящик (PostgreSQL)
 Веб-почта (Angular) ──► REST /api ──► SubmissionService ──────┘               └─► OutboundQueue → DeliveryWorker → SMTP получателя
  • Приём (SMTP). Команды разбираются по строкам (LineBasedFrameDecoder): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь до ответа 250; если сохранить не удалось — 451, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке.
  • TLS. STARTTLS на порту SMTP (RFC 3207) и, если задан SMTPS_PORT, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После STARTTLS сессия начинается заново (новый EHLO), а команды, присланные открытым текстом вместе со STARTTLS, не выполняются — соединение закрывается (CVE-2011-0411). AUTH предлагается только внутри TLS (или без него при SMTP_AUTH_WITHOUT_TLS=true). Сертификат — свой (MAIL_TLS_CERT + MAIL_TLS_KEY, например Let's Encrypt) или самоподписанный: создаётся один раз в MAIL_TLS_DIR и потом переиспользуется. Свой сертификат сервер перечитывает сам, когда файлы меняются (проверка раз в минуту): обновлённый Let's Encrypt подхватывается без перезапуска, недописанный файл не ломает TLS — остаётся прежний сертификат.
  • Не открытый релей.
    • Письмо на свой домен принимается от кого угодно.
    • Письмо на чужой домен — только после AUTH, иначе 550 5.7.1 Relaying denied.
    • Вошедший пользователь пишет только от своего адреса, иначе 553.
  • Маршрут (MailRouter) один для SMTP и веб-почты:
    • есть такой ящик в нашем домене → в него;
    • задан MAIL_CATCH_ALL → всё остальное (и чужие домены) — в ящик-ловушку, наружу ничего;
    • наш домен без такого ящика → «нет такого пользователя»;
    • чужой домен → очередь.
  • Отправка наружу (OUTBOUND_MODE):
    • off — ничего не уходит;
    • mx — напрямую на MX получателя, порт 25, STARTTLS по возможности;
    • smarthost — через провайдера (Resend, SES) с логином и проверкой TLS.
    • Очередь — таблица outbound_queue. Временная ошибка — повтор через 1 мин, 5 мин, 15 мин, 1 ч, 3 ч, 6 ч, 12 ч. Постоянная ошибка или исчерпанные повторы — уведомление отправителю во входящие.
  • DKIM. Подпись — utils-mail-dkim (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в DKIM_KEYS_DIR, запись для DNS печатается в лог при старте: TXT <selector>._domainkey.<domain>.
  • Права. Письма, папки и вложения отдаются только владельцу (чужие — 404). Регистрация по умолчанию закрыта, ящики — только в своих доменах.

IMAP

Почтовые программы (Thunderbird, почта на телефоне) работают с теми же ящиками, что и веб-почта.

  • Порт IMAP_PORT (3143) со STARTTLS, и неявный TLS на IMAPS_PORT (993), если задан. Сертификат тот же, что у SMTP. Вход (LOGIN, AUTHENTICATE PLAIN) — только внутри TLS, без него сервер отвечает LOGINDISABLED.
  • Возможности: IMAP4rev1, LITERAL+, IDLE (новые письма приходят сами, проверка раз в 5 с), UIDPLUS, MOVE, UNSELECT, NAMESPACE, SPECIAL-USE (Отправленные, Черновики, Корзина, Спам клиент находит сам), ID.
  • UID выдаёт база (триггер): письмо, перенесённое в папку из веба или по IMAP, получает новый, больший UID — клиент его не пропустит. Флаги \Seen, \Flagged, \Answered, \Deleted; \Draft — у писем в «Черновиках».
  • Письмо целиком (BODY[]) — ровно в том виде, в каком оно пришло по SMTP или через APPEND. Для писем, написанных в веб-почте, MIME собирается один раз и сохраняется: размер и байты не меняются между запросами.
  • Каждая команда выполняется на рабочем потоке, не на сетевом. Команды, присланные открытым текстом вместе со STARTTLS, не выполняются — соединение закрывается.
  • Папки — плоские (без вложенных), имена — modified UTF-7, кириллица работает.

Веб-почта

Angular 22 в стиле synthwave-шаблона: стеклянные панели, неон, движущаяся сетка (выключается в настройках и при prefers-reduced-motion), четыре палитры и высокий контраст. Все таблицы — грид @jambotron/aria-grid (сортировка, страницы, выбор строк, массовые действия), пакет — из реестра npm Gitea (frontend/.npmrc).

  • Вход и регистрация. Регистрация (если открыта, MAIL_REGISTRATION) — имя ящика в одном из MAIL_DOMAINS, после неё вход сразу. Служебные имена (postmaster, abuse, admin…) заняты. Пароль — от 8 символов.
  • Папки. Входящие, Черновики, Отправленные, Спам, Корзина и свои (создать, переименовать, удалить — письма уходят в Корзину). Счётчики непрочитанных обновляются раз в 30 с.
  • Список писем. Поиск по теме, отправителю, получателям и тексту; флажок; прочитано/не прочитано; перенос в папку, в Спам и обратно; удаление (в Корзину, из Корзины — насовсем); очистка Корзины и Спама.
  • Письмо. HTML-письма — в изолированном iframe без скриптов, внешние картинки — только по кнопке (отправитель не узнаёт, что письмо открыли). Вложения скачиваются. Заблокировать отправителя — его письма сразу идут в Спам.
  • Написать. Несколько адресов в «Кому» и «Копия», подсказки из контактов, ответ, ответ всем, пересылка. Черновик сохраняется сам каждые 15 с; файлы (до 10 МБ) прикрепляются к черновику и уходят вместе с письмом.
  • Контакты, заблокированные отправители, настройки (имя, пароль, место, оформление).

API: GET /api/config (без входа), /api/me, /api/me/password, /api/folders, /api/messages (PATCH — read/flagged/folderId), /api/drafts, /api/send (draftId — с вложениями черновика), /api/contacts, /api/spam/blacklist.

Настройки (переменные окружения)

Секреты без значений по умолчанию — без них сервер не стартует. Их можно передать переменными или файлами в MAILSERVER_SECRETS_DIR (имя файла = имя переменной).

Переменная Что По умолчанию
DB_PASSWORD пароль PostgreSQL обязательно
JWT_SECRET ключ токенов входа, ≥ 32 байт обязательно
DB_HOST, DB_PORT, DB_NAME, DB_USER база localhost, 5432, mailserver, mail
MAIL_DOMAINS наши домены, через запятую localhost
MAIL_HOSTNAME имя сервера в SMTP localhost
MAIL_ADMIN_EMAIL, MAIL_ADMIN_PASSWORD первый ящик на пустой базе; без пароля — случайный (в MAIL_PASSWORDS_FILE или один раз в логе) admin@<первый домен>
MAIL_ADMINS кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую первый ящик (MAIL_ADMIN_EMAIL или admin@<первый домен>)
HTTPS_PORT веб-клиент и API по HTTPS тем же сертификатом, что SMTP/IMAP (в docker-compose.public.yml — 8443 → 443: https://mail.<домен>); 0 — только HTTP на server.port 0
MAIL_PASSWORDS_FILE файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки — (в образе /app/data/initial-passwords.txt)
MAIL_REGISTRATION открыть регистрацию false
MAIL_CATCH_ALL ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте —
SMTP_PORT порт SMTP 3025
MAIL_TLS TLS для SMTP: STARTTLS и порт SMTPS_PORT true
MAIL_TLS_CERT, MAIL_TLS_KEY свой сертификат: PEM-цепочка и ключ (PKCS#8, RSA PRIVATE KEY или EC PRIVATE KEY); перечитывается при изменении; без них — самоподписанный —
MAIL_TLS_DIR где лежит самоподписанный сертификат ./tls (в образе /app/data/tls)
SMTPS_PORT порт неявного TLS (465); 0 — выключен 0
IMAP_ENABLED, IMAP_PORT, IMAPS_PORT IMAP: включён; порт со STARTTLS; порт неявного TLS (0 — выключен) true, 3143, 0
IMAP_AUTH_WITHOUT_TLS вход в IMAP без TLS — только в закрытой сети false
SMTP_AUTH_WITHOUT_TLS разрешить AUTH без шифрования — только в закрытой сети (docker, ВМ) false
OUTBOUND_MODE off / mx / smarthost off
OUTBOUND_HOST, OUTBOUND_PORT, OUTBOUND_USERNAME, OUTBOUND_PASSWORD, OUTBOUND_TLS smarthost (starttls / ssl / none) —, 587, —, —, starttls
DKIM_DOMAIN, DKIM_SELECTOR, DKIM_KEYS_DIR DKIM localhost, mail, ./dkim-keys
SPF_ENABLED, DMARC_ENABLED проверки входящих true
MAIL_CORS_ORIGINS другие источники для веб-клиента (только разработка) —
LOGIN_GUARD защита от перебора паролей (см. ниже) true
LOGIN_GUARD_MAX_PER_IP, LOGIN_GUARD_MAX_PER_ACCOUNT неверных паролей за окно до блокировки: с одного IP; на один ящик с любых IP 10, 20
LOGIN_GUARD_WINDOW, LOGIN_GUARD_BLOCK, LOGIN_GUARD_MAX_BLOCK окно подсчёта; первая блокировка; потолок (каждая следующая блокировка того же IP вдвое дольше) 15m, 15m, 24h
LOGIN_GUARD_TRUSTED_IPS IP через запятую, которые не блокируются по IP (ящик всё равно считается) —

Защита от перебора паролей

Один счётчик на всё, где проверяется пароль: AUTH в SMTP (25/587/465), LOGIN/AUTHENTICATE в IMAP, вход в веб-почту, смена пароля.

  • По IP: 10 неверных паролей за 15 минут → IP заблокирован на 15 минут, следующая блокировка — 30 минут, потом час… до суток. Сутки тишины — счёт сначала.
  • По ящику: 20 неверных паролей на один ящик с разных IP (распределённый перебор) → ящик закрыт на 15 минут, кроме IP, с которых в него уже входили успешно (30 дней) — так перебором нельзя запереть владельца из его же почтовой программы.
  • Пока блокировка — пароль не проверяется вовсе: SMTP 421 4.7.0 и обрыв, IMAP * BYE + NO [UNAVAILABLE], веб 429 с Retry-After. Неверный пароль отвечается через секунду; после 3 неверных в одном соединении SMTP и IMAP его закрывают.
  • «Нет такого ящика» и «неверный пароль» снаружи не отличить, оба считаются.
  • В логе: login guard: IP … blocked for 15 min after 10 failed logins (block #1).
  • X-Forwarded-For веб-входа принимается только от прокси в частной сети (Caddy Jambotron приходит через docker/WireGuard); с публичного адреса заголовок отбрасывается — подменой IP лимит не обойти.
  • Счётчики в памяти: перезапуск их сбрасывает.

Разработка

docker compose up -d                                               # PostgreSQL на 127.0.0.1:5433
./gradlew :backend:bootRun --args='--spring.profiles.active=dev'   # API :8080, SMTP :3025, admin@mailserver.local / admin123
cd frontend && npm install && npx ng serve                         # http://localhost:4200, /api → :8080
./gradlew :backend:test                                            # тесты (база не нужна)

Профиль dev — только для своего компьютера: известные пароли, открытая регистрация, POST /api/simulate/receive (письмо в ящик без SMTP).

Деплой на Jambotron (тест)

В мастере деплоя выбрать docker-compose.jambotron.yml, домен дать сервису mail, порт 8080.

  • Пароль базы и ключ токенов создаёт контейнер secrets-init при первом запуске (том secrets) — вводить ничего не нужно, в git ничего нет.
  • Ящики admin@mail.test (первый) и trap@mail.test (ловушка) создаются при старте, их пароли печатаются в лог сервиса mail при первом запуске (в Jambotron: проект → Сервисы → mail → Логи) и лежат в файле /app/data/initial-passwords.txt контейнера mail (том data, права 600): docker exec <контейнер mail> cat /app/data/initial-passwords.txt. Прочитать и сменить пароль в настройках.
  • Регистрация открыта (MAIL_REGISTRATION=true): любой может завести ящик в MAIL_DOMAINS. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — MAIL_REGISTRATION=false.
  • Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (OUTBOUND_MODE=off).
  • Домен ящиков — MAIL_DOMAINS (по умолчанию mail.test).

Ловушка почты для dev и staging (75.4)

В docker-compose.jambotron.yml включена ловушка: MAIL_CATCH_ALL=trap@mail.test. Письмо на любой адрес, у которого здесь нет своего ящика, — user@gmail.com, nobody@mail.test — ложится в ящик trap@mail.test; письма между ящиками стенда идут как обычно. Наружу не уходит ничего (OUTBOUND_MODE=off), порт 25 и репутация IP не нужны. Выключить — MAIL_CATCH_ALL= (пусто).

Чтобы staging Jambotron слал письма сюда вместо Resend, его spring.mail смотрит на SMTP этого сервера: хост — адрес, по которому staging видит SMTP 3025, без логина и без TLS. Как: в Jambotron, карточка «Домены» проекта → «Внутренние TCP-порты» → сервис mail, порт 3025 (Jambotron PR #781). Адрес vmIp:vmPort доступен только в сети WireGuard Jambotron, из интернета — нет.

Образ собирается из backend/Dockerfile в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.

Готовый Docker-образ

Как у htop-web: Drone (.drone.yml) собирает backend/Dockerfile. На PR образ только собирается, на master уходит в реестр Gitea:

gitea.jambotron.com/jambotron_gang/reactive-mail-server:latest
gitea.jambotron.com/jambotron_gang/reactive-mail-server:<первые 8 символов коммита master>

Реестр публичный: docker pull без логина.

docker-compose.image.yml — тот же стенд, что docker-compose.jambotron.yml, но сервис mail берётся из образа, на ВМ ничего не собирается (деплой быстрее, Node и Gradle на ВМ не нужны). В мастере деплоя Jambotron выбрать этот файл; домен — сервису mail, порт 8080. По умолчанию latest, он скачивается заново при каждом деплое (pull_policy: always); закрепить сборку — переменная MAIL_IMAGE_TAG=<8 символов коммита>.

docker compose -f docker-compose.image.yml up -d

Настоящая почта в интернете (75.6)

docker-compose.public.yml — приём писем для MAIL_DOMAINS и отправка наружу прямо на MX получателей (OUTBOUND_MODE=mx, порт 25), с подписью DKIM; регистрация закрыта, ловушки нет. Нужна своя ВМ с публичным адресом (Jambotron 60.8: «Публичный адрес», SMTP «да» — порт 25 по умолчанию закрыт). На этом адресе открыты:

Порт Что
25 SMTP: письма от других серверов (STARTTLS)
587 submission для почтовых программ (STARTTLS + вход) — тот же SMTP, что 25
465 SMTPS, неявный TLS
993 IMAPS, неявный TLS
80 только Let's Encrypt (HTTP-01), сервис acme

Сертификат для MAIL_HOSTNAME выпускает и продлевает сервис acme (lego, ключ EC256, продление за 30 дней, проверка дважды в сутки); хук кладёт пару в том tls, сервер перечитывает её сам. mail стартует после первого сертификата. Веб-почта (сервис mail, порт 8080) — через Caddy Jambotron под другим именем, например webmail.<домен>: MAIL_HOSTNAME смотрит на публичный адрес ВМ, а не на Caddy.

Переменные: MAIL_DOMAINS (например jambocheck.com), MAIL_HOSTNAME (mail.jambocheck.com), ACME_EMAIL (по умолчанию postmaster@<домен>).

DNS до первого запуска:

Запись Значение
A mail.<домен> публичный адрес ВМ
PTR публичного адреса mail.<домен> (панель хостера, Reverse DNS)
MX <домен> 10 mail.<домен>
TXT <домен> v=spf1 ip4:<публичный адрес> -all
TXT _dmarc.<домен> v=DMARC1; p=none; rua=mailto:postmaster@<домен>
TXT mail._domainkey.<домен> из лога mail при первом запуске (ключ DKIM лежит в томе data)

Ящик admin@<домен> — пароль в логе mail при первом запуске и в /app/data/initial-passwords.txt.

Не сделано

  • IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по BODY/TEXT без перебора писем.
  • Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
  • Встроенные картинки HTML-писем (cid:) показываются как вложения, а не внутри письма.
  • Защита входа от перебора: SMTP, IMAP, веб (лимит по IP и по ящику, см. «Защита от перебора паролей»).
  • Защита регистрации от массового заведения ящиков (лимит, капча) — пока регистрация на публичной почте закрыта.
  • Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
  • Настоящая почта (75.6): docker-compose.public.yml готов, первый запуск — mail.jambocheck.com на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5).

Лицензия

MIT