Files
reactive-mail-server/README.md
Liosha84 152e93d997 Пароли новых ящиков — ещё и в лог: видно в Jambotron без консоли
После передеплоя (новая база) владелец видит пароли admin@ и trap@ в
проект → Сервисы → mail → Логи; файл /app/data/initial-passwords.txt остаётся.

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:34:48 +01:00

129 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reactive Mail Server
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend.
## Как устроено
```
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` и потом переиспользуется.
- **Не открытый релей.**
- Письмо на свой домен принимается от кого угодно.
- Письмо на чужой домен — только после `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-шаблона](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_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; без них — самоподписанный | — |
| `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` | другие источники для веб-клиента (только разработка) | — |
## Разработка
```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.
## Не сделано
- [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный.
- [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем.
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча).
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
- [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6).
## Лицензия
MIT