Reviewed-on: #26
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 <SERVICE_TOKEN>; токен генерирует 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 <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