# 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. Адрес рассылки не может совпадать с ящиком. **Служебное API для Jambotron** (`/api/service/**`, заголовок `Authorization: Bearer `; токен генерирует `secrets-init` в томе секретов — файл `SERVICE_TOKEN`, без токена API выключено): `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 ._domainkey.`. - **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `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-шаблона](frontend/src/assets/synthwave-design-showcase.html): стеклянные панели, неон, движущаяся сетка (выключается в настройках и при `prefers-reduced-motion`), четыре палитры и высокий контраст. Все таблицы — грид [`@jambotron/aria-grid`](https://gitea.jambotron.com/jambotron_gang/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 лимит не обойти. - Счётчики в памяти: перезапуск их сбрасывает. ## Разработка ```bash 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 символов коммита>`. ```bash 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:`) показываются как вложения, а не внутри письма. - [x] Защита входа от перебора: SMTP, IMAP, веб (лимит по IP и по ящику, см. «Защита от перебора паролей»). - [ ] Защита регистрации от массового заведения ящиков (лимит, капча) — пока регистрация на публичной почте закрыта. - [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя). - [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5). ## Лицензия MIT