Compare commits

...
Author SHA1 Message Date
Liosha84andClaude Opus 5.5 714c62f285 Ящики: минимальная длина пароля 8 (как при регистрации и смене пароля), было 10
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:51:47 +01:00
Liosha84 e37487d509 Merge pull request 'Веб-почта: MAILBOXES на две колонки; левое меню вровень с панелью' (#23) from fix/layout-leftovers into master
continuous-integration/drone/push Build is passing
Reviewed-on: #23
2026-11-07 03:41:44 +01:00
Liosha84andClaude Opus 5.5 312e65fc33 Веб-почта: левое меню вровень с панелью — sticky top 96 → 80 px
continuous-integration/drone/pr Build is passing
Без прокрутки меню стоит на ~86 px, а sticky top-24 (96 px) сдвигал его ниже панели.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84andClaude Opus 5.5 5c08d7e361 Веб-почта: левое меню — верхний отступ как у панели посередине (md: 24 px)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84andClaude Opus 5.5 ccd31c503e Веб-почта: MAILBOXES на две колонки (col-span на элементе компонента — он и есть ячейка сетки)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84 532f6ccab8 Merge pull request 'Веб-почта: колонка Subject ещё на ~1 см уже (520 → 480 px)' (#22) from fix/subject-width-2 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #22
2026-11-07 03:25:56 +01:00
Liosha84andClaude Opus 5.5 9658cdfcd7 Веб-почта: колонка Subject ещё на ~1 см уже (520 → 480 px)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:21:15 +01:00
Liosha84 44c8e17a27 Merge pull request 'Администратор открывает любой ящик (OPEN); @домен при создании ящика; «показать пароль» везде' (#21) from feat/admin-open-mailbox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #21
2026-11-07 03:12:23 +01:00
Liosha84andClaude Opus 5.5 aedda06f2e Веб-почта: при создании ящика — только имя, @домен подставляется (выбор, если доменов несколько); глазок «показать пароль» у всех полей пароля
continuous-integration/drone/pr Build is passing
Регистрация, смена пароля, создание ящика (вход уже был).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:11:42 +01:00
Liosha84andClaude Opus 5.5 91bb8b9b2d Администратор открывает любой ящик: MAILBOXES → OPEN, полоса «вернуться в admin@»
continuous-integration/drone/pr Build is passing
POST /api/admin/mailboxes/{id}/open — сессия этого ящика без его пароля, только для
MAIL_ADMINS; каждое открытие — в лог (кто какой ящик открыл). В веб-почте: кнопка OPEN
у ящика, сверху розовая полоса «Вы в ящике X как администратор — Back to admin@».
Тесты: +1 (всего 75), ng build OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:09:29 +01:00
Liosha84 18ee4820c1 Merge pull request 'Веб-почта: колонка Subject на ~1 см уже — без горизонтальной прокрутки' (#20) from fix/subject-width into master
continuous-integration/drone/push Build is passing
Reviewed-on: #20
2026-11-07 03:03:15 +01:00
Liosha84andClaude Opus 5.5 59e2c11d6a Веб-почта: колонка Subject на ~1 см уже (560 → 520 px) — без горизонтальной прокрутки
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:02:31 +01:00
Liosha84 84ae0b6c7d Merge pull request 'README: начальный пароль admin@ — из файла, не из лога' (#19) from docs/password-file-not-log into master
continuous-integration/drone/push Build is passing
Reviewed-on: #19
2026-11-07 02:50:58 +01:00
Liosha84andClaude Opus 5.5 0d0f4800f0 README: пароль admin@jambocheck.com (по решению владельца)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:50:22 +01:00
Liosha84andClaude Opus 5.5 b009af972a README: где пароли ящиков — SECRETS.md закрытого репозитория Jambotron (этот репозиторий публичный)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:50:08 +01:00
Liosha84andClaude Opus 5.5 100ab741f3 README: начальный пароль admin@ — из файла в томе, не из лога (лог пропадает при передеплое)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:49:14 +01:00
Liosha84 bdca471b97 Merge pull request 'README: «Забыл пароль — где взять» первым разделом' (#18) from docs/forgot-admin-password into master
continuous-integration/drone/push Build is passing
Reviewed-on: #18
2026-11-07 02:41:25 +01:00
Liosha84andClaude Opus 5.5 639ab0a6e9 README: первым разделом — забыл пароль, где взять (admin@ из логов, сброс, MAILBOXES)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:40:52 +01:00
Liosha84 f1d66d4c7d Merge pull request 'Веб-почта по HTTPS: https://mail.jambocheck.com (порт 443)' (#17) from feat/https-443 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #17
2026-11-07 02:22:03 +01:00
Liosha84andClaude Opus 5.5 f9b929ac7a Веб-почта по HTTPS на том же адресе: https://mail.jambocheck.com (порт 443)
continuous-integration/drone/pr Build is passing
HTTPS_PORT — второй Netty-сервер с тем же HttpHandler и тем же сертификатом, что SMTP/IMAP
(Let's Encrypt от acme, перечитывается при продлении). HSTS добавляет Spring Security.
docker-compose.public.yml: 443 → 8443. HTTP 8080 остаётся для прокси внутри сети.
Тесты: HttpsConfigTest, всего 74; живой запуск: /api/config по HTTPS, HSTS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:18:42 +01:00
Liosha84 4ad2e44e9b Merge pull request 'Ящики из веб-почты: создать, новый пароль, выключить (для администратора)' (#16) from feat/admin-mailboxes into master
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2026-11-07 01:49:57 +01:00
Liosha84andClaude Opus 5.5 2149c29a32 Ящики из веб-почты: администратор создаёт no-reply@, monitor@, support@, задаёт новый пароль, выключает
continuous-integration/drone/pr Build is passing
Регистрация остаётся закрытой. Администраторы — MAIL_ADMINS (по умолчанию первый ящик);
роль не в базе, укравший ящик себя админом не сделает. API /api/admin/mailboxes,
сгенерированный пароль — один раз в ответе, в лог не пишется. Настройки → MAILBOXES
(видно только администратору). Тесты: MailboxAdminControllerTest (5), всего 73.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:23:48 +01:00
Liosha84 048d6b3679 Merge pull request 'docker-compose.public.yml: значения по умолчанию для jambocheck.com (деплой без .env)' (#15) from fix/public-compose-defaults into master
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2026-11-06 20:20:21 +01:00
Liosha84andClaude Opus 5.5 2a422f638f docker-compose.public.yml: наши значения по умолчанию (mail.jambocheck.com) — Jambotron деплоит без .env
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:19:32 +01:00
Liosha84 0eb5348e4b Merge pull request 'Защита от перебора паролей: SMTP, IMAP, веб — лимит по IP и по ящику' (#14) from feat/login-guard into master
continuous-integration/drone/push Build is passing
Reviewed-on: #14
2026-11-06 18:44:15 +01:00
Liosha84andClaude Opus 5.5 b19e9682a8 Защита от перебора паролей: SMTP, IMAP, веб — лимит по IP и по ящику
continuous-integration/drone/pr Build is passing
Один LoginGuard на все проверки пароля (SMTP AUTH, IMAP LOGIN/AUTHENTICATE,
вход в веб, смена пароля): 10 неверных с IP за 15 мин → блок 15 мин, дальше
вдвое дольше до суток; 20 неверных на ящик с разных IP → ящик закрыт, кроме
IP, с которых в него уже входили. Во время блока пароль не проверяется
(SMTP 421, IMAP BYE + NO [UNAVAILABLE], веб 429 + Retry-After); неверный
пароль — ответ через 1 с; 3 неверных в соединении — обрыв.

X-Forwarded-For веб-входа — только от прокси в частной сети, иначе
заголовок отбрасывается (подменой IP лимит не обойти).

Попутно: IMAP терял тегированный ответ после "* BYE" (LOGOUT, обрыв после
неудачных входов) — takeUntil теперь после тегированной строки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:32:33 +01:00
Liosha84 8f3ad77b69 Merge pull request 'Настоящая почта: docker-compose.public.yml (MX, DKIM, Let's Encrypt) + перечитка сертификата' (#13) from feat/public-mail into master
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2026-11-06 13:12:10 +01:00
Liosha84andClaude Opus 5.5 711807847e Настоящая почта: docker-compose.public.yml (MX, DKIM, Let's Encrypt через lego), сертификат перечитывается без перезапуска
continuous-integration/drone/pr Build is passing
- ReloadingKeyManager: сертификат и ключ из PEM (PKCS#8, RSA, EC/SEC1 как у lego), раз в минуту
  проверяет файлы и перечитывает их; битый файл оставляет прежний сертификат; поколения по алиасам.
- docker-compose.public.yml: порты 25/587/465/993 на публичном адресе ВМ, OUTBOUND_MODE=mx, DKIM,
  SPF/DMARC, регистрация закрыта, без ловушки; сервис acme (lego v5.5.2, HTTP-01 на :80, EC256,
  продление за 30 дней, хук кладёт пару в том tls для uid 10001).
- README: раздел «Настоящая почта», DNS-записи, перечитка сертификата.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:10:04 +01:00
Liosha84 620aae179b Merge pull request 'Docker-образ в реестре Gitea (как у htop-web) и docker-compose.image.yml' (#12) from feat/docker-image into master
continuous-integration/drone/push Build is passing
2026-11-06 11:53:33 +01:00
Liosha84andClaude Opus 5.5 a37acfaaa7 Docker-образ в реестре Gitea (Drone, как у htop-web) и docker-compose.image.yml на нём
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:58:44 +01:00
Liosha84 87c6e5325d Merge pull request 'Пароли новых ящиков — ещё и в лог (видно в Jambotron без консоли)' (#11) from feat/passwords-in-log into master
Reviewed-on: #11
2026-11-05 22:55:42 +01:00
37 changed files with 2391 additions and 50 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.idea
.vscode
**/node_modules
**/build
**/dist
**/.gradle
**/.angular
*.log
+47
View File
@@ -0,0 +1,47 @@
# Docker image of reactive-mail-server — like htop-web: Drone builds backend/Dockerfile,
# master pushes it to the Gitea container registry of jambotron.com.
# pull request -> build only (image is not pushed)
# push master -> gitea.jambotron.com/jambotron_gang/reactive-mail-server:latest and :<commit 8>
# Drone secrets of this repo: REGISTRY_USERNAME, REGISTRY_PASSWORD (Gitea user with write:package in jambotron_gang)
kind: pipeline
type: docker
name: build-test
trigger:
event:
- pull_request
steps:
- name: build-test
image: plugins/docker
settings:
dockerfile: backend/Dockerfile
context: .
repo: gitea.jambotron.com/jambotron_gang/reactive-mail-server
dry_run: true
---
kind: pipeline
type: docker
name: build-and-push
trigger:
event:
- push
branch:
- master
steps:
- name: build-and-push
image: plugins/docker
settings:
dockerfile: backend/Dockerfile
context: .
registry: gitea.jambotron.com
repo: gitea.jambotron.com/jambotron_gang/reactive-mail-server
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
username:
from_secret: REGISTRY_USERNAME
password:
from_secret: REGISTRY_PASSWORD
+86 -6
View File
@@ -2,7 +2,23 @@
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC. Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend. Назначение — наша настоящая почта **https://mail.jambocheck.com** (prod Jambotron, системная ВМ, адрес `103.109.235.154`) и тестовый проект на Jambotron.
## Забыл пароль — где взять
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**.
**Администратор:** `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`.
## Как устроено ## Как устроено
@@ -13,7 +29,7 @@
``` ```
- **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке. - **Приём (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` и потом переиспользуется. - **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`. - Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`.
@@ -67,12 +83,14 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` | | `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` | | `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` | | `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_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
| `MAIL_REGISTRATION` | открыть регистрацию | `false` | | `MAIL_REGISTRATION` | открыть регистрацию | `false` |
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — | | `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
| `SMTP_PORT` | порт SMTP | `3025` | | `SMTP_PORT` | порт SMTP | `3025` |
| `MAIL_TLS` | TLS для SMTP: `STARTTLS` и порт `SMTPS_PORT` | `true` | | `MAIL_TLS` | TLS для SMTP: `STARTTLS` и порт `SMTPS_PORT` | `true` |
| `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ PKCS#8; без них — самоподписанный | — | | `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ (PKCS#8, `RSA PRIVATE KEY` или `EC PRIVATE KEY`); перечитывается при изменении; без них — самоподписанный | — |
| `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) | | `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) |
| `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` | | `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` |
| `IMAP_ENABLED`, `IMAP_PORT`, `IMAPS_PORT` | IMAP: включён; порт со STARTTLS; порт неявного TLS (`0` — выключен) | `true`, `3143`, `0` | | `IMAP_ENABLED`, `IMAP_PORT`, `IMAPS_PORT` | IMAP: включён; порт со STARTTLS; порт неявного TLS (`0` — выключен) | `true`, `3143`, `0` |
@@ -83,6 +101,22 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
| `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` | | `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` |
| `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` | | `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` |
| `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — | | `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 лимит не обойти.
- Счётчики в памяти: перезапуск их сбрасывает.
## Разработка ## Разработка
@@ -113,15 +147,61 @@ cd frontend && npm install && npx ng serve # http://loca
Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root. Образ собирается из `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`.
## Не сделано ## Не сделано
- [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный.
- [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем. - [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем.
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. - [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча). - [x] Защита входа от перебора: SMTP, IMAP, веб (лимит по IP и по ящику, см. «Защита от перебора паролей»).
- [ ] Защита регистрации от массового заведения ящиков (лимит, капча) — пока регистрация на публичной почте закрыта.
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя). - [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
- [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6). - [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5).
## Лицензия ## Лицензия
@@ -0,0 +1,81 @@
package com.mailserver.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import java.time.Duration;
/**
* The web client and API over HTTPS on the mail host itself (mailserver.https.port, in public compose 8443 →
* published 443): https://mail.jambocheck.com opens the web mail, no second name through Jambotron's Caddy.
* Same certificate as SMTP/IMAP (Let's Encrypt from the acme service, reloaded when renewed). The plain HTTP port
* (8080) stays for a reverse proxy inside the network (staging: domain in Jambotron).
*/
@Configuration
public class HttpsConfig {
@Bean
@ConditionalOnExpression("${mailserver.https.port:0} > 0 and ${mailserver.tls.enabled:true}")
public HttpsServer httpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls,
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.https.port}") int port) {
return new HttpsServer(handler, tls, host, port);
}
public static class HttpsServer implements SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(HttpsServer.class);
private final HttpHandler handler;
private final MailServerConfig.SmtpTlsContext tls;
private final String host;
private final int port;
private volatile DisposableServer server;
HttpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls, String host, int port) {
this.handler = handler;
this.tls = tls;
this.host = host;
this.port = port;
}
@Override
public void start() {
server = HttpServer.create()
.host(host)
.port(port)
.secure(spec -> spec.sslContext(tls.context()))
// HSTS: Spring Security adds it for HTTPS requests by itself
.handle(new ReactorHttpHandlerAdapter(handler))
.bindNow(Duration.ofSeconds(30));
log.info("HTTPS (web client + API) listening on {}:{}", host, server.port());
}
@Override
public void stop() {
DisposableServer current = server;
if (current != null) {
current.disposeNow(Duration.ofSeconds(10));
server = null;
}
}
@Override
public boolean isRunning() {
return server != null;
}
public int port() {
return server == null ? -1 : server.port();
}
}
}
@@ -1,5 +1,6 @@
package com.mailserver.config; package com.mailserver.config;
import com.mailserver.security.LoginGuard;
import com.mailserver.imap.DbImapStore; import com.mailserver.imap.DbImapStore;
import com.mailserver.imap.ImapServer; import com.mailserver.imap.ImapServer;
import com.mailserver.imap.ImapStore; import com.mailserver.imap.ImapStore;
@@ -32,7 +33,7 @@ public class ImapConfig {
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true)
public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard,
@Value("${mailserver.imap.host:0.0.0.0}") String host, @Value("${mailserver.imap.host:0.0.0.0}") String host,
@Value("${mailserver.imap.port:3143}") int port, @Value("${mailserver.imap.port:3143}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
@@ -41,12 +42,12 @@ public class ImapConfig {
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout, @Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) { @Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false, return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false,
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store); (int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard);
} }
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("'${mailserver.imap.enabled:true}' == 'true' and ${mailserver.imaps.port:0} > 0 and ${mailserver.tls.enabled:true}") @ConditionalOnExpression("'${mailserver.imap.enabled:true}' == 'true' and ${mailserver.imaps.port:0} > 0 and ${mailserver.tls.enabled:true}")
public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard,
@Value("${mailserver.imap.host:0.0.0.0}") String host, @Value("${mailserver.imap.host:0.0.0.0}") String host,
@Value("${mailserver.imaps.port}") int port, @Value("${mailserver.imaps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
@@ -54,6 +55,6 @@ public class ImapConfig {
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout, @Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) { @Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true, return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true,
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store); (int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard);
} }
} }
@@ -0,0 +1,74 @@
package com.mailserver.config;
import com.mailserver.security.LoginGuard;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.server.adapter.ForwardedHeaderTransformer;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/** Brute-force protection (one guard for SMTP, IMAP and the web) and whose X-Forwarded-* headers to believe. */
@Configuration
public class LoginGuardConfig {
@Bean
public LoginGuard loginGuard(@Value("${mailserver.login-guard.enabled:true}") boolean enabled,
@Value("${mailserver.login-guard.max-per-ip:10}") int maxPerIp,
@Value("${mailserver.login-guard.max-per-account:20}") int maxPerAccount,
@Value("${mailserver.login-guard.window:15m}") Duration window,
@Value("${mailserver.login-guard.block:15m}") Duration block,
@Value("${mailserver.login-guard.max-block:24h}") Duration maxBlock,
@Value("${mailserver.login-guard.known-ip-ttl:30d}") Duration knownIpTtl,
@Value("${mailserver.login-guard.trusted-ips:}") String trustedIps,
@Value("${mailserver.login-guard.failure-delay:1s}") Duration failureDelay) {
Set<String> trusted = Arrays.stream(trustedIps.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toUnmodifiableSet());
return new LoginGuard(new LoginGuard.Settings(enabled, maxPerIp, maxPerAccount, window, block, maxBlock,
knownIpTtl, trusted, failureDelay));
}
/**
* Replaces Boot's transformer (server.forward-headers-strategy=framework): X-Forwarded-* / Forwarded are believed
* only from a proxy in a private network (Jambotron's Caddy comes over the docker network / WireGuard). A request
* straight from the internet (the VM's public address) has them removed — otherwise anyone could put a new
* X-Forwarded-For into every login attempt and never hit the per-IP limit.
*/
@Bean(name = "forwardedHeaderTransformer")
public ForwardedHeaderTransformer forwardedHeaderTransformer() {
return new TrustedProxyHeaderTransformer();
}
static final class TrustedProxyHeaderTransformer extends ForwardedHeaderTransformer {
private final ForwardedHeaderTransformer stripOnly = new ForwardedHeaderTransformer();
TrustedProxyHeaderTransformer() {
stripOnly.setRemoveOnly(true);
}
@Override
public ServerHttpRequest apply(ServerHttpRequest request) {
return privatePeer(request.getRemoteAddress()) ? super.apply(request) : stripOnly.apply(request);
}
}
/** Loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7. */
static boolean privatePeer(InetSocketAddress peer) {
if (peer == null || peer.getAddress() == null) {
return false;
}
InetAddress a = peer.getAddress();
if (a.isLoopbackAddress() || a.isSiteLocalAddress() || a.isLinkLocalAddress()) {
return true;
}
byte[] b = a.getAddress();
return b.length == 16 && (b[0] & 0xfe) == 0xfc;
}
}
@@ -0,0 +1,36 @@
package com.mailserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first
* mailbox — {@code mailserver.admin.email}, or admin@&lt;first domain&gt;. No role in the database: the list lives in
* the configuration, so a stolen mailbox can not make itself an administrator.
*/
@Component
public class MailAdmins {
private final Set<String> admins;
public MailAdmins(@Value("${mailserver.admins:}") String admins,
@Value("${mailserver.admin.email:}") String firstMailbox,
MailDomains domains) {
String list = !admins.isBlank() ? admins
: !firstMailbox.isBlank() ? firstMailbox
: "admin@" + domains.domains().iterator().next();
this.admins = Arrays.stream(list.split(","))
.map(a -> a.trim().toLowerCase(Locale.ROOT))
.filter(a -> !a.isEmpty())
.collect(Collectors.toUnmodifiableSet());
}
public boolean isAdmin(String email) {
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
}
}
@@ -1,5 +1,6 @@
package com.mailserver.config; package com.mailserver.config;
import com.mailserver.security.LoginGuard;
import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpServer; import com.mailserver.smtp.SmtpServer;
@@ -39,7 +40,7 @@ public class MailServerConfig {
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls, public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard,
@Value("${mailserver.smtp.host:0.0.0.0}") String host, @Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtp.port:3025}") int port, @Value("${mailserver.smtp.port:3025}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
@@ -47,20 +48,20 @@ public class MailServerConfig {
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls, @Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout, return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout,
tls.context(), false), router); tls.context(), false), router, guard);
} }
/** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */ /** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}") @ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}")
public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls, public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard,
@Value("${mailserver.smtp.host:0.0.0.0}") String host, @Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtps.port}") int port, @Value("${mailserver.smtps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes, @Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout, return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout,
tls.context(), true), router); tls.context(), true), router, guard);
} }
/** /**
@@ -1,5 +1,6 @@
package com.mailserver.controller; package com.mailserver.controller;
import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains; import com.mailserver.config.MailDomains;
import com.mailserver.domain.User; import com.mailserver.domain.User;
import com.mailserver.dto.LoginRequest; import com.mailserver.dto.LoginRequest;
@@ -10,11 +11,16 @@ import com.mailserver.dto.ProfileUpdateRequest;
import com.mailserver.dto.RegisterRequest; import com.mailserver.dto.RegisterRequest;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import com.mailserver.security.LoginGuard;
import com.mailserver.util.JwtUtils; import com.mailserver.util.JwtUtils;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -25,6 +31,8 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -34,6 +42,10 @@ import java.util.Set;
@RequestMapping("/api") @RequestMapping("/api")
public class AuthenticationController { public class AuthenticationController {
private static final Logger log = LoggerFactory.getLogger(AuthenticationController.class);
/** Marker for "no such mailbox or wrong password": both look the same from outside. */
private static final User NO_USER = new User();
/** Service addresses nobody can register (RFC 2142 and ours). */ /** Service addresses nobody can register (RFC 2142 and ours). */
static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster", static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster",
"root", "admin", "noreply", "no-reply", "security", "support"); "root", "admin", "noreply", "no-reply", "security", "support");
@@ -43,9 +55,14 @@ public class AuthenticationController {
private final JwtUtils jwtUtils; private final JwtUtils jwtUtils;
private final MailDomains domains; private final MailDomains domains;
private final boolean registrationEnabled; private final boolean registrationEnabled;
private final LoginGuard guard;
private final MailAdmins admins;
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) { @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
LoginGuard guard, MailAdmins admins) {
this.guard = guard;
this.admins = admins;
this.userService = userService; this.userService = userService;
this.mailService = mailService; this.mailService = mailService;
this.jwtUtils = jwtUtils; this.jwtUtils = jwtUtils;
@@ -60,23 +77,34 @@ public class AuthenticationController {
} }
@PostMapping("/login") @PostMapping("/login")
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono) { public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono, ServerHttpRequest http) {
return requestMono.flatMap(request -> String ip = clientIp(http);
userService.findByEmail(request.getEmail().trim().toLowerCase(Locale.ROOT)) return requestMono.flatMap(request -> {
.switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials"))) String email = request.getEmail().trim().toLowerCase(Locale.ROOT);
Duration wait = guard.retryAfter(ip, email);
if (!wait.isZero()) {
log.warn("WEB {}: login as {} refused, blocked for {}", ip, email, LoginGuard.human(wait));
return Mono.just(tooMany(wait));
}
return userService.findByEmail(email)
.map(user -> userService.checkPassword(user, request.getPassword()) ? user : NO_USER)
.onErrorResume(IllegalArgumentException.class, e -> Mono.just(NO_USER))
.defaultIfEmpty(NO_USER)
.flatMap(user -> { .flatMap(user -> {
if (user == NO_USER) {
guard.failure(ip, email);
log.warn("WEB {}: authentication failed for {}", ip, email);
Mono<ResponseEntity<?>> answer = Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
Duration delay = guard.failureDelay();
return delay.isZero() ? answer : answer.delayElement(delay);
}
guard.success(ip, email);
if (!user.isActive()) { if (!user.isActive()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated"))); return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
} }
if (!userService.checkPassword(user, request.getPassword())) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
}
return Mono.just(ResponseEntity.ok(token(user))); return Mono.just(ResponseEntity.ok(token(user)));
}) });
.onErrorResume(IllegalArgumentException.class, e -> });
Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")))
)
);
} }
/** New mailbox in one of our domains; logs in right away (the answer carries the token). */ /** New mailbox in one of our domains; logs in right away (the answer carries the token). */
@@ -116,16 +144,46 @@ public class AuthenticationController {
} }
@PutMapping("/me/password") @PutMapping("/me/password")
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body) { public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body,
ServerHttpRequest http) {
String ip = clientIp(http);
return user(auth).zipWith(body) return user(auth).zipWith(body)
.flatMap(pair -> userService.changePassword(pair.getT1(), pair.getT2().currentPassword(), pair.getT2().newPassword())) .flatMap(pair -> {
User user = pair.getT1();
// Someone with a stolen session must not guess the current password either
Duration wait = guard.retryAfter(ip, user.getEmail());
if (!wait.isZero()) {
return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Too many failed attempts, try again in " + LoginGuard.human(wait)));
}
if (!userService.checkPassword(user, pair.getT2().currentPassword())) {
guard.failure(ip, user.getEmail());
}
return userService.changePassword(user, pair.getT2().currentPassword(), pair.getT2().newPassword());
})
.thenReturn(Map.of("message", "Password changed")); .thenReturn(Map.of("message", "Password changed"));
} }
private static ResponseEntity<?> tooMany(Duration wait) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(Math.max(1, (wait.toMillis() + 999) / 1000)))
.body(Map.of("error", "Too many failed logins, try again in " + LoginGuard.human(wait)));
}
/** Client address; behind Caddy it comes from X-Forwarded-For (server.forward-headers-strategy=framework). */
static String clientIp(ServerHttpRequest http) {
InetSocketAddress address = http.getRemoteAddress();
if (address == null) {
return "unknown";
}
return address.getAddress() != null ? address.getAddress().getHostAddress() : address.getHostString();
}
private Mono<ProfileResponse> profile(User user) { private Mono<ProfileResponse> profile(User user) {
return mailService.usedBytes(user.getId()) return mailService.usedBytes(user.getId())
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used, .map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt())); user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt(),
admins.isAdmin(user.getEmail())));
} }
private Mono<User> user(Mono<Authentication> auth) { private Mono<User> user(Mono<Authentication> auth) {
@@ -0,0 +1,184 @@
package com.mailserver.controller;
import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.Comparator;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@,
* support@ — registration stays closed), new password, switch off. A generated password is returned once, in the
* answer — it is not logged.
*/
@RestController
@RequestMapping("/api/admin/mailboxes")
public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?");
private static final int MIN_PASSWORD = 8;
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin,
long usedBytes, long quotaBytes, Instant createdAt) {
}
/** @param password empty — the server generates one and returns it once */
public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName,
@Size(max = 200) String password) {
}
public record PasswordRequest(@Size(max = 200) String password) {
}
public record ActiveRequest(boolean active) {
}
/** @param password the generated password, or null when the administrator chose it */
public record Created(MailboxView mailbox, String password) {
}
private final UserService users;
private final MailService mail;
private final MailDomains domains;
private final MailAdmins admins;
private final JwtUtils jwt;
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt) {
this.jwt = jwt;
this.users = users;
this.mail = mail;
this.domains = domains;
this.admins = admins;
}
@GetMapping
public Flux<MailboxView> list(Mono<Authentication> auth) {
return admin(auth).thenMany(users.all())
.concatMap(this::view)
.sort(Comparator.comparing(MailboxView::email));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
return admin(auth).then(body).flatMap(request -> {
String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT);
int at = email.lastIndexOf('@');
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
return Mono.error(bad("Not a mailbox address: " + email));
}
if (!domains.isLocal(email)) {
return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList())));
}
String chosen = request.password() == null ? "" : request.password();
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
}
String password = chosen.isEmpty() ? randomPassword() : chosen;
return users.register(email, password, request.displayName())
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists"))
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email))
.flatMap(this::view)
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
});
}
/** New password: the given one, or a generated one returned once. */
@PostMapping("/{id}/password")
public Mono<Created> password(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<PasswordRequest> body) {
return admin(auth).then(body.defaultIfEmpty(new PasswordRequest(null))).flatMap(request -> {
String chosen = request.password() == null ? "" : request.password();
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
}
String password = chosen.isEmpty() ? randomPassword() : chosen;
return mailbox(id)
.flatMap(user -> users.setPassword(user, password))
.doOnNext(user -> log.info("Password of {} set by administrator", user.getEmail()))
.flatMap(this::view)
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
});
}
/**
* Administrator opens a mailbox without its password (Settings → MAILBOXES → Open): a session of that mailbox.
* Every opening is logged — who opened which mailbox.
*/
@PostMapping("/{id}/open")
public Mono<LoginResponse> open(Mono<Authentication> auth, @PathVariable Long id) {
return admin(auth).flatMap(me -> mailbox(id).map(user -> {
log.warn("Administrator {} opened mailbox {}", me, user.getEmail());
LoginResponse response = new LoginResponse();
response.setToken(jwt.generateToken(user.getEmail()));
response.setEmail(user.getEmail());
return response;
}));
}
/** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */
@PutMapping("/{id}/active")
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
if (!request.active() && user.getEmail().equalsIgnoreCase(me)) {
return Mono.error(bad("You can not switch off your own mailbox"));
}
log.info("Mailbox {} {} by administrator", user.getEmail(), request.active() ? "switched on" : "switched off");
return users.setActive(user, request.active());
}))).flatMap(this::view);
}
private Mono<String> admin(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.map(Authentication::getName)
.filter(admins::isAdmin)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "Only for mail administrators")));
}
private Mono<User> mailbox(Long id) {
return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found")));
}
private Mono<MailboxView> view(User user) {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L)
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt()));
}
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
static String randomPassword() {
byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -2,5 +2,7 @@ package com.mailserver.dto;
import java.time.Instant; import java.time.Instant;
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt) { /** @param admin may manage mailboxes ({@link com.mailserver.config.MailAdmins}) */
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt,
boolean admin) {
} }
@@ -1,5 +1,6 @@
package com.mailserver.imap; package com.mailserver.imap;
import com.mailserver.security.LoginGuard;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
@@ -67,11 +68,17 @@ public class ImapServer {
private final Settings settings; private final Settings settings;
private final ImapStore store; private final ImapStore store;
private final LoginGuard guard;
private DisposableServer server; private DisposableServer server;
public ImapServer(Settings settings, ImapStore store) { public ImapServer(Settings settings, ImapStore store) {
this(settings, store, LoginGuard.disabled());
}
public ImapServer(Settings settings, ImapStore store, LoginGuard guard) {
this.settings = settings; this.settings = settings;
this.store = store; this.store = store;
this.guard = guard;
} }
public void start() { public void start() {
@@ -98,7 +105,7 @@ public class ImapServer {
}); });
ImapSession session = new ImapSession( ImapSession session = new ImapSession(
new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()), new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()),
store, remoteIp[0], settings.implicitTls()); store, guard, remoteIp[0], settings.implicitTls());
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Sinks.Empty<Void>[] idleDone = new Sinks.Empty[1]; Sinks.Empty<Void>[] idleDone = new Sinks.Empty[1];
@@ -135,7 +142,9 @@ public class ImapServer {
log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString()); log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just(ImapSession.line("* BYE " + e.getMessage())); return Mono.just(ImapSession.line("* BYE " + e.getMessage()));
}) })
.takeUntil(response -> session.closing); // after the tagged line of the command that closes (LOGOUT, too many failed logins), not after
// its first untagged "* BYE" — the tagged answer must still reach the client
.takeUntil(response -> session.closing && !untagged(response));
return Flux.concat(Mono.just(session.greeting()), responses) return Flux.concat(Mono.just(session.greeting()), responses)
.concatMap(response -> response == ImapSession.STARTTLS_READY .concatMap(response -> response == ImapSession.STARTTLS_READY
@@ -164,6 +173,10 @@ public class ImapServer {
return new String(bytes, StandardCharsets.US_ASCII).trim().toUpperCase(Locale.ROOT).equals("DONE"); return new String(bytes, StandardCharsets.US_ASCII).trim().toUpperCase(Locale.ROOT).equals("DONE");
} }
private static boolean untagged(byte[] response) {
return response.length > 1 && response[0] == '*' && response[1] == ' ';
}
private static Flux<byte[]> blocking(java.util.concurrent.Callable<List<byte[]>> work) { private static Flux<byte[]> blocking(java.util.concurrent.Callable<List<byte[]>> work) {
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list); return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list);
} }
@@ -1,5 +1,6 @@
package com.mailserver.imap; package com.mailserver.imap;
import com.mailserver.security.LoginGuard;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import jakarta.mail.Session; import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
@@ -9,6 +10,7 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneOffset; import java.time.ZoneOffset;
@@ -39,6 +41,8 @@ final class ImapSession {
private static final Logger log = LoggerFactory.getLogger(ImapSession.class); private static final Logger log = LoggerFactory.getLogger(ImapSession.class);
private static final int MAX_BAD_COMMANDS = 30; private static final int MAX_BAD_COMMANDS = 30;
/** Wrong passwords in one connection before it is closed. */
private static final int MAX_AUTH_FAILURES = 3;
private static final int SOURCE_CACHE = 16; private static final int SOURCE_CACHE = 16;
private static final DateTimeFormatter INTERNAL_DATE = private static final DateTimeFormatter INTERNAL_DATE =
DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss Z", Locale.ENGLISH); DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss Z", Locale.ENGLISH);
@@ -53,6 +57,7 @@ final class ImapSession {
private final Settings settings; private final Settings settings;
private final ImapStore store; private final ImapStore store;
private final LoginGuard guard;
final String remoteIp; final String remoteIp;
boolean tls; boolean tls;
@@ -78,8 +83,13 @@ final class ImapSession {
volatile String idleTag; volatile String idleTag;
ImapSession(Settings settings, ImapStore store, String remoteIp, boolean tls) { ImapSession(Settings settings, ImapStore store, String remoteIp, boolean tls) {
this(settings, store, LoginGuard.disabled(), remoteIp, tls);
}
ImapSession(Settings settings, ImapStore store, LoginGuard guard, String remoteIp, boolean tls) {
this.settings = settings; this.settings = settings;
this.store = store; this.store = store;
this.guard = guard;
this.remoteIp = remoteIp; this.remoteIp = remoteIp;
this.tls = tls; this.tls = tls;
} }
@@ -254,7 +264,7 @@ final class ImapSession {
out.add(line(tag + " NO [PRIVACYREQUIRED] Use STARTTLS first")); out.add(line(tag + " NO [PRIVACYREQUIRED] Use STARTTLS first"));
return; return;
} }
loggedIn(tag, store.authenticate(user, password), user, out); checkPassword(tag, user, password, out);
} }
private void authenticate(String tag, ImapReader in, List<byte[]> out) { private void authenticate(String tag, ImapReader in, List<byte[]> out) {
@@ -292,13 +302,44 @@ final class ImapSession {
bad(tag, "Bad PLAIN response", out); bad(tag, "Bad PLAIN response", out);
return; return;
} }
loggedIn(tag, store.authenticate(fields[1], fields[2]), fields[1], out); checkPassword(tag, fields[1], fields[2], out);
}
/** Through the login guard: a blocked IP/mailbox gets no answer about the password and the connection closes. */
private void checkPassword(String tag, String user, String password, List<byte[]> out) {
Duration wait = guard.retryAfter(remoteIp, user);
if (!wait.isZero()) {
log.warn("IMAP {}: login as {} refused, blocked for {}", remoteIp, user, LoginGuard.human(wait));
out.add(line("* BYE Too many failed logins, try again in " + LoginGuard.human(wait)));
out.add(line(tag + " NO [UNAVAILABLE] Too many failed logins"));
closing = true;
return;
}
ImapStore.Account result = store.authenticate(user, password);
if (result == null) {
guard.failure(remoteIp, user);
pause(guard.failureDelay());
} else {
guard.success(remoteIp, user);
}
loggedIn(tag, result, user, out);
}
private static void pause(Duration delay) {
if (delay.isZero()) {
return;
}
try {
Thread.sleep(delay.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
} }
private void loggedIn(String tag, ImapStore.Account result, String user, List<byte[]> out) { private void loggedIn(String tag, ImapStore.Account result, String user, List<byte[]> out) {
if (result == null) { if (result == null) {
log.warn("IMAP {}: authentication failed for {}", remoteIp, user); log.warn("IMAP {}: authentication failed for {}", remoteIp, user);
if (++badCommands > 5) { if (++badCommands >= MAX_AUTH_FAILURES) {
out.add(line("* BYE Too many failed logins")); out.add(line("* BYE Too many failed logins"));
closing = true; closing = true;
} }
@@ -0,0 +1,257 @@
package com.mailserver.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Brute-force protection for every place that checks a password: SMTP AUTH, IMAP LOGIN/AUTHENTICATE, web login,
* password change. One instance is shared by all of them, so failures on SMTP and IMAP add up.
* <ul>
* <li><b>By IP:</b> {@code maxPerIp} failures within {@code window} → the IP is blocked for {@code block};
* each next block of the same IP is twice as long, up to {@code maxBlock}.</li>
* <li><b>By mailbox:</b> {@code maxPerAccount} failures within {@code window} from any IPs (a distributed attack on
* one mailbox) → the mailbox is blocked for {@code block} — but not for IPs that logged in to it successfully
* before ({@code knownIpTtl}), so an attacker cannot lock the owner's own mail program out.</li>
* </ul>
* While blocked the password is not checked at all (no answer to a guess) and the caller gets {@link #retryAfter}.
* State is in memory: a restart forgets it, which is fine for minutes-long blocks.
*/
public class LoginGuard {
private static final Logger log = LoggerFactory.getLogger(LoginGuard.class);
/** Upper bound of tracked keys of each kind, so a flood of random usernames/IPs cannot eat the memory. */
static final int MAX_KEYS = 100_000;
private static final int MAX_KNOWN_IPS_PER_ACCOUNT = 20;
public record Settings(boolean enabled, int maxPerIp, int maxPerAccount, Duration window, Duration block,
Duration maxBlock, Duration knownIpTtl, Set<String> trustedIps, Duration failureDelay) {
public static Settings defaults() {
return new Settings(true, 10, 20, Duration.ofMinutes(15), Duration.ofMinutes(15), Duration.ofHours(24),
Duration.ofDays(30), Set.of(), Duration.ofSeconds(1));
}
}
/** Failures in the window, current block and how many times the key was blocked in a row. */
private static final class Counter {
final Deque<Instant> failures = new ArrayDeque<>();
volatile Instant blockedUntil = Instant.EPOCH;
int strikes;
Instant lastSeen = Instant.EPOCH;
}
private final Settings settings;
private final Clock clock;
private final Map<String, Counter> byIp = new ConcurrentHashMap<>();
private final Map<String, Counter> byAccount = new ConcurrentHashMap<>();
/** account → IPs that logged in to it successfully, with the time of the last success. */
private final Map<String, Map<String, Instant>> knownIps = new ConcurrentHashMap<>();
public LoginGuard(Settings settings) {
this(settings, Clock.systemUTC());
}
LoginGuard(Settings settings, Clock clock) {
this.settings = settings;
this.clock = clock;
}
/** Guard that never blocks (tests, closed networks). */
public static LoginGuard disabled() {
Settings d = Settings.defaults();
return new LoginGuard(new Settings(false, d.maxPerIp(), d.maxPerAccount(), d.window(), d.block(), d.maxBlock(),
d.knownIpTtl(), Set.of(), Duration.ZERO));
}
/** Pause before answering "wrong password": slows down guessing inside one connection. */
public Duration failureDelay() {
return settings.enabled() ? settings.failureDelay() : Duration.ZERO;
}
/**
* Zero when the attempt may go on and the password may be checked; otherwise how long the caller has to wait.
*/
public Duration retryAfter(String ip, String account) {
if (!settings.enabled()) {
return Duration.ZERO;
}
Instant now = clock.instant();
Duration wait = Duration.ZERO;
if (!trusted(ip)) {
wait = remaining(byIp.get(ip), now);
}
String acc = normalize(account);
if (acc != null && !known(acc, ip, now)) {
Duration accountWait = remaining(byAccount.get(acc), now);
if (accountWait.compareTo(wait) > 0) {
wait = accountWait;
}
}
return wait;
}
public boolean blocked(String ip, String account) {
return !retryAfter(ip, account).isZero();
}
/** A wrong password (or an unknown mailbox). */
public void failure(String ip, String account) {
if (!settings.enabled()) {
return;
}
Instant now = clock.instant();
if (!trusted(ip)) {
count(byIp, ip, settings.maxPerIp(), now, "IP " + ip);
}
String acc = normalize(account);
if (acc != null) {
count(byAccount, acc, settings.maxPerAccount(), now, "mailbox " + acc);
}
}
/** A right password: the IP becomes known for the mailbox, failures of this IP are forgiven. */
public void success(String ip, String account) {
if (!settings.enabled()) {
return;
}
Instant now = clock.instant();
String acc = normalize(account);
if (acc != null) {
if (knownIps.size() < MAX_KEYS || knownIps.containsKey(acc)) {
knownIps.compute(acc, (k, ips) -> {
Map<String, Instant> map = ips != null ? ips : new LinkedHashMap<>();
synchronized (map) {
map.remove(ip);
map.put(ip, now);
while (map.size() > MAX_KNOWN_IPS_PER_ACCOUNT) {
map.remove(map.keySet().iterator().next());
}
}
return map;
});
}
}
byIp.computeIfPresent(ip, (k, c) -> {
c.failures.clear();
return c.blockedUntil.isAfter(now) ? c : null;
});
}
/** Drops what expired; runs every minute. */
@Scheduled(fixedDelay = 60_000)
public void sweep() {
Instant now = clock.instant();
Instant idle = now.minus(settings.maxBlock()).minus(settings.window());
byIp.entrySet().removeIf(e -> stale(e.getValue(), now, idle));
byAccount.entrySet().removeIf(e -> stale(e.getValue(), now, idle));
Instant knownSince = now.minus(settings.knownIpTtl());
knownIps.entrySet().removeIf(e -> {
synchronized (e.getValue()) {
e.getValue().values().removeIf(t -> t.isBefore(knownSince));
return e.getValue().isEmpty();
}
});
}
int trackedIps() {
return byIp.size();
}
int trackedAccounts() {
return byAccount.size();
}
// ---------------------------------------------------------------------------------------------
private void count(Map<String, Counter> map, String key, int max, Instant now, String what) {
if (map.size() >= MAX_KEYS && !map.containsKey(key)) {
sweep();
if (map.size() >= MAX_KEYS) {
log.warn("login guard: {} keys tracked, {} not counted", map.size(), what);
return;
}
}
map.compute(key, (k, existing) -> {
Counter c = existing != null ? existing : new Counter();
c.lastSeen = now;
Instant from = now.minus(settings.window());
while (!c.failures.isEmpty() && c.failures.peekFirst().isBefore(from)) {
c.failures.pollFirst();
}
if (c.blockedUntil.isAfter(now)) {
return c; // already blocked: attempts are refused before the password check anyway
}
// A long quiet time after the last block forgives the strikes
if (c.strikes > 0 && c.blockedUntil.plus(settings.maxBlock()).isBefore(now)) {
c.strikes = 0;
}
c.failures.addLast(now);
if (c.failures.size() >= max) {
Duration block = settings.block().multipliedBy(1L << Math.min(c.strikes, 20));
if (block.compareTo(settings.maxBlock()) > 0) {
block = settings.maxBlock();
}
c.strikes++;
c.blockedUntil = now.plus(block);
c.failures.clear();
log.warn("login guard: {} blocked for {} after {} failed logins (block #{})",
what, human(block), max, c.strikes);
}
return c;
});
}
private static Duration remaining(Counter c, Instant now) {
if (c == null) {
return Duration.ZERO;
}
Instant until = c.blockedUntil;
return until.isAfter(now) ? Duration.between(now, until) : Duration.ZERO;
}
private boolean known(String account, String ip, Instant now) {
Map<String, Instant> ips = knownIps.get(account);
if (ips == null) {
return false;
}
synchronized (ips) {
Instant at = ips.get(ip);
return at != null && at.plus(settings.knownIpTtl()).isAfter(now);
}
}
private boolean trusted(String ip) {
return settings.trustedIps().contains(ip);
}
private static boolean stale(Counter c, Instant now, Instant idle) {
return !c.blockedUntil.isAfter(now) && c.lastSeen.isBefore(idle);
}
private static String normalize(String account) {
if (account == null) {
return null;
}
String acc = account.trim().toLowerCase(Locale.ROOT);
return acc.isEmpty() ? null : acc;
}
/** "15 min" / "2 h" for messages to the user. */
public static String human(Duration d) {
long minutes = Math.max(1, (d.toSeconds() + 59) / 60);
return minutes < 120 ? minutes + " min" : (minutes + 59) / 60 + " h";
}
}
@@ -74,6 +74,25 @@ public class UserService {
return userRepository.save(user).then(); return userRepository.save(user).then();
} }
public Flux<User> all() {
return userRepository.findAll();
}
public Mono<User> byId(Long id) {
return userRepository.findById(id);
}
/** Administrator sets a new password (Settings → Mailboxes): no current password needed. */
public Mono<User> setPassword(User user, String newPassword) {
user.setPassword(passwordEncoder.encode(newPassword));
return userRepository.save(user);
}
public Mono<User> setActive(User user, boolean active) {
user.setActive(active);
return userRepository.save(user);
}
public Mono<User> updateDisplayName(User user, String displayName) { public Mono<User> updateDisplayName(User user, String displayName) {
user.setDisplayName(displayName.trim()); user.setDisplayName(displayName.trim());
return userRepository.save(user); return userRepository.save(user);
@@ -0,0 +1,194 @@
package com.mailserver.smtp;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.X509ExtendedKeyManager;
import java.io.InputStream;
import java.io.Reader;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Server certificate and key from PEM files, read again when the files change — a Let's Encrypt certificate
* renewed on disk (lego, certbot) is picked up without a restart, for STARTTLS and implicit TLS alike: the JDK
* asks the key manager on every handshake. The files are checked at most once per {@code interval}; a file
* that cannot be read (a renewal half written) keeps the previous certificate and is tried again next time.
* <p>
* Key: PKCS#8 ({@code PRIVATE KEY}) or the traditional {@code RSA PRIVATE KEY} / {@code EC PRIVATE KEY} that
* lego and certbot write. Each loaded pair gets its own alias, so a handshake that asks for the chain and then
* for the key never mixes two generations.
*/
public final class ReloadingKeyManager extends X509ExtendedKeyManager {
private static final Logger log = LoggerFactory.getLogger(ReloadingKeyManager.class);
private static final String ALIAS_PREFIX = "mail-";
record Material(String alias, X509Certificate[] chain, PrivateKey key, FileTime certTime, FileTime keyTime) {
}
private final Path certFile;
private final Path keyFile;
private final Duration interval;
private final Clock clock;
/** The current pair and the one before it (a handshake may still hold its alias) */
private final Map<String, Material> byAlias = new ConcurrentHashMap<>();
private volatile Material current;
private volatile long checkedAt;
private int generation;
public ReloadingKeyManager(Path certFile, Path keyFile, Duration interval, Clock clock) throws Exception {
this.certFile = certFile;
this.keyFile = keyFile;
this.interval = interval;
this.clock = clock;
install(load());
this.checkedAt = clock.millis();
}
/** The pair to use now; reads the files again when the interval has passed and they changed */
Material material() {
long now = clock.millis();
if (now - checkedAt >= interval.toMillis()) {
synchronized (this) {
if (now - checkedAt >= interval.toMillis()) {
checkedAt = now;
reloadIfChanged();
}
}
}
return current;
}
private void reloadIfChanged() {
Material old = current;
try {
if (Files.getLastModifiedTime(certFile).equals(old.certTime())
&& Files.getLastModifiedTime(keyFile).equals(old.keyTime())) {
return;
}
Material fresh = load();
install(fresh);
log.info("TLS: certificate {} reloaded, valid until {}", certFile, fresh.chain()[0].getNotAfter());
} catch (Exception e) {
log.warn("TLS: cannot read {} / {} ({}), keeping the certificate valid until {}",
certFile, keyFile, e.toString(), old.chain()[0].getNotAfter());
}
}
private synchronized void install(Material material) {
Material previous = current;
byAlias.keySet().removeIf(alias -> previous == null || !alias.equals(previous.alias()));
byAlias.put(material.alias(), material);
current = material;
}
private Material load() throws Exception {
FileTime certTime = Files.getLastModifiedTime(certFile);
FileTime keyTime = Files.getLastModifiedTime(keyFile);
X509Certificate[] chain;
try (InputStream in = Files.newInputStream(certFile)) {
chain = CertificateFactory.getInstance("X.509").generateCertificates(in).stream()
.map(X509Certificate.class::cast).toArray(X509Certificate[]::new);
}
if (chain.length == 0) {
throw new IllegalStateException("no certificate in " + certFile);
}
PrivateKey key = readKey(keyFile);
if (!chain[0].getPublicKey().getAlgorithm().equals(key.getAlgorithm())) {
throw new IllegalStateException("the key in " + keyFile + " is " + key.getAlgorithm()
+ ", the certificate is " + chain[0].getPublicKey().getAlgorithm());
}
String alias;
synchronized (this) {
alias = ALIAS_PREFIX + (++generation);
}
return new Material(alias, chain, key, certTime, keyTime);
}
static PrivateKey readKey(Path file) throws Exception {
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.US_ASCII);
PEMParser parser = new PEMParser(reader)) {
Object object;
while ((object = parser.readObject()) != null) {
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
if (object instanceof PrivateKeyInfo info) {
return converter.getPrivateKey(info);
}
if (object instanceof PEMKeyPair pair) {
return converter.getPrivateKey(pair.getPrivateKeyInfo()); // SEC1 may come without the public part
}
// EC PARAMETERS before EC PRIVATE KEY (openssl ecparam -genkey) — skip
}
}
throw new IllegalStateException("no private key in " + file);
}
private String serverAlias(String keyType) {
Material material = material();
String algorithm = material.key().getAlgorithm();
boolean fits = keyType == null || keyType.equalsIgnoreCase(algorithm)
|| ("RSA".equals(algorithm) && keyType.equalsIgnoreCase("RSASSA-PSS"))
|| ("EC".equals(algorithm) && keyType.toUpperCase().startsWith("EC"));
return fits ? material.alias() : null;
}
@Override
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
return serverAlias(keyType);
}
@Override
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
return serverAlias(keyType);
}
@Override
public String[] getServerAliases(String keyType, Principal[] issuers) {
String alias = serverAlias(keyType);
return alias == null ? null : new String[]{alias};
}
@Override
public X509Certificate[] getCertificateChain(String alias) {
Material material = alias == null ? null : byAlias.get(alias);
return material == null ? null : material.chain().clone();
}
@Override
public PrivateKey getPrivateKey(String alias) {
Material material = alias == null ? null : byAlias.get(alias);
return material == null ? null : material.key();
}
@Override
public String[] getClientAliases(String keyType, Principal[] issuers) {
return null;
}
@Override
public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
return null;
}
@Override
public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) {
return null;
}
}
@@ -1,5 +1,6 @@
package com.mailserver.smtp; package com.mailserver.smtp;
import com.mailserver.security.LoginGuard;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil; import io.netty.buffer.ByteBufUtil;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
@@ -54,6 +55,8 @@ public class SmtpServer {
static final int MAX_LINE = 64 * 1024; static final int MAX_LINE = 64 * 1024;
static final int MAX_RECIPIENTS = 100; static final int MAX_RECIPIENTS = 100;
private static final int MAX_BAD_COMMANDS = 20; private static final int MAX_BAD_COMMANDS = 20;
/** Wrong passwords in one connection before it is closed. */
private static final int MAX_AUTH_FAILURES = 3;
// Compared by identity: TLS starts right after exactly this reply is written // Compared by identity: TLS starts right after exactly this reply is written
private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n"); private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n");
@@ -84,11 +87,17 @@ public class SmtpServer {
private final Settings settings; private final Settings settings;
private final SmtpBackend backend; private final SmtpBackend backend;
private final LoginGuard guard;
private DisposableServer server; private DisposableServer server;
public SmtpServer(Settings settings, SmtpBackend backend) { public SmtpServer(Settings settings, SmtpBackend backend) {
this(settings, backend, LoginGuard.disabled());
}
public SmtpServer(Settings settings, SmtpBackend backend, LoginGuard guard) {
this.settings = settings; this.settings = settings;
this.backend = backend; this.backend = backend;
this.guard = guard;
} }
public void start() { public void start() {
@@ -183,6 +192,7 @@ public class SmtpServer {
final ByteArrayOutputStream data = new ByteArrayOutputStream(); final ByteArrayOutputStream data = new ByteArrayOutputStream();
boolean dataTooBig; boolean dataTooBig;
int badCommands; int badCommands;
int authFailures;
volatile boolean closing; volatile boolean closing;
Session(String remoteIp, boolean tls) { Session(String remoteIp, boolean tls) {
@@ -356,16 +366,32 @@ public class SmtpServer {
private Mono<String> checkPassword(String user, String password) { private Mono<String> checkPassword(String user, String password) {
String username = user.trim().toLowerCase(Locale.ROOT); String username = user.trim().toLowerCase(Locale.ROOT);
Duration wait = guard.retryAfter(remoteIp, username);
if (!wait.isZero()) {
// Blocked: the password is not even checked, the connection is closed
log.warn("SMTP {}: login as {} refused, blocked for {}", remoteIp, username, LoginGuard.human(wait));
closing = true;
return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait));
}
return blocking(() -> backend.authenticate(username, password)) return blocking(() -> backend.authenticate(username, password))
.map(ok -> { .flatMap(ok -> {
if (ok) { if (ok) {
guard.success(remoteIp, username);
authenticatedUser = username; authenticatedUser = username;
log.info("SMTP {}: authenticated as {}", remoteIp, username); log.info("SMTP {}: authenticated as {}", remoteIp, username);
return "235 2.7.0 Authentication successful\r\n"; return Mono.just("235 2.7.0 Authentication successful\r\n");
} }
guard.failure(remoteIp, username);
log.warn("SMTP {}: authentication failed for {}", remoteIp, username); log.warn("SMTP {}: authentication failed for {}", remoteIp, username);
badCommands++; badCommands++;
return "535 5.7.8 Authentication credentials invalid\r\n"; if (++authFailures >= MAX_AUTH_FAILURES) {
closing = true;
}
String answer = closing
? "535 5.7.8 Authentication credentials invalid, closing connection\r\n"
: "535 5.7.8 Authentication credentials invalid\r\n";
Duration delay = guard.failureDelay();
return delay.isZero() ? Mono.just(answer) : Mono.just(answer).delayElement(delay);
}); });
} }
@@ -22,6 +22,7 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Base64; import java.util.Base64;
@@ -30,7 +31,8 @@ import java.util.Date;
/** /**
* Certificate for SMTP TLS (STARTTLS and implicit TLS): * Certificate for SMTP TLS (STARTTLS and implicit TLS):
* <ul> * <ul>
* <li>{@code cert} and {@code key} given (PEM: certificate chain and PKCS#8 key, e.g. from Let's Encrypt) — used as is;</li> * <li>{@code cert} and {@code key} given (PEM: certificate chain and the key — PKCS#8, RSA or EC, e.g. from Let's Encrypt) —
* used as is and read again when the files change ({@link ReloadingKeyManager}, checked once a minute);</li>
* <li>otherwise a self-signed certificate for the server hostname, created once in {@code dir} and reused — * <li>otherwise a self-signed certificate for the server hostname, created once in {@code dir} and reused —
* enough for encryption between our own services; other servers deliver over it too (MX delivery does not * enough for encryption between our own services; other servers deliver over it too (MX delivery does not
* check certificates, RFC 7435 opportunistic TLS).</li> * check certificates, RFC 7435 opportunistic TLS).</li>
@@ -41,6 +43,7 @@ public final class SmtpTls {
private static final Logger log = LoggerFactory.getLogger(SmtpTls.class); private static final Logger log = LoggerFactory.getLogger(SmtpTls.class);
static final String CERT_FILE = "smtp-cert.pem"; static final String CERT_FILE = "smtp-cert.pem";
static final String KEY_FILE = "smtp-key.pem"; static final String KEY_FILE = "smtp-key.pem";
static final Duration RELOAD_CHECK = Duration.ofMinutes(1);
private SmtpTls() { private SmtpTls() {
} }
@@ -67,7 +70,8 @@ public final class SmtpTls {
log.info("SMTP TLS: self-signed certificate {}", cert); log.info("SMTP TLS: self-signed certificate {}", cert);
} }
} }
return SslContextBuilder.forServer(cert.toFile(), key.toFile()) // Read again when the files change (a renewed Let's Encrypt certificate) — no restart needed
return SslContextBuilder.forServer(new ReloadingKeyManager(cert, key, RELOAD_CHECK, Clock.systemUTC()))
.protocols("TLSv1.3", "TLSv1.2") .protocols("TLSv1.3", "TLSv1.2")
.build(); .build();
} }
@@ -27,6 +27,8 @@ mailserver.catch-all=${MAIL_CATCH_ALL:}
mailserver.registration.enabled=${MAIL_REGISTRATION:false} mailserver.registration.enabled=${MAIL_REGISTRATION:false}
mailserver.admin.email=${MAIL_ADMIN_EMAIL:} mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:} mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
# Who manages mailboxes (Settings → Mailboxes), comma separated; empty — the first mailbox (admin.email or admin@<first domain>)
mailserver.admins=${MAIL_ADMINS:}
# Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once # Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once
mailserver.passwords-file=${MAIL_PASSWORDS_FILE:} mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
mailserver.cors.origins=${MAIL_CORS_ORIGINS:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
@@ -47,6 +49,8 @@ mailserver.tls.enabled=${MAIL_TLS:true}
mailserver.tls.cert-file=${MAIL_TLS_CERT:} mailserver.tls.cert-file=${MAIL_TLS_CERT:}
mailserver.tls.key-file=${MAIL_TLS_KEY:} mailserver.tls.key-file=${MAIL_TLS_KEY:}
mailserver.tls.dir=${MAIL_TLS_DIR:./tls} mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
# Web client + API over HTTPS with the same certificate (public compose: 8443 → 443); 0 — only plain HTTP (server.port)
mailserver.https.port=${HTTPS_PORT:0}
mailserver.smtps.port=${SMTPS_PORT:0} mailserver.smtps.port=${SMTPS_PORT:0}
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend) # Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
@@ -83,3 +87,15 @@ management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never management.endpoint.health.show-details=never
logging.level.com.mailserver=INFO logging.level.com.mailserver=INFO
# Brute-force protection for SMTP AUTH, IMAP login, web login and password change (one counter for all of them):
# MAX_PER_IP wrong passwords from one IP within WINDOW → the IP is blocked for BLOCK (each next block twice as long,
# up to MAX_BLOCK); MAX_PER_ACCOUNT wrong passwords to one mailbox from any IPs → the mailbox is blocked for BLOCK,
# except for IPs that logged in to it before. TRUSTED_IPS (comma-separated) are never blocked by IP
mailserver.login-guard.enabled=${LOGIN_GUARD:true}
mailserver.login-guard.max-per-ip=${LOGIN_GUARD_MAX_PER_IP:10}
mailserver.login-guard.max-per-account=${LOGIN_GUARD_MAX_PER_ACCOUNT:20}
mailserver.login-guard.window=${LOGIN_GUARD_WINDOW:15m}
mailserver.login-guard.block=${LOGIN_GUARD_BLOCK:15m}
mailserver.login-guard.max-block=${LOGIN_GUARD_MAX_BLOCK:24h}
mailserver.login-guard.trusted-ips=${LOGIN_GUARD_TRUSTED_IPS:}
@@ -0,0 +1,45 @@
package com.mailserver.config;
import com.mailserver.smtp.SmtpTls;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.HttpHandler;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
/** https://mail.jambocheck.com: the web client over HTTPS with the mail certificate. */
class HttpsConfigTest {
@Test
void servesTheWebAppOverTls(@TempDir Path dir) throws Exception {
MailServerConfig.SmtpTlsContext tls = new MailServerConfig.SmtpTlsContext(SmtpTls.serverContext("", "", dir, "localhost"));
HttpHandler handler = (request, response) -> {
response.setStatusCode(HttpStatus.OK);
return response.writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance
.wrap(("hello " + request.getURI().getScheme()).getBytes(StandardCharsets.UTF_8))));
};
io.netty.handler.ssl.SslContext trustAll = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build();
HttpsConfig.HttpsServer server = new HttpsConfig.HttpsServer(handler, tls, "127.0.0.1", 0);
server.start();
try {
assertThat(server.isRunning()).isTrue();
String body = HttpClient.create()
.secure(spec -> spec.sslContext(trustAll))
.get().uri("https://127.0.0.1:" + server.port() + "/")
.responseContent().aggregate().asString().block();
assertThat(body).isEqualTo("hello https");
} finally {
server.stop();
}
assertThat(server.isRunning()).isFalse();
}
}
@@ -0,0 +1,46 @@
package com.mailserver.config;
import org.junit.jupiter.api.Test;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import java.net.InetSocketAddress;
import static org.assertj.core.api.Assertions.assertThat;
class LoginGuardConfigTest {
private final LoginGuardConfig.TrustedProxyHeaderTransformer transformer = new LoginGuardConfig.TrustedProxyHeaderTransformer();
private ServerHttpRequest from(String peer) {
return MockServerHttpRequest.post("/api/login")
.remoteAddress(new InetSocketAddress(peer, 40000))
.header("X-Forwarded-For", "198.51.100.9")
.header("X-Forwarded-Proto", "https")
.build();
}
@Test
void forwardedForIsBelievedFromAPrivateProxy() {
ServerHttpRequest request = transformer.apply(from("172.18.0.5"));
assertThat(request.getRemoteAddress().getHostString()).isEqualTo("198.51.100.9");
assertThat(request.getURI().getScheme()).isEqualTo("https");
}
@Test
void forwardedForFromTheInternetIsDropped() {
ServerHttpRequest request = transformer.apply(from("203.0.113.7"));
assertThat(request.getRemoteAddress().getAddress().getHostAddress()).isEqualTo("203.0.113.7");
assertThat(request.getHeaders().containsKey("X-Forwarded-For")).isFalse();
}
@Test
void privateRanges() {
for (String ip : new String[]{"127.0.0.1", "10.30.0.13", "172.16.0.1", "192.168.1.1", "fd00::1", "::1", "fe80::1"}) {
assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isTrue();
}
for (String ip : new String[]{"103.109.235.154", "172.32.0.1", "2001:db8::1", "8.8.8.8"}) {
assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isFalse();
}
}
}
@@ -0,0 +1,81 @@
package com.mailserver.controller;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.dto.LoginRequest;
import com.mailserver.security.LoginGuard;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import reactor.core.publisher.Mono;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AuthenticationControllerTest {
private final UserService users = mock(UserService.class);
private final JwtUtils jwt = mock(JwtUtils.class);
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class));
private final User alice = new User();
AuthenticationControllerTest() {
alice.setEmail("alice@x.test");
alice.setPassword("hash");
when(users.findByEmail("alice@x.test")).thenReturn(Mono.just(alice));
when(users.findByEmail("ghost@x.test")).thenReturn(Mono.empty());
when(users.checkPassword(eq(alice), anyString())).thenAnswer(i -> "secret".equals(i.getArgument(1)));
when(jwt.generateToken(any())).thenReturn("token");
}
private ResponseEntity<?> login(String ip, String email, String password) {
LoginRequest request = new LoginRequest();
request.setEmail(email);
request.setPassword(password);
MockServerHttpRequest http = MockServerHttpRequest.post("/api/login")
.remoteAddress(new InetSocketAddress(ip, 40000)).build();
return controller.login(Mono.just(request), http).block();
}
@Test
void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() {
assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
assertThat(login("1.2.3.4", "alice@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
login("1.2.3.4", "alice@x.test", "y");
ResponseEntity<?> blocked = login("1.2.3.4", "alice@x.test", "secret");
assertThat(blocked.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
assertThat(blocked.getHeaders().getFirst("Retry-After")).isEqualTo("900");
assertThat(blocked.getBody()).isEqualTo(Map.of("error", "Too many failed logins, try again in 15 min"));
assertThat(login("5.6.7.8", "alice@x.test", "secret").getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void blockedAttemptDoesNotCheckThePassword() {
for (int i = 0; i < 3; i++) {
login("1.2.3.4", "ghost@x.test", "x");
}
login("1.2.3.4", "alice@x.test", "secret");
verify(users, never()).checkPassword(any(), eq("secret"));
}
}
@@ -0,0 +1,143 @@
package com.mailserver.controller;
import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Settings → Mailboxes: only administrators; mailboxes only in our domains; a generated password comes back once. */
class MailboxAdminControllerTest {
private final UserService users = mock(UserService.class);
private final MailService mail = mock(MailService.class);
private final MailDomains domains = new MailDomains("jambocheck.com", "");
private final MailAdmins admins = new MailAdmins("", "", domains);
private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt);
private final User admin = user(1L, "admin@jambocheck.com");
private final User alice = user(2L, "alice@jambocheck.com");
MailboxAdminControllerTest() {
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
when(users.all()).thenReturn(Flux.just(alice, admin));
when(users.byId(1L)).thenReturn(Mono.just(admin));
when(users.byId(2L)).thenReturn(Mono.just(alice));
when(users.byId(99L)).thenReturn(Mono.empty());
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0))));
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setActive(i.getArgument(1));
return Mono.just(u);
});
}
@Test
void administratorIsTheFirstMailboxByDefault() {
assertThat(admins.isAdmin("ADMIN@jambocheck.com")).isTrue();
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
assertThat(new MailAdmins("ops@jambocheck.com, root@jambocheck.com", "admin@jambocheck.com", domains).isAdmin("admin@jambocheck.com")).isFalse();
}
@Test
void listsMailboxesSortedForAnAdministratorOnly() {
List<MailboxAdminController.MailboxView> list = controller.list(as("admin@jambocheck.com")).collectList().block();
assertThat(list).extracting(MailboxAdminController.MailboxView::email).containsExactly("admin@jambocheck.com", "alice@jambocheck.com");
assertThat(list.get(0).admin()).isTrue();
assertThat(status(() -> controller.list(as("alice@jambocheck.com")).collectList().block())).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void createsReservedNamesWithAGeneratedPasswordShownOnce() {
var created = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", ""))).block();
assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
var chosen = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1"))).block();
assertThat(chosen.password()).isNull();
}
@Test
void refusesOtherDomainsBadAddressesShortPasswordsAndNonAdmins() {
assertThat(status(() -> create("admin@jambocheck.com", "x@gmail.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> create("admin@jambocheck.com", "bad address@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> create("admin@jambocheck.com", "x@jambocheck.com", "short"))).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> create("alice@jambocheck.com", "x@jambocheck.com", ""))).isEqualTo(HttpStatus.FORBIDDEN);
verify(users, never()).register(anyString(), anyString(), any());
when(users.register(eq("alice@jambocheck.com"), anyString(), any())).thenReturn(Mono.error(new IllegalArgumentException("exists")));
assertThat(status(() -> create("admin@jambocheck.com", "alice@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
}
@Test
void newPasswordAndSwitchOffButNotYourself() {
var reset = controller.password(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.PasswordRequest(null))).block();
assertThat(reset.password()).isNotBlank();
verify(users).setPassword(alice, reset.password());
assertThat(controller.active(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block().active()).isFalse();
assertThat(status(() -> controller.active(as("admin@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com")).thenReturn("alice-token");
var session = controller.open(as("admin@jambocheck.com"), 2L).block();
assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com");
assertThat(session.getToken()).isEqualTo("alice-token");
assertThat(status(() -> controller.open(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(status(() -> controller.open(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
}
private MailboxAdminController.Created create(String me, String email, String password) {
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block();
}
private static Mono<Authentication> as(String email) {
return Mono.just(new UsernamePasswordAuthenticationToken(email, null, List.of()));
}
private static HttpStatus status(Runnable call) {
Throwable error = org.assertj.core.api.Assertions.catchThrowable(call::run);
assertThat(error).isInstanceOf(ResponseStatusException.class);
return HttpStatus.valueOf(((ResponseStatusException) error).getStatusCode().value());
}
private static User user(Long id, String email) {
User user = new User();
user.setId(id);
user.setEmail(email);
user.setPassword("hash");
return user;
}
}
@@ -14,6 +14,7 @@ import jakarta.mail.internet.MimeMessage;
import jakarta.mail.search.SubjectTerm; import jakarta.mail.search.SubjectTerm;
import org.eclipse.angus.mail.imap.AppendUID; import org.eclipse.angus.mail.imap.AppendUID;
import org.eclipse.angus.mail.imap.IMAPFolder; import org.eclipse.angus.mail.imap.IMAPFolder;
import com.mailserver.security.LoginGuard;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -184,6 +185,33 @@ class ImapServerTest {
} }
} }
@Test
void wrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() throws Exception {
server.stop();
LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 4, 100, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), java.util.Set.of(), Duration.ZERO));
server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false,
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000,
Duration.ofMinutes(5), Duration.ofMillis(200)), store, guard);
server.start();
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
assertThat(client.send("a LOGIN alice@example.test w1")).startsWith("a NO [AUTHENTICATIONFAILED]");
assertThat(client.send("b LOGIN alice@example.test w2")).startsWith("b NO [AUTHENTICATIONFAILED]");
assertThat(client.untilTagged("c", "c LOGIN alice@example.test w3")).contains("* BYE Too many failed logins");
assertThat(client.read()).isNull();
}
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
assertThat(client.send("a LOGIN alice@example.test w4")).startsWith("a NO [AUTHENTICATIONFAILED]");
assertThat(client.untilTagged("b", "b LOGIN alice@example.test secret"))
.anyMatch(line -> line.startsWith("* BYE Too many failed logins, try again in 15 min"))
.anyMatch(line -> line.startsWith("b NO [UNAVAILABLE]"));
}
}
@Test @Test
void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception { void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception {
try (Client client = new Client(server.port())) { try (Client client = new Client(server.port())) {
@@ -0,0 +1,164 @@
package com.mailserver.security;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class LoginGuardTest {
/** Clock the test moves by hand. */
static final class TestClock extends Clock {
Instant now = Instant.parse("2026-11-06T12:00:00Z");
void plus(Duration d) {
now = now.plus(d);
}
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
}
private final TestClock clock = new TestClock();
private LoginGuard guard(int perIp, int perAccount, String... trusted) {
return new LoginGuard(new LoginGuard.Settings(true, perIp, perAccount, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(trusted), Duration.ZERO), clock);
}
@Test
void ipIsBlockedAfterTheLimitWhateverMailboxItTries() {
LoginGuard guard = guard(3, 100);
for (int i = 0; i < 2; i++) {
guard.failure("1.2.3.4", "user" + i + "@x.test");
}
assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse();
guard.failure("1.2.3.4", "user9@x.test");
assertThat(guard.retryAfter("1.2.3.4", "any@x.test")).isEqualTo(Duration.ofMinutes(15));
assertThat(guard.blocked("5.6.7.8", "any@x.test")).isFalse();
clock.plus(Duration.ofMinutes(15));
assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse();
}
@Test
void failuresOutsideTheWindowDoNotCount() {
LoginGuard guard = guard(3, 100);
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
clock.plus(Duration.ofMinutes(16));
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
}
@Test
void eachNextBlockOfTheSameIpIsTwiceAsLongUpToTheMaximum() {
LoginGuard guard = guard(2, 100);
Duration expected = Duration.ofMinutes(15);
for (int round = 0; round < 10; round++) {
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
Duration wait = guard.retryAfter("1.2.3.4", "b@x.test");
assertThat(wait).isEqualTo(expected);
clock.plus(wait);
expected = expected.multipliedBy(2).compareTo(Duration.ofHours(24)) > 0 ? Duration.ofHours(24) : expected.multipliedBy(2);
}
// a quiet day forgives
clock.plus(Duration.ofHours(25));
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.retryAfter("1.2.3.4", "b@x.test")).isEqualTo(Duration.ofMinutes(15));
}
@Test
void attemptsWhileBlockedDoNotProlongTheBlock() {
LoginGuard guard = guard(2, 100);
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
clock.plus(Duration.ofMinutes(10));
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.retryAfter("1.2.3.4", "a@x.test")).isEqualTo(Duration.ofMinutes(5));
}
@Test
void distributedAttackBlocksTheMailboxButNotItsOwnerFromAKnownIp() {
LoginGuard guard = guard(100, 5);
guard.success("10.0.0.7", "Alice@X.test");
for (int i = 0; i < 5; i++) {
guard.failure("203.0.113." + i, "alice@x.test");
}
assertThat(guard.blocked("198.51.100.1", "alice@x.test")).isTrue();
assertThat(guard.blocked("198.51.100.1", "bob@x.test")).isFalse();
assertThat(guard.blocked("10.0.0.7", "alice@x.test")).isFalse();
}
@Test
void successForgivesTheIpFailuresButNotAnActiveBlock() {
LoginGuard guard = guard(3, 100);
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
guard.success("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isTrue();
guard.success("1.2.3.4", "a@x.test");
assertThat(guard.blocked("1.2.3.4", "other@x.test")).isTrue();
}
@Test
void trustedIpIsNeverBlockedByIp() {
LoginGuard guard = guard(2, 100, "172.18.0.1");
for (int i = 0; i < 10; i++) {
guard.failure("172.18.0.1", "u" + i + "@x.test");
}
assertThat(guard.blocked("172.18.0.1", "new@x.test")).isFalse();
}
@Test
void sweepForgetsOldKeys() {
LoginGuard guard = guard(5, 5);
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.trackedIps()).isEqualTo(1);
assertThat(guard.trackedAccounts()).isEqualTo(1);
clock.plus(Duration.ofHours(25).plusMinutes(16));
guard.sweep();
assertThat(guard.trackedIps()).isZero();
assertThat(guard.trackedAccounts()).isZero();
}
@Test
void disabledGuardNeverBlocks() {
LoginGuard guard = LoginGuard.disabled();
for (int i = 0; i < 100; i++) {
guard.failure("1.2.3.4", "a@x.test");
}
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
assertThat(guard.failureDelay()).isZero();
}
@Test
void humanDuration() {
assertThat(LoginGuard.human(Duration.ofSeconds(30))).isEqualTo("1 min");
assertThat(LoginGuard.human(Duration.ofMinutes(15))).isEqualTo("15 min");
assertThat(LoginGuard.human(Duration.ofHours(4))).isEqualTo("4 h");
}
}
@@ -0,0 +1,149 @@
package com.mailserver.smtp;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.StringWriter;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.*;
class ReloadingKeyManagerTest {
/** A clock moved by hand */
static final class TestClock extends Clock {
Instant now = Instant.parse("2026-11-06T12:00:00Z");
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
}
@Test
void readsTheFilesAgainWhenTheyChange(@TempDir Path dir) throws Exception {
Path cert = dir.resolve("cert.pem");
Path key = dir.resolve("key.pem");
SmtpTls.selfSigned("old.example", cert, key);
TestClock clock = new TestClock();
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), clock);
String first = manager.chooseEngineServerAlias("RSA", null, null);
assertEquals("CN=old.example", subject(manager.getCertificateChain(first)));
assertNull(manager.chooseEngineServerAlias("EC", null, null), "an RSA key is not offered for EC");
SmtpTls.selfSigned("new.example", cert, key);
Files.setLastModifiedTime(cert, FileTime.from(Instant.now().plusSeconds(5)));
Files.setLastModifiedTime(key, FileTime.from(Instant.now().plusSeconds(5)));
assertEquals(first, manager.chooseEngineServerAlias("RSA", null, null), "not checked before the interval");
clock.now = clock.now.plus(Duration.ofMinutes(1));
String second = manager.chooseEngineServerAlias("RSA", null, null);
assertNotEquals(first, second);
assertEquals("CN=new.example", subject(manager.getCertificateChain(second)));
assertNotNull(manager.getPrivateKey(second));
assertNotNull(manager.getCertificateChain(first), "a handshake that holds the previous alias still finishes");
}
@Test
void keepsTheOldCertificateWhenTheNewFileIsBroken(@TempDir Path dir) throws Exception {
Path cert = dir.resolve("cert.pem");
Path key = dir.resolve("key.pem");
SmtpTls.selfSigned("old.example", cert, key);
TestClock clock = new TestClock();
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), clock);
Files.writeString(cert, "-----BEGIN CERTIFICATE-----\nhalf");
Files.setLastModifiedTime(cert, FileTime.from(Instant.now().plusSeconds(5)));
clock.now = clock.now.plus(Duration.ofMinutes(2));
String alias = manager.chooseEngineServerAlias("RSA", null, null);
assertEquals("CN=old.example", subject(manager.getCertificateChain(alias)));
}
@Test
void readsTraditionalEcKeysAsLegoWritesThem(@TempDir Path dir) throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(256);
KeyPair pair = generator.generateKeyPair();
X500Name name = new X500Name("CN=mail.example");
X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(
new JcaX509v3CertificateBuilder(name, BigInteger.ONE, new Date(System.currentTimeMillis() - 86_400_000L),
new Date(System.currentTimeMillis() + 86_400_000L), name, pair.getPublic())
.build(new JcaContentSignerBuilder("SHA256withECDSA").build(pair.getPrivate())));
Path cert = dir.resolve("cert.pem");
Path key = dir.resolve("key.pem");
Files.writeString(cert, pem(certificate));
Files.writeString(key, sec1(pair)); // -----BEGIN EC PRIVATE KEY----- (SEC1 with the curve), not PKCS#8
assertTrue(Files.readString(key).contains("EC PRIVATE KEY"));
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), new TestClock());
String alias = manager.chooseEngineServerAlias("EC", null, null);
assertNotNull(alias);
assertEquals("EC", manager.getPrivateKey(alias).getAlgorithm());
assertNull(manager.chooseEngineServerAlias("RSA", null, null));
}
@Test
void refusesAKeyThatDoesNotMatchTheCertificateType(@TempDir Path dir) throws Exception {
Path cert = dir.resolve("cert.pem");
Path key = dir.resolve("key.pem");
SmtpTls.selfSigned("rsa.example", cert, key);
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(256);
Files.writeString(key, sec1(generator.generateKeyPair()));
assertThrows(IllegalStateException.class,
() -> new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), new TestClock()));
}
/** As lego and openssl ecparam -genkey write it: SEC1 with the named curve secp256r1 */
private static String sec1(KeyPair pair) throws Exception {
java.security.interfaces.ECPrivateKey key = (java.security.interfaces.ECPrivateKey) pair.getPrivate();
org.bouncycastle.asn1.sec.ECPrivateKey sec1 = new org.bouncycastle.asn1.sec.ECPrivateKey(256, key.getS(),
new org.bouncycastle.asn1.x9.X962Parameters(org.bouncycastle.asn1.sec.SECObjectIdentifiers.secp256r1));
StringWriter out = new StringWriter();
try (org.bouncycastle.util.io.pem.PemWriter writer = new org.bouncycastle.util.io.pem.PemWriter(out)) {
writer.writeObject(new org.bouncycastle.util.io.pem.PemObject("EC PRIVATE KEY", sec1.getEncoded()));
}
return out.toString();
}
private static String subject(X509Certificate[] chain) {
return chain[0].getSubjectX500Principal().getName();
}
private static String pem(Object object) throws Exception {
StringWriter out = new StringWriter();
try (JcaPEMWriter writer = new JcaPEMWriter(out)) {
writer.writeObject(object);
}
return out.toString();
}
}
@@ -5,6 +5,7 @@ import jakarta.mail.Session;
import jakarta.mail.Transport; import jakarta.mail.Transport;
import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import com.mailserver.security.LoginGuard;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -158,6 +159,34 @@ class SmtpServerTest {
assertThat(backend.messages.get(0)).contains("with ESMTPA;"); assertThat(backend.messages.get(0)).contains("with ESMTPA;");
} }
@Test
void threeWrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() throws Exception {
server.stop();
LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 4, 100, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), java.util.Set.of(), Duration.ZERO));
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, true,
Duration.ofSeconds(30)), backend, guard);
server.start();
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
client.readReplyLines();
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w1"))).startsWith("535");
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w2"))).startsWith("535");
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w3"))).startsWith("535").contains("closing");
assertThat(client.read()).isNull();
}
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
client.readReplyLines();
assertThat(client.send("AUTH PLAIN " + b64("\0bob@example.test\0w4"))).startsWith("535");
// 4th failure from 127.0.0.1: blocked — even the right password is not checked
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("421 4.7.0");
assertThat(client.read()).isNull();
}
}
@Test @Test
void authLoginWorksToo() throws Exception { void authLoginWorksToo() throws Exception {
try (Client client = new Client(server.port())) { try (Client client = new Client(server.port())) {
+84
View File
@@ -0,0 +1,84 @@
# Same stand as docker-compose.jambotron.yml, but from the ready image — nothing is built on the VM:
# gitea.jambotron.com/jambotron_gang/reactive-mail-server (Drone builds it from master, see .drone.yml).
# Pin a build: MAIL_IMAGE_TAG=<first 8 chars of the master commit>; default latest (pulled again on every deploy).
# Test deploy on Jambotron (v0.2.3 75.3): pick this file in the deploy wizard.
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
# SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off).
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in the "mail" log on the first start
# (Jambotron: project → Services → mail → Logs) and in /app/data/initial-passwords.txt
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
name: reactive-mail
services:
secrets-init:
image: alpine:3.20
restart: "no"
command:
- sh
- -c
- |
umask 077
for name in DB_PASSWORD JWT_SECRET; do
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
done
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
volumes:
- secrets:/secrets
db:
# Debian, not -alpine: on Jambotron staging VMs (Alpine 3.24, kernel 6.18) the Alpine build of postgres
# dies with "Bus error" even on --version; the Debian build works (2026-11-05)
image: postgres:17
restart: unless-stopped
depends_on:
secrets-init:
condition: service_completed_successfully
environment:
POSTGRES_DB: mailserver
POSTGRES_USER: mail
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
volumes:
- pgdata:/var/lib/postgresql/data
- secrets:/run/mail-secrets:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
interval: 10s
timeout: 5s
retries: 10
mem_limit: 256m
mail:
image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
pull_policy: always
restart: unless-stopped
depends_on:
db:
condition: service_healthy
expose:
- "8080" # web client + API → domain in Jambotron
- "3025" # SMTP, inside the network only
- "3143" # IMAP (STARTTLS), inside the network only — open it in Jambotron as an internal TCP port
environment:
DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test}
# mail trap: mail to any address without its own mailbox here (also gmail.com etc.) goes into this mailbox
MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-trap@mail.test}
MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside)
OUTBOUND_MODE: "off"
SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM
SPF_ENABLED: "false"
DMARC_ENABLED: "false"
volumes:
- data:/app/data
- secrets:/run/mail-secrets:ro
mem_limit: 512m
volumes:
secrets:
pgdata:
data:
+159
View File
@@ -0,0 +1,159 @@
# Real mail on the internet (v0.2.3 75.6): receives mail for MAIL_DOMAINS and sends it straight to the recipients'
# MX servers (port 25), DKIM-signed. From the ready image, like docker-compose.image.yml.
#
# Needs a VM of its own with a public address (Jambotron 60.8: «Публичный адрес» of the VM, SMTP «да» — port 25
# is closed by default) — the ports below are published on that address:
# 25 SMTP: mail from other servers (STARTTLS) and from mail programs
# 587 submission: mail programs (STARTTLS + login) — the same SMTP listener as 25
# 465 SMTPS: mail programs, implicit TLS
# 993 IMAPS: mail programs, implicit TLS
# 80 only for Let's Encrypt (HTTP-01) — service "acme"
# 443 web client + API over HTTPS, same Let's Encrypt certificate (acme): https://mail.jambocheck.com
# The web client + API can also go through Jambotron's Caddy (plain port 8080) under ANOTHER name
# (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
#
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
# A mail.<domain> → public address of the VM
# PTR public address → mail.<domain> (hoster's panel: Reverse DNS)
# MX <domain> → 10 mail.<domain>
# TXT <domain> → "v=spf1 ip4:<public address> -all"
# TXT _dmarc.<domain> → "v=DMARC1; p=none; rua=mailto:postmaster@<domain>"
# TXT mail._domainkey.<domain> → printed in the "mail" log on the first start (DKIM key, kept in the data volume)
#
# Mailbox admin@<domain>: its password is in the "mail" log on the first start and in /app/data/initial-passwords.txt.
# Registration is closed: new mailboxes only by the admin. No catch-all.
# Defaults are ours (mail.jambocheck.com / jambocheck.com) — Jambotron deploys without a .env; another domain:
# MAIL_HOSTNAME, MAIL_DOMAINS, ACME_EMAIL in .env next to this file.
name: reactive-mail-public
services:
secrets-init:
image: alpine:3.20
restart: "no"
command:
- sh
- -c
- |
umask 077
for name in DB_PASSWORD JWT_SECRET; do
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
done
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
volumes:
- secrets:/secrets
# Let's Encrypt for MAIL_HOSTNAME: issued on the first start, renewed 30 days before the end (checked twice a day).
# The deploy hook copies the pair to /tls (readable by the "mail" user 10001); the server reads it again within
# a minute — no restart
acme:
image: goacme/lego:v5.5.2
restart: unless-stopped
ports:
- "80:80"
entrypoint:
- sh
- -c
- |
cat > /usr/local/bin/jb-deploy <<'EOF'
#!/bin/sh
set -e
cp "$$LEGO_HOOK_CERT_PATH" /tls/cert.pem.new
cp "$$LEGO_HOOK_CERT_KEY_PATH" /tls/key.pem.new
chown 10001 /tls/cert.pem.new /tls/key.pem.new
chmod 600 /tls/key.pem.new
chmod 644 /tls/cert.pem.new
mv /tls/key.pem.new /tls/key.pem
mv /tls/cert.pem.new /tls/cert.pem
echo "certificate for $$LEGO_HOOK_CERT_DOMAINS copied to /tls"
EOF
chmod 755 /usr/local/bin/jb-deploy
while true; do
/lego run --accept-tos --email "$$ACME_EMAIL" --domains "$$MAIL_HOSTNAME" --key-type EC256 \
--http --http.address :80 --path /var/lib/lego --renew-days 30 --no-random-sleep \
--deploy-hook /usr/local/bin/jb-deploy || echo "lego failed — next try in 1 hour"
[ -s /tls/cert.pem ] && sleep 43200 || sleep 3600
done
environment:
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
ACME_EMAIL: ${ACME_EMAIL:-postmaster@jambocheck.com}
LEGO_LOG_FORMAT: text
volumes:
- lego:/var/lib/lego
- tls:/tls
healthcheck:
test: ["CMD-SHELL", "test -s /tls/cert.pem && test -s /tls/key.pem"]
interval: 15s
timeout: 5s
start_period: 10m
retries: 3
mem_limit: 128m
db:
image: postgres:17
restart: unless-stopped
depends_on:
secrets-init:
condition: service_completed_successfully
environment:
POSTGRES_DB: mailserver
POSTGRES_USER: mail
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
volumes:
- pgdata:/var/lib/postgresql/data
- secrets:/run/mail-secrets:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
interval: 10s
timeout: 5s
retries: 10
mem_limit: 256m
mail:
image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
pull_policy: always
restart: unless-stopped
depends_on:
db:
condition: service_healthy
acme:
condition: service_healthy # waits for the first certificate
ports:
- "25:3025"
- "587:3025"
- "465:3465"
- "993:3993"
- "443:8443" # web client + API over HTTPS: https://mail.jambocheck.com
expose:
- "8080" # plain HTTP, for a reverse proxy inside the network (not needed on the public address)
environment:
DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets
MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
MAIL_TLS_CERT: /tls/cert.pem
MAIL_TLS_KEY: /tls/key.pem
SMTPS_PORT: "3465"
IMAPS_PORT: "3993"
HTTPS_PORT: "8443"
DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
DKIM_SELECTOR: mail
DKIM_KEYS_DIR: /app/data/dkim-keys
MAIL_CATCH_ALL: ""
MAIL_REGISTRATION: "false"
OUTBOUND_MODE: mx
SMTP_AUTH_WITHOUT_TLS: "false"
IMAP_AUTH_WITHOUT_TLS: "false"
SPF_ENABLED: "true"
DMARC_ENABLED: "true"
volumes:
- data:/app/data
- secrets:/run/mail-secrets:ro
- tls:/tls:ro
mem_limit: 512m
volumes:
secrets:
pgdata:
data:
lego:
tls:
+16 -1
View File
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models'; import { Attachment, BlacklistEntry, Contact, Draft, Folder, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models';
/** The mail server REST API (same origin, /api). */ /** The mail server REST API (same origin, /api). */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -17,6 +17,21 @@ export class ApiService {
return this.http.put('/api/me/password', { currentPassword, newPassword }); return this.http.put('/api/me/password', { currentPassword, newPassword });
} }
// mailboxes — administrators only
mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password });
}
newMailboxPassword(id: number): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
}
openMailbox(id: number): Observable<{ token: string; email: string }> {
return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {});
}
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
}
// folders // folders
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); } folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); } createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
+31
View File
@@ -12,6 +12,8 @@ export class AuthService {
private router = inject(Router); private router = inject(Router);
readonly email = signal<string | null>(read('email')); readonly email = signal<string | null>(read('email'));
/** Administrator who opened this mailbox (Settings → MAILBOXES → Open), or null */
readonly openedBy = signal<string | null>(read('adminEmail'));
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null); readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
login(email: string, password: string): Observable<TokenResponse> { login(email: string, password: string): Observable<TokenResponse> {
@@ -24,6 +26,32 @@ export class AuthService {
token(): string | null { return read('token'); } token(): string | null { return read('token'); }
/** Administrator opens another mailbox: its session, the admin session kept to come back. */
openAs(response: TokenResponse): void {
const adminToken = read('token');
const adminEmail = read('email');
try {
if (adminToken && adminEmail && !read('adminToken')) {
localStorage.setItem('adminToken', adminToken);
localStorage.setItem('adminEmail', adminEmail);
}
} catch { /* storage blocked */ }
this.store(response);
this.openedBy.set(read('adminEmail'));
}
/** Back to the administrator's own mailbox. */
backToAdmin(): void {
const token = read('adminToken');
const email = read('adminEmail');
try {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ }
this.openedBy.set(null);
if (token && email) { this.store({ token, email }); } else { this.clear(); }
}
logout(): void { logout(): void {
this.clear(); this.clear();
this.router.navigate(['/login']); this.router.navigate(['/login']);
@@ -33,8 +61,11 @@ export class AuthService {
try { try {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('email'); localStorage.removeItem('email');
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ } } catch { /* storage blocked */ }
this.email.set(null); this.email.set(null);
this.openedBy.set(null);
} }
private store(response: TokenResponse): void { private store(response: TokenResponse): void {
+19
View File
@@ -53,6 +53,25 @@ export interface Profile {
usedBytes: number; usedBytes: number;
quotaBytes: number; quotaBytes: number;
createdAt: string; createdAt: string;
admin?: boolean; // may manage mailboxes (Settings → Mailboxes)
}
/** MailboxAdminController.MailboxView */
export interface Mailbox {
id: number;
email: string;
displayName: string;
active: boolean;
admin: boolean;
usedBytes: number;
quotaBytes: number;
createdAt: string;
}
/** password — generated by the server, shown once; null when the administrator chose it */
export interface MailboxCreated {
mailbox: Mailbox;
password: string | null;
} }
export interface ServerConfig { export interface ServerConfig {
+11 -2
View File
@@ -49,13 +49,21 @@ const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/;
</div> </div>
<div> <div>
<label class="synth-label" for="password">Password (8+ characters)</label> <label class="synth-label" for="password">Password (8+ characters)</label>
<input id="password" name="password" type="password" class="synth-input" [ngModel]="password()" <div class="relative"><input id="password" name="password" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password()"
(ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password"> (ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div>
</div> </div>
<div> <div>
<label class="synth-label" for="password2">Password again</label> <label class="synth-label" for="password2">Password again</label>
<input id="password2" name="password2" type="password" class="synth-input" [ngModel]="password2()" <div class="relative"><input id="password2" name="password2" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password2()"
(ngModelChange)="password2.set($event)" required autocomplete="new-password"> (ngModelChange)="password2.set($event)" required autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div>
</div> </div>
@if (error()) { @if (error()) {
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert"> <p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
@@ -79,6 +87,7 @@ export class RegisterPage {
readonly loaded = signal(false); readonly loaded = signal(false);
readonly enabled = signal(false); readonly enabled = signal(false);
readonly show = signal(false);
readonly domains = signal<string[]>([]); readonly domains = signal<string[]>([]);
readonly domain = signal(''); readonly domain = signal('');
readonly local = signal(''); readonly local = signal('');
+1 -1
View File
@@ -67,7 +67,7 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</ng-template> </ng-template>
<ng-template jtGridCell="subject" let-row> <ng-template jtGridCell="subject" let-row>
<a [routerLink]="link(row)" class="block max-w-[560px] group"> <a [routerLink]="link(row)" class="block max-w-[480px] group">
<span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read"> <span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read">
@if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> } @if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> }
{{ row.subject || '(no subject)' }} {{ row.subject || '(no subject)' }}
@@ -0,0 +1,164 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { Mailbox, errorText, formatBytes } from '../../core/models';
import { ToastService } from '../../ui/toast.service';
import { AuthService } from '../../core/auth.service';
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
@Component({
selector: 'app-mailboxes-section',
imports: [FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
// ячейка сетки настроек — сам элемент компонента: на две колонки
host: { class: 'block xl:col-span-2' },
template: `
<section class="glass-panel static p-5 md:p-6" aria-labelledby="mailboxes-title">
<h2 id="mailboxes-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
</h2>
<form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4">
<div><label class="synth-label" for="mb-email">Address</label>
<div class="flex items-stretch gap-2">
<input id="mb-email" name="email" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
placeholder="no-reply" autocomplete="off" required>
<span class="flex items-center text-cyan-300 font-mono">&#64;</span>
@if (domains().length > 1) {
<select name="domain" class="synth-input !w-auto" [ngModel]="domain()" (ngModelChange)="domain.set($event)" aria-label="Domain">
@for (d of domains(); track d) { <option [value]="d">{{ d }}</option> }
</select>
} @else {
<span class="flex items-center px-3 rounded-[10px] bg-slate-900/70 border border-cyan-500/30 font-mono text-sm text-slate-200 whitespace-nowrap">{{ domain() }}</span>
}
</div></div>
<div><label class="synth-label" for="mb-name">Name</label>
<input id="mb-name" name="name" class="synth-input" [(ngModel)]="name" maxlength="100" autocomplete="off"></div>
<div><label class="synth-label" for="mb-password">Password (empty — generate)</label>
<div class="relative"><input id="mb-password" name="password" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="password" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button>
</form>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
@if (secret(); as s) {
<div class="p-3 mb-4 rounded-lg border border-pink-500/40 bg-slate-900/60" role="status">
<div class="text-xs text-slate-400 mb-1">Password of <span class="font-mono text-cyan-300">{{ s.email }}</span> — shown once, copy it now</div>
<div class="flex items-center gap-2">
<code class="font-mono text-pink-300 break-all select-all">{{ s.password }}</code>
<button type="button" class="ghost-btn !py-1" (click)="copy(s.password)" aria-label="Copy password"><mat-icon>content_copy</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="secret.set(null)" aria-label="Hide password"><mat-icon>close</mat-icon></button>
</div>
</div>
}
<ul class="divide-y divide-cyan-500/10">
@for (m of mailboxes(); track m.id) {
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
@if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="open(m)" [attr.aria-label]="'Open ' + m.email" title="Open mailbox">
<mat-icon>mark_email_read</mat-icon> OPEN</button>
}
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
@if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
}
</li>
}
</ul>
</section>
`
})
export class MailboxesSection {
private api = inject(ApiService);
private state = inject(MailStateService);
private toasts = inject(ToastService);
private auth = inject(AuthService);
readonly mailboxes = signal<Mailbox[]>([]);
readonly secret = signal<{ email: string; password: string } | null>(null);
readonly error = signal('');
readonly busy = signal(false);
readonly me = computed(() => this.state.profile()?.email ?? '');
readonly showPw = signal(false);
readonly local = signal('');
// Домены сервера (/api/config); пока не пришли — домен своего ящика
readonly domains = signal<string[]>([]);
readonly domain = signal('');
name = '';
password = '';
constructor() {
this.domain.set(this.me().split('@')[1] ?? '');
this.api.config().subscribe({
next: c => { this.domains.set(c.domains); if (!c.domains.includes(this.domain())) { this.domain.set(c.domains[0] ?? ''); } },
error: () => {}
});
this.load();
}
size(bytes: number): string {
return formatBytes(bytes);
}
load(): void {
this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: e => this.error.set(errorText(e)) });
}
create(): void {
const local = this.local().replace(/@.*$/, '');
if (!local) { this.error.set('Enter an address'); return; }
const email = local + '@' + this.domain();
this.error.set('');
this.busy.set(true);
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({
next: created => {
this.busy.set(false);
this.local.set('');
this.name = this.password = '';
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
this.load();
},
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
});
}
newPassword(m: Mailbox): void {
if (!confirm('New password for ' + m.email + '? The old one stops working at once.')) return;
this.api.newMailboxPassword(m.id).subscribe({
next: created => created.password && this.secret.set({ email: m.email, password: created.password }),
error: e => this.toasts.error(errorText(e))
});
}
/** Administrator reads this mailbox: its session; the banner on top leads back. Full reload — fresh folders. */
open(m: Mailbox): void {
this.api.openMailbox(m.id).subscribe({
next: session => { this.auth.openAs(session); location.assign('/'); },
error: e => this.toasts.error(errorText(e))
});
}
toggle(m: Mailbox): void {
this.api.setMailboxActive(m.id, !m.active).subscribe({
next: () => this.load(),
error: e => this.toasts.error(errorText(e))
});
}
copy(text: string): void {
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
}
}
@@ -7,11 +7,12 @@ import { MailStateService } from '../../core/mail-state.service';
import { errorText, formatBytes } from '../../core/models'; import { errorText, formatBytes } from '../../core/models';
import { THEMES, ThemeService } from '../../ui/theme.service'; import { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service'; import { ToastService } from '../../ui/toast.service';
import { MailboxesSection } from './mailboxes.section';
/** Profile name, password, storage, look. */ /** Profile name, password, storage, look. */
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule], imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
@@ -41,11 +42,23 @@ import { ToastService } from '../../ui/toast.service';
<form (ngSubmit)="changePassword()" class="space-y-3"> <form (ngSubmit)="changePassword()" class="space-y-3">
<input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true"> <input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true">
<div><label class="synth-label" for="pw-current">Current password</label> <div><label class="synth-label" for="pw-current">Current password</label>
<input id="pw-current" name="current" type="password" class="synth-input" [(ngModel)]="current" autocomplete="current-password"></div> <div class="relative"><input id="pw-current" name="current" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="current" autocomplete="current-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label> <div><label class="synth-label" for="pw-new">New password (8+ characters)</label>
<input id="pw-new" name="next" type="password" class="synth-input" [(ngModel)]="next" autocomplete="new-password"></div> <div class="relative"><input id="pw-new" name="next" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="next" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<div><label class="synth-label" for="pw-again">New password again</label> <div><label class="synth-label" for="pw-again">New password again</label>
<input id="pw-again" name="again" type="password" class="synth-input" [(ngModel)]="again" autocomplete="new-password"></div> <div class="relative"><input id="pw-again" name="again" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="again" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> } @if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> }
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button> <button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button>
</form> </form>
@@ -97,6 +110,10 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</div> </div>
</section> </section>
@if (state.profile()?.admin) {
<app-mailboxes-section />
}
</div> </div>
` `
}) })
@@ -112,6 +129,7 @@ export class SettingsPage {
next = ''; next = '';
again = ''; again = '';
readonly passwordError = signal(''); readonly passwordError = signal('');
readonly showPw = signal(false);
readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes)); readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes));
readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes)); readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes));
@@ -69,8 +69,17 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</header> </header>
@if (auth.openedBy(); as admin) {
<div class="bg-pink-600/90 text-white text-sm font-rajdhani px-4 py-2 flex flex-wrap items-center justify-center gap-3" role="status">
<mat-icon class="!text-base">visibility</mat-icon>
<span>You are in mailbox <b class="font-mono">{{ auth.email() }}</b> as administrator</span>
<button type="button" class="ghost-btn !py-1 !border-white !text-white" (click)="backToAdmin()">
<mat-icon>arrow_back</mat-icon> Back to {{ admin }}</button>
</div>
}
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5"> <div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
<nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()" <nav class="glass-panel static p-4 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders"> aria-label="Mail folders">
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)"> <a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
<mat-icon>edit</mat-icon> COMPOSE <mat-icon>edit</mat-icon> COMPOSE
@@ -247,6 +256,11 @@ export class ShellComponent {
} }
} }
backToAdmin(): void {
this.auth.backToAdmin();
location.assign('/settings');
}
logout(): void { logout(): void {
this.state.reset(); this.state.reset();
this.auth.logout(); this.auth.logout();