Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bc0792ea | ||
|
|
53c80c57d7 | ||
|
|
7b7de905f9 | ||
|
|
a26e8fe3dd | ||
|
|
80d2988fa3 | ||
|
|
c765c51cf0 | ||
|
|
cf84797e68 | ||
|
|
3603b9d315 | ||
|
|
fc3fe3970d | ||
|
|
0811dfcf68 | ||
|
|
714c62f285 | ||
|
|
e37487d509 | ||
|
|
312e65fc33 | ||
|
|
5c08d7e361 | ||
|
|
ccd31c503e | ||
|
|
532f6ccab8 | ||
|
|
9658cdfcd7 | ||
|
|
44c8e17a27 | ||
|
|
aedda06f2e | ||
|
|
91bb8b9b2d | ||
|
|
18ee4820c1 | ||
|
|
59e2c11d6a | ||
|
|
84ae0b6c7d | ||
|
|
0d0f4800f0 | ||
|
|
b009af972a | ||
|
|
100ab741f3 | ||
|
|
bdca471b97 | ||
|
|
639ab0a6e9 | ||
|
|
f1d66d4c7d | ||
|
|
f9b929ac7a | ||
|
|
4ad2e44e9b | ||
|
|
2149c29a32 | ||
|
|
048d6b3679 | ||
|
|
2a422f638f | ||
|
|
0eb5348e4b | ||
|
|
b19e9682a8 | ||
|
|
8f3ad77b69 | ||
|
|
711807847e | ||
|
|
620aae179b | ||
|
|
a37acfaaa7 | ||
|
|
87c6e5325d |
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
**/node_modules
|
||||||
|
**/build
|
||||||
|
**/dist
|
||||||
|
**/.gradle
|
||||||
|
**/.angular
|
||||||
|
*.log
|
||||||
+47
@@ -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
|
||||||
@@ -2,7 +2,38 @@
|
|||||||
|
|
||||||
Почтовый сервер на **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**.
|
||||||
|
|
||||||
|
**Ловушка у ящика (TRAP):** Настройки → MAILBOXES → кнопка `outbox` у ящика. Всё, что такой ящик отправляет (SMTP или веб) **на чужие адреса**, никому не доставляется: одна копия ложится в его же INBOX. Письма на наши адреса и рассылки (`alerts-staging@`) доставляются как обычно. Для `staging@` — тестовые письма staging-стенда не попадают к живым людям, а алерты staging доходят до команды.
|
||||||
|
|
||||||
|
**Папки по `+метке`:** письмо на `bob+prod@jambocheck.com` ложится в ящик `bob@` в папку **prod** (создаётся сама). В имени ящика и рассылки `+` нельзя. Метка не может быть системной папкой (Spam, Trash …) — тогда INBOX.
|
||||||
|
|
||||||
|
**Рассылки (LISTS):** Настройки → LISTS. Письмо на адрес рассылки (`alerts-prod@`) → **каждому участнику своя копия** (своё «прочитано») в папку рассылки. Закрытая рассылка (по умолчанию) принимает письма только от вошедших отправителей (Kuma через `monitor@`, Jambotron) — снаружи 550. Адрес рассылки не может совпадать с ящиком.
|
||||||
|
|
||||||
|
**Несколько ящиков у одного логина:** слева в меню **MAILBOXES** — свой ящик, общие, в которых ты участник, и подключённые (кнопка ⊕: адрес + пароль того ящика). Клик — переключиться; «×» — убрать из своего меню.
|
||||||
|
|
||||||
|
**Общий ящик** (`support@`, `billing@`): Настройки → MAILBOXES → галочка «shared mailbox» при создании, кнопка `group` — кто может его открывать. Своего пароля и входа у него нет; «прочитано» общее (командный ящик); у письма видно **«Answered by …»** — кто ответил из веб-почты. В профиле открытого чужого ящика пароль/имя не меняются.
|
||||||
|
|
||||||
|
**В почтовой программе** (Thunderbird, телефон) общий или подключённый ящик — логин `ящик*свой_логин` (например `support@jambocheck.com*jambotronpw@jambocheck.com`) и **свой** пароль; так и для IMAP, и для SMTP (письма уходят от имени общего ящика).
|
||||||
|
|
||||||
|
**Служебное API для Jambotron** (`/api/service/**`, заголовок `Authorization: Bearer <SERVICE_TOKEN>`; токен генерирует `secrets-init` в томе секретов — файл `SERVICE_TOKEN`, без токена API выключено): Общие ящики: `PUT /shared/{address}` `{displayName, members[]}` — создать при отсутствии, точный состав участников; выключение аккаунта убирает его и из общих ящиков.
|
||||||
|
`PUT /accounts/{email}` `{displayName, active}` — создать (201 + пароль один раз) или изменить; `active:false` — выключить и убрать из всех рассылок · `POST /accounts/{email}/password` — новый пароль один раз · `GET /accounts/{email}` · `GET /lists` · `PUT /lists/{address}` `{folder, description, openToOutside, members[]}` — точный состав · `DELETE /lists/{address}`.
|
||||||
|
|
||||||
|
**Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
|
||||||
|
|
||||||
|
Пароли остальных ящиков (`staging@`, `no-reply@`, `monitor@`) — в репозитории Jambotron: `INFRASTRUCTURE/SECRETS.md`, раздел «Почта mail.jambocheck.com».
|
||||||
|
|
||||||
|
| Ситуация | Что делать |
|
||||||
|
| :--- | :--- |
|
||||||
|
| Пароль `admin@` ещё не меняли | файл `/app/data/initial-passwords.txt` **внутри контейнера** `mail` на ВМ почты (в этом репозитории его нет; лежит в томе `data`, передеплой его не стирает). С компьютера: `ssh root@103.109.235.150`, затем `incus exec --project jambotron-prod incust-prod-react-mail-server -- docker exec jambotron-109cf3bb-mail-1 cat /app/data/initial-passwords.txt`. В логе `mail` строка `Mailbox created…` есть **только до первого передеплоя** |
|
||||||
|
| Пароль `admin@` сменили и забыли | новый пароль прямо в базе почты, ящик и письма остаются — попросить α/Ω; команда — Jambotron, `INFRASTRUCTURE/test-stands.md`, раздел «Почта mail.jambocheck.com» |
|
||||||
|
| Забыт пароль другого ящика (`staging@`, `no-reply@`, `monitor@`, свой) | войти под `admin@` → Настройки → MAILBOXES → ключик у ящика → новый пароль (показывается один раз) и вписать его туда, где ящик настроен |
|
||||||
|
|
||||||
|
Всё о prod-почте (порты, DNS, кто через неё шлёт) — Jambotron, `INFRASTRUCTURE/test-stands.md`.
|
||||||
|
|
||||||
## Как устроено
|
## Как устроено
|
||||||
|
|
||||||
@@ -13,7 +44,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 +98,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 +116,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 +162,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;
|
||||||
@@ -26,13 +27,14 @@ public class ImapConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
|
public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
|
||||||
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
|
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
|
||||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||||
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname);
|
com.mailserver.service.MailboxAccess access) {
|
||||||
|
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname, access);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 +43,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 +56,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@<first domain>. 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,24 @@ public class MailDomains {
|
|||||||
return domains;
|
return domains;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Address without its "+tag": {@code bob+prod@x} → {@code bob@x}. Mailbox and list names never contain "+",
|
||||||
|
* so the part after it is a label chosen by the sender — the folder the message goes to.
|
||||||
|
*/
|
||||||
|
public static String baseOf(String address) {
|
||||||
|
String lower = address.toLowerCase(Locale.ROOT);
|
||||||
|
int at = lower.lastIndexOf('@');
|
||||||
|
int plus = lower.indexOf('+');
|
||||||
|
return plus > 0 && plus < at ? lower.substring(0, plus) + lower.substring(at) : lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "+tag" of an address, or null: {@code bob+prod@x} → {@code prod}. */
|
||||||
|
public static String tagOf(String address) {
|
||||||
|
int at = address.lastIndexOf('@');
|
||||||
|
int plus = address.indexOf('+');
|
||||||
|
return plus > 0 && plus < at - 1 ? address.substring(plus + 1, at) : null;
|
||||||
|
}
|
||||||
|
|
||||||
public static String domainOf(String address) {
|
public static String domainOf(String address) {
|
||||||
int at = address == null ? -1 : address.lastIndexOf('@');
|
int at = address == null ? -1 : address.lastIndexOf('@');
|
||||||
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
|
return at < 0 ? "" : address.substring(at + 1).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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ public class SecurityConfig {
|
|||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.authorizeExchange(exchange -> exchange
|
.authorizeExchange(exchange -> exchange
|
||||||
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
|
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
|
||||||
|
// service API for Jambotron: its own token, checked by ServiceController (not a mailbox login)
|
||||||
|
.pathMatchers("/api/service/**").permitAll()
|
||||||
.pathMatchers("/api/**", "/actuator/**").authenticated()
|
.pathMatchers("/api/**", "/actuator/**").authenticated()
|
||||||
// the web client (static files, SPA routes)
|
// the web client (static files, SPA routes)
|
||||||
.anyExchange().permitAll()
|
.anyExchange().permitAll()
|
||||||
|
|||||||
@@ -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,38 @@ 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.isShared()) {
|
||||||
|
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
|
||||||
|
"This is a shared mailbox: log in with your own mailbox and open it there")));
|
||||||
|
}
|
||||||
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). */
|
||||||
@@ -110,22 +142,61 @@ public class AuthenticationController {
|
|||||||
|
|
||||||
@PutMapping("/me")
|
@PutMapping("/me")
|
||||||
public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) {
|
public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) {
|
||||||
return user(auth).zipWith(body)
|
return own(auth).zipWith(body)
|
||||||
.flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName()))
|
.flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName()))
|
||||||
.flatMap(this::profile);
|
.flatMap(this::profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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,
|
||||||
return user(auth).zipWith(body)
|
ServerHttpRequest http) {
|
||||||
.flatMap(pair -> userService.changePassword(pair.getT1(), pair.getT2().currentPassword(), pair.getT2().newPassword()))
|
String ip = clientIp(http);
|
||||||
|
return own(auth).zipWith(body)
|
||||||
|
.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())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's own mailbox — not a shared / attached / administrator-opened one: no profile changes there. */
|
||||||
|
private Mono<User> own(Mono<Authentication> auth) {
|
||||||
|
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||||
|
.flatMap(a -> com.mailserver.security.Actor.opened(a)
|
||||||
|
? Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||||
|
"This mailbox is opened from " + com.mailserver.security.Actor.login(a) + " — change it in Settings → MAILBOXES"))
|
||||||
|
: user(Mono.just(a)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<User> user(Mono<Authentication> auth) {
|
private Mono<User> user(Mono<Authentication> auth) {
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ public class MailController {
|
|||||||
@PostMapping("/send")
|
@PostMapping("/send")
|
||||||
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
|
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
|
||||||
return auth.zipWith(requestMono)
|
return auth.zipWith(requestMono)
|
||||||
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
|
.flatMap(pair -> submissionService.send(pair.getT1().getName(),
|
||||||
|
com.mailserver.security.Actor.login(pair.getT1()), pair.getT2()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Drafts ---
|
// --- Drafts ---
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
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.MailDirectory;
|
||||||
|
import com.mailserver.service.MailLists;
|
||||||
|
import com.mailserver.service.MailboxAccess;
|
||||||
|
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.DeleteMapping;
|
||||||
|
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.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
|
||||||
|
|
||||||
|
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
|
||||||
|
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param password empty — the server generates one and returns it once */
|
||||||
|
/** @param shared shared mailbox (support@): no login of its own — members open it; password ignored */
|
||||||
|
public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName,
|
||||||
|
@Size(max = 200) String password, Boolean shared) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordRequest(@Size(max = 200) String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ActiveRequest(boolean active) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TrapRequest(boolean trap) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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;
|
||||||
|
private final MailDirectory directory;
|
||||||
|
private final MailLists lists;
|
||||||
|
private final MailboxAccess access;
|
||||||
|
|
||||||
|
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt,
|
||||||
|
MailDirectory directory, MailLists lists, MailboxAccess access) {
|
||||||
|
this.access = access;
|
||||||
|
this.jwt = jwt;
|
||||||
|
this.directory = directory;
|
||||||
|
this.lists = lists;
|
||||||
|
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 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"));
|
||||||
|
}
|
||||||
|
boolean shared = Boolean.TRUE.equals(request.shared());
|
||||||
|
String password = chosen.isEmpty() || shared ? randomPassword() : chosen;
|
||||||
|
return directory.createMailbox(request.email(), request.displayName(), password)
|
||||||
|
.flatMap(user -> shared ? users.setShared(user, true) : Mono.just(user))
|
||||||
|
.doOnNext(user -> log.info("{} created by administrator: {}", shared ? "Shared mailbox" : "Mailbox", user.getEmail()))
|
||||||
|
.flatMap(this::view)
|
||||||
|
.map(view -> new Created(view, chosen.isEmpty() && !shared ? 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(), me));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deleted for good with all its mail (Settings → MAILBOXES → Delete); not your own mailbox. */
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> delete(Mono<Authentication> auth, @PathVariable Long id) {
|
||||||
|
return admin(auth).flatMap(me -> mailbox(id).flatMap(user -> {
|
||||||
|
if (user.getEmail().equalsIgnoreCase(me)) {
|
||||||
|
return Mono.error(bad("You can not delete your own mailbox"));
|
||||||
|
}
|
||||||
|
return mail.deleteMailbox(user)
|
||||||
|
.doOnSuccess(v -> log.warn("Mailbox {} deleted with all its mail by administrator {}", user.getEmail(), me));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
|
||||||
|
@PutMapping("/{id}/trap")
|
||||||
|
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
|
||||||
|
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
|
||||||
|
log.info("Mailbox {} trap {} by administrator {}", user.getEmail(), request.trap() ? "ON" : "OFF", me);
|
||||||
|
return users.setTrap(user, request.trap());
|
||||||
|
}))).flatMap(this::view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MembersRequest(List<String> members) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exactly these members may open the mailbox (shared mailbox; for an ordinary one — delegates). */
|
||||||
|
@PutMapping("/{id}/members")
|
||||||
|
public Mono<MailboxView> members(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<MembersRequest> body) {
|
||||||
|
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user ->
|
||||||
|
directory.setMembers(user, request.members())
|
||||||
|
.doOnSuccess(v -> log.info("Members of {} set by administrator {}: {}", user.getEmail(), me, request.members()))
|
||||||
|
.then(users.byId(id)))))
|
||||||
|
.flatMap(this::view);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mailing lists (Settings → LISTS) ---
|
||||||
|
|
||||||
|
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
|
||||||
|
List<String> members) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/lists")
|
||||||
|
public Flux<MailLists.MailList> lists(Mono<Authentication> auth) {
|
||||||
|
return admin(auth).thenMany(lists.all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates or replaces the list {@code address} (settings + the exact member set). */
|
||||||
|
@PutMapping("/lists/{address}")
|
||||||
|
public Mono<MailLists.MailList> saveList(Mono<Authentication> auth, @PathVariable String address,
|
||||||
|
@RequestBody Mono<ListRequest> body) {
|
||||||
|
return admin(auth).flatMap(me -> body.flatMap(request ->
|
||||||
|
directory.saveList(address, request.folder(), request.description(), request.openToOutside(), request.members())
|
||||||
|
.doOnNext(list -> log.info("List {} saved by administrator {}: {} members", list.address(), me, list.members().size()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/lists/{address}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> deleteList(Mono<Authentication> auth, @PathVariable String address) {
|
||||||
|
return admin(auth).flatMap(me -> lists.delete(address)
|
||||||
|
.flatMap(deleted -> deleted ? Mono.<Void>empty()
|
||||||
|
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "List not found")))
|
||||||
|
.doOnSuccess(v -> log.info("List {} deleted by administrator {}", address, me)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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).zipWith(access.members(user.getId()).collectList())
|
||||||
|
.map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
|
||||||
|
admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
|
||||||
|
user.getCreatedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseStatusException bad(String message) {
|
||||||
|
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String randomPassword() {
|
||||||
|
return MailDirectory.randomPassword();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.dto.LoginResponse;
|
||||||
|
import com.mailserver.security.Actor;
|
||||||
|
import com.mailserver.security.LoginGuard;
|
||||||
|
import com.mailserver.service.MailboxAccess;
|
||||||
|
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.http.server.reactive.ServerHttpRequest;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
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.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.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mailboxes of the logged-in user (left menu): own, shared ones it is a member of, attached ones.
|
||||||
|
* Always works for the LOGIN behind the session, also when a shared mailbox is open.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mailboxes")
|
||||||
|
public class MailboxesController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MailboxesController.class);
|
||||||
|
|
||||||
|
public record AttachRequest(@NotBlank @Size(max = 254) String email, @NotBlank @Size(max = 200) String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final MailboxAccess access;
|
||||||
|
private final UserService users;
|
||||||
|
private final JwtUtils jwt;
|
||||||
|
private final LoginGuard guard;
|
||||||
|
|
||||||
|
public MailboxesController(MailboxAccess access, UserService users, JwtUtils jwt, LoginGuard guard) {
|
||||||
|
this.access = access;
|
||||||
|
this.users = users;
|
||||||
|
this.jwt = jwt;
|
||||||
|
this.guard = guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<MailboxAccess.Entry> mine(Mono<Authentication> auth) {
|
||||||
|
return login(auth).flatMapMany(access::mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session of this mailbox for the login (switch in the left menu). */
|
||||||
|
@PostMapping("/{email}/session")
|
||||||
|
public Mono<LoginResponse> session(Mono<Authentication> auth, @PathVariable String email) {
|
||||||
|
return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.has(target.getId(), me.getId())
|
||||||
|
.flatMap(allowed -> {
|
||||||
|
if (!allowed) {
|
||||||
|
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "No access to this mailbox"));
|
||||||
|
}
|
||||||
|
LoginResponse response = new LoginResponse();
|
||||||
|
boolean own = target.getId().equals(me.getId());
|
||||||
|
response.setToken(own ? jwt.generateToken(me.getEmail()) : jwt.generateToken(target.getEmail(), me.getEmail()));
|
||||||
|
response.setEmail(target.getEmail());
|
||||||
|
return Mono.just(response);
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds another mailbox the user knows the password of. The password is checked like a login (LoginGuard). */
|
||||||
|
@PostMapping("/attach")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Mono<MailboxAccess.Entry> attach(Mono<Authentication> auth, @RequestBody Mono<AttachRequest> body, ServerHttpRequest http) {
|
||||||
|
String ip = AuthenticationController.clientIp(http);
|
||||||
|
return login(auth).zipWith(body).flatMap(pair -> {
|
||||||
|
User me = pair.getT1();
|
||||||
|
String email = pair.getT2().email().trim().toLowerCase(Locale.ROOT);
|
||||||
|
Duration wait = guard.retryAfter(ip, email);
|
||||||
|
if (!wait.isZero()) {
|
||||||
|
return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
"Too many failed attempts, try again in " + LoginGuard.human(wait)));
|
||||||
|
}
|
||||||
|
return users.findOptional(email)
|
||||||
|
.filter(target -> target.isActive() && !target.isShared() && users.checkPassword(target, pair.getT2().password()))
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
guard.failure(ip, email);
|
||||||
|
log.warn("WEB {}: {} failed to attach {}", ip, me.getEmail(), email);
|
||||||
|
return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "Wrong address or password"));
|
||||||
|
}))
|
||||||
|
.flatMap(target -> {
|
||||||
|
if (target.getId().equals(me.getId())) {
|
||||||
|
return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "This is your own mailbox"));
|
||||||
|
}
|
||||||
|
guard.success(ip, email);
|
||||||
|
log.info("{} attached mailbox {}", me.getEmail(), email);
|
||||||
|
return access.grant(target.getId(), me.getId(), MailboxAccess.ATTACHED)
|
||||||
|
.then(access.unread(target.getId()))
|
||||||
|
.map(n -> new MailboxAccess.Entry(target.getEmail(), target.getDisplayName(), MailboxAccess.ATTACHED, false, n));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the mailbox from the user's menu (attached or shared). */
|
||||||
|
@DeleteMapping("/{email}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> leave(Mono<Authentication> auth, @PathVariable String email) {
|
||||||
|
return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.revoke(target.getId(), me.getId())
|
||||||
|
.flatMap(done -> done ? Mono.<Void>empty()
|
||||||
|
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Not in your mailboxes")))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<User> login(Mono<Authentication> auth) {
|
||||||
|
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||||
|
.flatMap(a -> users.findOptional(Actor.login(a)))
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<User> mailbox(String email) {
|
||||||
|
return users.findOptional(email)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.service.MailDirectory;
|
||||||
|
import com.mailserver.service.MailLists;
|
||||||
|
import com.mailserver.service.MailboxAccess;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
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.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
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.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service API for Jambotron (staff mailboxes): token {@code SERVICE_TOKEN} in {@code Authorization: Bearer …}.
|
||||||
|
* Without the token configured the API is off (404). Accounts: create (password returned ONCE), switch on/off,
|
||||||
|
* new password; lists: the exact settings and members, Jambotron is the source of truth for the lists it keeps.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/service")
|
||||||
|
public class ServiceController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ServiceController.class);
|
||||||
|
|
||||||
|
/** @param displayName null — unchanged (new account: the name before @) */
|
||||||
|
public record AccountRequest(@Size(max = 100) String displayName, Boolean active) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AccountView(String email, String displayName, boolean active, List<String> lists) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param password generated, only when the account was just created or got a new password */
|
||||||
|
public record AccountResult(AccountView account, String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
|
||||||
|
List<String> members) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final byte[] token;
|
||||||
|
private final UserService users;
|
||||||
|
private final MailLists lists;
|
||||||
|
private final MailDirectory directory;
|
||||||
|
private final MailboxAccess access;
|
||||||
|
|
||||||
|
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists,
|
||||||
|
MailDirectory directory, MailboxAccess access) {
|
||||||
|
this.access = access;
|
||||||
|
this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
|
||||||
|
this.users = users;
|
||||||
|
this.lists = lists;
|
||||||
|
this.directory = directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/accounts/{email}")
|
||||||
|
public Mono<AccountView> account(@RequestHeader(value = "Authorization", required = false) String auth,
|
||||||
|
@PathVariable String email) {
|
||||||
|
check(auth);
|
||||||
|
return users.findOptional(email)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")))
|
||||||
|
.flatMap(this::view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the account (201 + password once) or updates name / on-off (200). Switched off → out of every list. */
|
||||||
|
@PutMapping("/accounts/{email}")
|
||||||
|
public Mono<ResponseEntity<AccountResult>> saveAccount(@RequestHeader(value = "Authorization", required = false) String auth,
|
||||||
|
@PathVariable String email, @RequestBody Mono<AccountRequest> body) {
|
||||||
|
check(auth);
|
||||||
|
String address = directory.address(email);
|
||||||
|
return body.defaultIfEmpty(new AccountRequest(null, null)).flatMap(request -> users.findOptional(address)
|
||||||
|
.flatMap(user -> update(user, request).flatMap(this::view)
|
||||||
|
.map(view -> ResponseEntity.ok(new AccountResult(view, null))))
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
String password = MailDirectory.randomPassword();
|
||||||
|
return directory.createMailbox(address, request.displayName(), password)
|
||||||
|
.flatMap(user -> Boolean.FALSE.equals(request.active()) ? update(user, request) : Mono.just(user))
|
||||||
|
.doOnNext(user -> log.info("Mailbox {} created by Jambotron", user.getEmail()))
|
||||||
|
.flatMap(this::view)
|
||||||
|
.map(view -> ResponseEntity.status(HttpStatus.CREATED).body(new AccountResult(view, password)));
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New generated password, returned once (Jambotron profile → «new mail password»). */
|
||||||
|
@PostMapping("/accounts/{email}/password")
|
||||||
|
public Mono<AccountResult> password(@RequestHeader(value = "Authorization", required = false) String auth,
|
||||||
|
@PathVariable String email) {
|
||||||
|
check(auth);
|
||||||
|
String password = MailDirectory.randomPassword();
|
||||||
|
return users.findOptional(email)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")))
|
||||||
|
.flatMap(user -> users.setPassword(user, password))
|
||||||
|
.doOnNext(user -> log.info("Password of {} set by Jambotron", user.getEmail()))
|
||||||
|
.flatMap(this::view)
|
||||||
|
.map(view -> new AccountResult(view, password));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/lists")
|
||||||
|
public Flux<MailLists.MailList> lists(@RequestHeader(value = "Authorization", required = false) String auth) {
|
||||||
|
check(auth);
|
||||||
|
return lists.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/lists/{address}")
|
||||||
|
public Mono<MailLists.MailList> saveList(@RequestHeader(value = "Authorization", required = false) String auth,
|
||||||
|
@PathVariable String address, @RequestBody Mono<ListRequest> body) {
|
||||||
|
check(auth);
|
||||||
|
return body.flatMap(request -> directory.saveList(address, request.folder(), request.description(),
|
||||||
|
request.openToOutside(), request.members()))
|
||||||
|
.doOnNext(list -> log.info("List {} saved by Jambotron: {}", list.address(), list.members()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/lists/{address}")
|
||||||
|
public Mono<ResponseEntity<Void>> deleteList(@RequestHeader(value = "Authorization", required = false) String auth,
|
||||||
|
@PathVariable String address) {
|
||||||
|
check(auth);
|
||||||
|
return lists.delete(address).map(deleted -> deleted
|
||||||
|
? ResponseEntity.noContent().<Void>build() : ResponseEntity.notFound().<Void>build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SharedRequest(@Size(max = 100) String displayName, List<String> members) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SharedView(String email, String displayName, List<String> members) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared mailbox (support@, billing@): created when missing, exactly these members. */
|
||||||
|
@PutMapping("/shared/{address}")
|
||||||
|
public Mono<SharedView> saveShared(@RequestHeader(value = "Authorization", required = false) String auth,
|
||||||
|
@PathVariable String address, @RequestBody Mono<SharedRequest> body) {
|
||||||
|
check(auth);
|
||||||
|
return body.flatMap(request -> directory.saveShared(address, request.displayName(), request.members()))
|
||||||
|
.flatMap(user -> access.members(user.getId()).collectList()
|
||||||
|
.map(members -> new SharedView(user.getEmail(), user.getDisplayName(), members)))
|
||||||
|
.doOnNext(view -> log.info("Shared mailbox {} saved by Jambotron: {}", view.email(), view.members()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<User> update(User user, AccountRequest request) {
|
||||||
|
Mono<User> named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
|
||||||
|
: users.updateDisplayName(user, request.displayName());
|
||||||
|
if (request.active() == null || request.active() == user.isActive()) {
|
||||||
|
return named;
|
||||||
|
}
|
||||||
|
boolean on = request.active();
|
||||||
|
return named.flatMap(u -> users.setActive(u, on))
|
||||||
|
.flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId())
|
||||||
|
.then(access.removeEverywhere(u.getId())).thenReturn(u))
|
||||||
|
.doOnNext(u -> log.info("Mailbox {} {} by Jambotron", u.getEmail(), on ? "switched on" : "switched off"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<AccountView> view(User user) {
|
||||||
|
return lists.listsOf(user.getId()).collectList()
|
||||||
|
.map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), in));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404 when the API is off, 401 for a wrong token (constant-time compare). */
|
||||||
|
private void check(String auth) {
|
||||||
|
if (token == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
byte[] given = auth != null && auth.startsWith("Bearer ")
|
||||||
|
? auth.substring(7).trim().getBytes(StandardCharsets.UTF_8) : new byte[0];
|
||||||
|
if (!MessageDigest.isEqual(token, given)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,13 @@ public class Message {
|
|||||||
@Column("message_id")
|
@Column("message_id")
|
||||||
private String messageId;
|
private String messageId;
|
||||||
|
|
||||||
|
/** Who answered it from the web client (V7) — the login, also for a shared mailbox. */
|
||||||
|
@Column("answered_by")
|
||||||
|
private String answeredBy;
|
||||||
|
|
||||||
|
public String getAnsweredBy() { return answeredBy; }
|
||||||
|
public void setAnsweredBy(String answeredBy) { this.answeredBy = answeredBy; }
|
||||||
|
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
public void setId(Long id) { this.id = id; }
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ public class User {
|
|||||||
@Column("is_active")
|
@Column("is_active")
|
||||||
private boolean isActive = true;
|
private boolean isActive = true;
|
||||||
|
|
||||||
|
/** Trap: what this mailbox sends is stored in its own INBOX, never delivered (V5). */
|
||||||
|
@Column("is_trap")
|
||||||
|
private boolean isTrap = false;
|
||||||
|
|
||||||
|
/** Shared mailbox (V7): no login of its own, members open it. */
|
||||||
|
@Column("is_shared")
|
||||||
|
private boolean isShared = false;
|
||||||
|
|
||||||
@Column("created_at")
|
@Column("created_at")
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -54,6 +62,12 @@ public class User {
|
|||||||
public boolean isActive() { return isActive; }
|
public boolean isActive() { return isActive; }
|
||||||
public void setActive(boolean active) { isActive = active; }
|
public void setActive(boolean active) { isActive = active; }
|
||||||
|
|
||||||
|
public boolean isTrap() { return isTrap; }
|
||||||
|
public void setTrap(boolean trap) { isTrap = trap; }
|
||||||
|
|
||||||
|
public boolean isShared() { return isShared; }
|
||||||
|
public void setShared(boolean shared) { isShared = shared; }
|
||||||
|
|
||||||
public Instant getCreatedAt() { return createdAt; }
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ public class MessageResponse {
|
|||||||
private Long folderId;
|
private Long folderId;
|
||||||
private boolean hasAttachments;
|
private boolean hasAttachments;
|
||||||
private int attachmentCount;
|
private int attachmentCount;
|
||||||
|
/** login that answered it from the web client, or null */
|
||||||
|
private String answeredBy;
|
||||||
|
|
||||||
|
public String getAnsweredBy() { return answeredBy; }
|
||||||
|
public void setAnsweredBy(String answeredBy) { this.answeredBy = answeredBy; }
|
||||||
|
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
public void setId(Long id) { this.id = id; }
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ public class SendRequest {
|
|||||||
|
|
||||||
private Long draftId;
|
private Long draftId;
|
||||||
|
|
||||||
|
/** The message this one answers (web client «Reply»): marked answered, by whom. */
|
||||||
|
private Long replyToId;
|
||||||
|
|
||||||
|
public Long getReplyToId() { return replyToId; }
|
||||||
|
public void setReplyToId(Long replyToId) { this.replyToId = replyToId; }
|
||||||
|
|
||||||
public String getTo() { return to; }
|
public String getTo() { return to; }
|
||||||
public void setTo(String to) { this.to = to; }
|
public void setTo(String to) { this.to = to; }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.mailserver.repository.MessageRepository;
|
|||||||
import com.mailserver.repository.UserRepository;
|
import com.mailserver.repository.UserRepository;
|
||||||
import com.mailserver.service.LocalDelivery;
|
import com.mailserver.service.LocalDelivery;
|
||||||
import com.mailserver.service.MailService;
|
import com.mailserver.service.MailService;
|
||||||
|
import com.mailserver.service.MailboxAccess;
|
||||||
import com.mailserver.service.MessageSources;
|
import com.mailserver.service.MessageSources;
|
||||||
import com.mailserver.service.UserService;
|
import com.mailserver.service.UserService;
|
||||||
import jakarta.mail.Session;
|
import jakarta.mail.Session;
|
||||||
@@ -45,10 +46,12 @@ public class DbImapStore implements ImapStore {
|
|||||||
private final AttachmentRepository attachments;
|
private final AttachmentRepository attachments;
|
||||||
private final MessageSources sources;
|
private final MessageSources sources;
|
||||||
private final String hostname;
|
private final String hostname;
|
||||||
|
private final MailboxAccess access;
|
||||||
|
|
||||||
public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
|
public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
|
||||||
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
|
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
|
||||||
String hostname) {
|
String hostname, MailboxAccess access) {
|
||||||
|
this.access = access;
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
@@ -61,11 +64,9 @@ public class DbImapStore implements ImapStore {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Account authenticate(String username, String password) {
|
public Account authenticate(String username, String password) {
|
||||||
User user = users.findByEmail(username.trim().toLowerCase(Locale.ROOT)).block();
|
// login or mailbox*login (shared / attached mailbox in a mail program)
|
||||||
if (user == null || !user.isActive() || !userService.checkPassword(user, password)) {
|
User user = access.login(username, password);
|
||||||
return null;
|
return user == null ? null : new Account(user.getId(), user.getEmail());
|
||||||
}
|
|
||||||
return new Account(user.getId(), user.getEmail());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ public interface AttachmentRepository extends R2dbcRepository<Attachment, Long>
|
|||||||
|
|
||||||
Mono<Long> countByMessageId(Long messageId);
|
Mono<Long> countByMessageId(Long messageId);
|
||||||
|
|
||||||
|
/** All attachments of a mailbox — their files are removed with the mailbox. */
|
||||||
|
@Query("SELECT a.* FROM attachments a JOIN messages m ON m.id = a.message_id JOIN folders f ON f.id = m.folder_id WHERE f.user_id = :#{#userId}")
|
||||||
|
Flux<Attachment> findByMailbox(Long userId);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}")
|
@Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}")
|
||||||
Mono<Integer> moveAll(Long from, Long to);
|
Mono<Integer> moveAll(Long from, Long to);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.mailserver.security;
|
||||||
|
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is acting: the mailbox the session belongs to ({@link Authentication#getName()}) and the login behind it —
|
||||||
|
* the same for an own session, another one when a member opened a shared mailbox or an administrator opened one.
|
||||||
|
*/
|
||||||
|
public final class Actor {
|
||||||
|
|
||||||
|
private Actor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The login behind the session. */
|
||||||
|
public static String login(Authentication auth) {
|
||||||
|
return auth.getDetails() instanceof String by && !by.isBlank() ? by : auth.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session is another login's view of this mailbox: no password / profile changes from it. */
|
||||||
|
public static boolean opened(Authentication auth) {
|
||||||
|
return !login(auth).equalsIgnoreCase(auth.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ public class JwtAuthenticationFilter implements WebFilter {
|
|||||||
UserDetails userDetails = new User(email, "", Collections.emptyList());
|
UserDetails userDetails = new User(email, "", Collections.emptyList());
|
||||||
UsernamePasswordAuthenticationToken authentication =
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
new UsernamePasswordAuthenticationToken(userDetails, null, Collections.emptyList());
|
new UsernamePasswordAuthenticationToken(userDetails, null, Collections.emptyList());
|
||||||
|
// the login that opened this mailbox (shared / attached / administrator's OPEN); see Actor
|
||||||
|
authentication.setDetails(jwtUtils.getByFromToken(token));
|
||||||
|
|
||||||
return chain.filter(exchange)
|
return chain.filter(exchange)
|
||||||
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
|
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
|
|||||||
/** Blocking: call from a worker thread. */
|
/** Blocking: call from a worker thread. */
|
||||||
public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted)
|
public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted)
|
||||||
throws MessagingException, IOException {
|
throws MessagingException, IOException {
|
||||||
|
deliver(raw, mailbox, null, remoteIp, envelopeFrom, trusted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param folder own folder of the mailbox ("+tag", mailing list), created when missing; null — INBOX */
|
||||||
|
public void deliver(byte[] raw, String mailbox, String folder, String remoteIp, String envelopeFrom, boolean trusted)
|
||||||
|
throws MessagingException, IOException {
|
||||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
|
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
|
||||||
|
|
||||||
String senderEmail = envelopeFrom;
|
String senderEmail = envelopeFrom;
|
||||||
@@ -80,7 +86,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
|
|||||||
com.mailserver.domain.Message stored =
|
com.mailserver.domain.Message stored =
|
||||||
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam,
|
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam,
|
||||||
addresses(mime, jakarta.mail.Message.RecipientType.TO),
|
addresses(mime, jakarta.mail.Message.RecipientType.TO),
|
||||||
addresses(mime, jakarta.mail.Message.RecipientType.CC)).block();
|
addresses(mime, jakarta.mail.Message.RecipientType.CC), folder).block();
|
||||||
if (stored != null) {
|
if (stored != null) {
|
||||||
for (FilePart file : attachments(mime)) {
|
for (FilePart file : attachments(mime)) {
|
||||||
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
|
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailboxes and mailing lists share one address space: one rule for both, used by Settings (administrators)
|
||||||
|
* and by the service API (Jambotron). Names never contain "+" — the part after it is a folder label.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MailDirectory {
|
||||||
|
|
||||||
|
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?");
|
||||||
|
private static final Pattern FOLDER = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}");
|
||||||
|
public static final int MIN_PASSWORD = 8;
|
||||||
|
|
||||||
|
private final UserService users;
|
||||||
|
private final MailLists lists;
|
||||||
|
private final MailDomains domains;
|
||||||
|
private final MailboxAccess access;
|
||||||
|
|
||||||
|
public MailDirectory(UserService users, MailLists lists, MailDomains domains, MailboxAccess access) {
|
||||||
|
this.access = access;
|
||||||
|
this.users = users;
|
||||||
|
this.lists = lists;
|
||||||
|
this.domains = domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lower-case address of our domain with an allowed name, or 400. */
|
||||||
|
public String address(String raw) {
|
||||||
|
String email = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
|
||||||
|
int at = email.lastIndexOf('@');
|
||||||
|
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
|
||||||
|
throw bad("Not a mailbox address: " + email + " (letters, digits, . _ - ; no +)");
|
||||||
|
}
|
||||||
|
if (!domains.isLocal(email)) {
|
||||||
|
throw bad("Only in " + String.join(", ", domains.domains().stream().sorted().toList()));
|
||||||
|
}
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New mailbox; the address must not be taken by a mailbox or a list. 409 when taken. */
|
||||||
|
public Mono<User> createMailbox(String rawEmail, String displayName, String password) {
|
||||||
|
String email = address(rawEmail);
|
||||||
|
if (password == null || password.length() < MIN_PASSWORD) {
|
||||||
|
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters"));
|
||||||
|
}
|
||||||
|
return lists.exists(email).flatMap(isList -> isList
|
||||||
|
? Mono.<User>error(new ResponseStatusException(HttpStatus.CONFLICT, "This address is a mailing list"))
|
||||||
|
: users.register(email, password, displayName)
|
||||||
|
.onErrorMap(IllegalArgumentException.class,
|
||||||
|
e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or replaces a list. Members: mailboxes of this server (addresses); unknown ones → 400 naming them.
|
||||||
|
*/
|
||||||
|
public Mono<MailLists.MailList> saveList(String rawAddress, String rawFolder, String description,
|
||||||
|
boolean openToOutside, List<String> members) {
|
||||||
|
String address = address(rawAddress);
|
||||||
|
String folder = rawFolder == null || rawFolder.isBlank() ? null : rawFolder.trim();
|
||||||
|
if (folder != null && (!FOLDER.matcher(folder).matches()
|
||||||
|
|| MailService.SYSTEM_FOLDERS.stream().anyMatch(folder::equalsIgnoreCase))) {
|
||||||
|
return Mono.error(bad("Folder: letters, digits, . _ - (up to 50), not a system folder; empty — INBOX"));
|
||||||
|
}
|
||||||
|
String about = description == null || description.isBlank() ? null : description.trim();
|
||||||
|
if (about != null && about.length() > 255) {
|
||||||
|
return Mono.error(bad("Description: up to 255 characters"));
|
||||||
|
}
|
||||||
|
List<String> wanted = members == null ? List.of()
|
||||||
|
: members.stream().map(m -> m.trim().toLowerCase(Locale.ROOT)).filter(m -> !m.isEmpty()).distinct().toList();
|
||||||
|
return users.findOptional(address).hasElement().flatMap(isMailbox -> {
|
||||||
|
if (isMailbox) {
|
||||||
|
return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, "This address is a mailbox"));
|
||||||
|
}
|
||||||
|
List<String> unknown = new ArrayList<>();
|
||||||
|
return Flux.fromIterable(wanted)
|
||||||
|
.concatMap(member -> users.findOptional(member)
|
||||||
|
.map(User::getId)
|
||||||
|
.switchIfEmpty(Mono.fromRunnable(() -> unknown.add(member))))
|
||||||
|
.collectList()
|
||||||
|
.flatMap(ids -> unknown.isEmpty()
|
||||||
|
? lists.save(address, folder, about, openToOutside, ids)
|
||||||
|
: Mono.error(bad("No such mailbox: " + String.join(", ", unknown))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exactly these members (mailboxes of this server) for the mailbox; unknown ones → 400 naming them. */
|
||||||
|
public Mono<Void> setMembers(User mailbox, List<String> members) {
|
||||||
|
List<String> wanted = members == null ? List.of()
|
||||||
|
: members.stream().map(m -> m.trim().toLowerCase(Locale.ROOT)).filter(m -> !m.isEmpty()).distinct().toList();
|
||||||
|
List<String> unknown = new ArrayList<>();
|
||||||
|
return Flux.fromIterable(wanted)
|
||||||
|
.concatMap(member -> users.findOptional(member).map(User::getId)
|
||||||
|
.switchIfEmpty(Mono.fromRunnable(() -> unknown.add(member))))
|
||||||
|
.collectList()
|
||||||
|
.flatMap(ids -> unknown.isEmpty() ? access.setMembers(mailbox.getId(), ids)
|
||||||
|
: Mono.error(bad("No such mailbox: " + String.join(", ", unknown))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared mailbox: created when missing (no login of its own), then exactly these members. */
|
||||||
|
public Mono<User> saveShared(String rawAddress, String displayName, List<String> members) {
|
||||||
|
String address = address(rawAddress);
|
||||||
|
return users.findOptional(address)
|
||||||
|
.switchIfEmpty(Mono.defer(() -> createMailbox(address, displayName, randomPassword())))
|
||||||
|
.flatMap(user -> user.isShared() ? Mono.just(user) : users.setShared(user, true))
|
||||||
|
.flatMap(user -> displayName == null || displayName.isBlank() ? Mono.just(user) : users.updateDisplayName(user, displayName))
|
||||||
|
.flatMap(user -> setMembers(user, members).thenReturn(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String randomPassword() {
|
||||||
|
byte[] bytes = new byte[18];
|
||||||
|
new SecureRandom().nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResponseStatusException bad(String message) {
|
||||||
|
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import org.springframework.r2dbc.core.DatabaseClient;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailing lists (V6): {@code alerts-prod@} → every member gets their own copy in the list's folder.
|
||||||
|
* Kept by the administrators (Settings → LISTS) and by Jambotron through the service API.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MailLists {
|
||||||
|
|
||||||
|
/** @param folder where members get the copy; null — INBOX */
|
||||||
|
public record MailList(Long id, String address, String folder, String description, boolean openToOutside,
|
||||||
|
List<String> members) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final DatabaseClient db;
|
||||||
|
|
||||||
|
public MailLists(DatabaseClient db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<MailList> find(String address) {
|
||||||
|
return db.sql("SELECT id, address, folder, description, open_to_outside FROM mail_lists WHERE address = :a")
|
||||||
|
.bind("a", address.toLowerCase(Locale.ROOT))
|
||||||
|
.map((row, meta) -> new MailList(row.get("id", Long.class), row.get("address", String.class),
|
||||||
|
row.get("folder", String.class), row.get("description", String.class),
|
||||||
|
Boolean.TRUE.equals(row.get("open_to_outside", Boolean.class)), List.of()))
|
||||||
|
.one()
|
||||||
|
.flatMap(this::withMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> exists(String address) {
|
||||||
|
return db.sql("SELECT 1 FROM mail_lists WHERE address = :a").bind("a", address.toLowerCase(Locale.ROOT))
|
||||||
|
.map((row, meta) -> true).one().defaultIfEmpty(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<MailList> all() {
|
||||||
|
return db.sql("SELECT id, address, folder, description, open_to_outside FROM mail_lists ORDER BY address")
|
||||||
|
.map((row, meta) -> new MailList(row.get("id", Long.class), row.get("address", String.class),
|
||||||
|
row.get("folder", String.class), row.get("description", String.class),
|
||||||
|
Boolean.TRUE.equals(row.get("open_to_outside", Boolean.class)), List.of()))
|
||||||
|
.all()
|
||||||
|
.concatMap(this::withMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists this mailbox is a member of (shown to the mailbox owner). */
|
||||||
|
public Flux<String> listsOf(Long userId) {
|
||||||
|
return db.sql("SELECT l.address FROM mail_lists l JOIN mail_list_members m ON m.list_id = l.id "
|
||||||
|
+ "WHERE m.user_id = :u ORDER BY l.address")
|
||||||
|
.bind("u", userId).map((row, meta) -> row.get("address", String.class)).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or replaces the list: settings and the exact member set. Members are mailbox ids (checked by the caller).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Mono<MailList> save(String address, String folder, String description, boolean openToOutside,
|
||||||
|
Collection<Long> memberIds) {
|
||||||
|
String a = address.toLowerCase(Locale.ROOT);
|
||||||
|
var insert = db.sql("INSERT INTO mail_lists (address, folder, description, open_to_outside) "
|
||||||
|
+ "VALUES (:a, :f, :d, :o) ON CONFLICT (address) DO UPDATE "
|
||||||
|
+ "SET folder = EXCLUDED.folder, description = EXCLUDED.description, "
|
||||||
|
+ "open_to_outside = EXCLUDED.open_to_outside RETURNING id")
|
||||||
|
.bind("a", a).bind("o", openToOutside);
|
||||||
|
insert = folder == null ? insert.bindNull("f", String.class) : insert.bind("f", folder);
|
||||||
|
insert = description == null ? insert.bindNull("d", String.class) : insert.bind("d", description);
|
||||||
|
return insert.map((row, meta) -> row.get("id", Long.class)).one()
|
||||||
|
.flatMap(id -> db.sql("DELETE FROM mail_list_members WHERE list_id = :l").bind("l", id).then()
|
||||||
|
.thenMany(Flux.fromIterable(memberIds).distinct()
|
||||||
|
.concatMap(member -> db.sql("INSERT INTO mail_list_members (list_id, user_id) VALUES (:l, :u)")
|
||||||
|
.bind("l", id).bind("u", member).then()))
|
||||||
|
.then(find(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> delete(String address) {
|
||||||
|
return db.sql("DELETE FROM mail_lists WHERE address = :a").bind("a", address.toLowerCase(Locale.ROOT))
|
||||||
|
.fetch().rowsUpdated().map(n -> n > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes the mailbox out of every list (mailbox switched off / role removed in Jambotron). */
|
||||||
|
public Mono<Long> removeEverywhere(Long userId) {
|
||||||
|
return db.sql("DELETE FROM mail_list_members WHERE user_id = :u").bind("u", userId).fetch().rowsUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<MailList> withMembers(MailList list) {
|
||||||
|
return db.sql("SELECT u.email FROM mail_list_members m JOIN users u ON u.id = m.user_id "
|
||||||
|
+ "WHERE m.list_id = :l ORDER BY u.email")
|
||||||
|
.bind("l", list.id()).map((row, meta) -> row.get("email", String.class)).all().collectList()
|
||||||
|
.map(members -> new MailList(list.id(), list.address(), list.folder(), list.description(),
|
||||||
|
list.openToOutside(), members));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,34 @@ public class MailService {
|
|||||||
.switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted")));
|
.switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final java.util.regex.Pattern DELIVERY_FOLDER = java.util.regex.Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "+tag" / list folder that may be used as a folder name, or null (→ INBOX). A sender never puts mail into
|
||||||
|
* a system folder (Spam, Trash, Sent …) this way.
|
||||||
|
*/
|
||||||
|
static String deliveryFolderName(String raw) {
|
||||||
|
if (raw == null || !DELIVERY_FOLDER.matcher(raw).matches() || SYSTEM_FOLDERS.stream().anyMatch(raw::equalsIgnoreCase)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder with this name (any case), created as an own folder when missing; two at once — one wins. */
|
||||||
|
private Mono<Folder> deliveryFolder(Long userId, String name) {
|
||||||
|
Mono<Folder> existing = folderRepository.findByUserId(userId)
|
||||||
|
.filter(folder -> folder.getName().equalsIgnoreCase(name))
|
||||||
|
.next();
|
||||||
|
return existing.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
Folder folder = new Folder();
|
||||||
|
folder.setUserId(userId);
|
||||||
|
folder.setName(name);
|
||||||
|
folder.setCreatedAt(Instant.now());
|
||||||
|
return folderRepository.save(folder)
|
||||||
|
.onErrorResume(org.springframework.dao.DataIntegrityViolationException.class, e -> existing);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<Folder> systemFolder(Long userId, String name) {
|
private Mono<Folder> systemFolder(Long userId, String name) {
|
||||||
return folderRepository.findByUserIdAndName(userId, name)
|
return folderRepository.findByUserIdAndName(userId, name)
|
||||||
.switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found")));
|
.switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found")));
|
||||||
@@ -253,12 +281,25 @@ public class MailService {
|
|||||||
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||||
String subject, String body, boolean isHtml, boolean externalSpamFlag,
|
String subject, String body, boolean isHtml, boolean externalSpamFlag,
|
||||||
String toHeader, String ccHeader) {
|
String toHeader, String ccHeader) {
|
||||||
|
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag,
|
||||||
|
toHeader, ccHeader, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param folder own folder for the message ("+tag" of the address, folder of a mailing list), created when
|
||||||
|
* missing; null or not a valid folder name — INBOX. Spam still goes to Spam.
|
||||||
|
*/
|
||||||
|
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||||
|
String subject, String body, boolean isHtml, boolean externalSpamFlag,
|
||||||
|
String toHeader, String ccHeader, String targetFolder) {
|
||||||
return userRepository.findByEmail(recipientEmail)
|
return userRepository.findByEmail(recipientEmail)
|
||||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
|
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
|
||||||
.flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
|
.flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
|
||||||
.flatMap(blacklisted -> {
|
.flatMap(blacklisted -> {
|
||||||
boolean isSpam = blacklisted || externalSpamFlag;
|
boolean isSpam = blacklisted || externalSpamFlag;
|
||||||
return systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
|
String own = isSpam ? null : deliveryFolderName(targetFolder);
|
||||||
|
return (own == null ? systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
|
||||||
|
: deliveryFolder(recipient.getId(), own))
|
||||||
.flatMap(folder -> {
|
.flatMap(folder -> {
|
||||||
Message message = new Message();
|
Message message = new Message();
|
||||||
message.setFolderId(folder.getId());
|
message.setFolderId(folder.getId());
|
||||||
@@ -360,6 +401,16 @@ public class MailService {
|
|||||||
.then(messageRepository.delete(message));
|
.then(messageRepository.delete(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailbox deleted for good: attachment files first, then the user row — folders, messages, sources, contacts,
|
||||||
|
* list memberships and access rows go with it (ON DELETE CASCADE).
|
||||||
|
*/
|
||||||
|
public Mono<Void> deleteMailbox(User user) {
|
||||||
|
return attachmentRepository.findByMailbox(user.getId())
|
||||||
|
.doOnNext(attachment -> deleteFile(attachment.getFilePath()))
|
||||||
|
.then(userRepository.delete(user));
|
||||||
|
}
|
||||||
|
|
||||||
private static void deleteFile(String path) {
|
private static void deleteFile(String path) {
|
||||||
try {
|
try {
|
||||||
Files.deleteIfExists(Paths.get(path));
|
Files.deleteIfExists(Paths.get(path));
|
||||||
@@ -439,6 +490,7 @@ public class MailService {
|
|||||||
resp.setMessageSize(message.getMessageSize());
|
resp.setMessageSize(message.getMessageSize());
|
||||||
resp.setReceivedAt(message.getReceivedAt());
|
resp.setReceivedAt(message.getReceivedAt());
|
||||||
resp.setFolderId(message.getFolderId());
|
resp.setFolderId(message.getFolderId());
|
||||||
|
resp.setAnsweredBy(message.getAnsweredBy());
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.repository.UserRepository;
|
||||||
|
import org.springframework.r2dbc.core.DatabaseClient;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several mailboxes for one login (V7): own, shared ones it is a MEMBER of, ATTACHED ones (by their password).
|
||||||
|
* One rule for the web client, SMTP and IMAP; in mail programs another mailbox is
|
||||||
|
* {@code mailbox*login} with the login's password (the Dovecot "master user" form).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MailboxAccess {
|
||||||
|
|
||||||
|
public static final String MEMBER = "MEMBER";
|
||||||
|
public static final String ATTACHED = "ATTACHED";
|
||||||
|
|
||||||
|
/** @param kind OWN, MEMBER or ATTACHED; unread — INBOX and own folders (not Spam/Trash/Sent/Drafts) */
|
||||||
|
public record Entry(String email, String displayName, String kind, boolean shared, long unread) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final DatabaseClient db;
|
||||||
|
private final UserRepository users;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService) {
|
||||||
|
this.db = db;
|
||||||
|
this.users = users;
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password → the mailbox the
|
||||||
|
* session works with, or null. A shared mailbox never logs in by itself.
|
||||||
|
*/
|
||||||
|
public User login(String username, String password) {
|
||||||
|
String name = username.trim().toLowerCase(Locale.ROOT);
|
||||||
|
int star = name.indexOf('*');
|
||||||
|
String mailbox = star < 0 ? name : name.substring(0, star);
|
||||||
|
String login = star < 0 ? name : name.substring(star + 1);
|
||||||
|
User user = users.findByEmail(login).block();
|
||||||
|
if (user == null || !user.isActive() || user.isShared() || !userService.checkPassword(user, password)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (mailbox.equals(login)) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
User target = users.findByEmail(mailbox).block();
|
||||||
|
return target != null && target.isActive() && Boolean.TRUE.equals(has(target.getId(), user.getId()).block())
|
||||||
|
? target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The login may open this mailbox (own or by access). */
|
||||||
|
public Mono<Boolean> has(Long mailboxId, Long userId) {
|
||||||
|
if (mailboxId.equals(userId)) {
|
||||||
|
return Mono.just(true);
|
||||||
|
}
|
||||||
|
return db.sql("SELECT 1 FROM mailbox_access WHERE mailbox_id = :m AND user_id = :u")
|
||||||
|
.bind("m", mailboxId).bind("u", userId).map((row, meta) -> true).one().defaultIfEmpty(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own mailbox first, then the others by address. */
|
||||||
|
public Flux<Entry> mailboxes(User login) {
|
||||||
|
Mono<Entry> own = unread(login.getId()).map(n -> new Entry(login.getEmail(), login.getDisplayName(), "OWN", false, n));
|
||||||
|
Flux<Entry> others = db.sql("SELECT u.id, u.email, u.display_name, u.is_shared, a.kind FROM mailbox_access a "
|
||||||
|
+ "JOIN users u ON u.id = a.mailbox_id WHERE a.user_id = :u AND u.is_active ORDER BY u.email")
|
||||||
|
.bind("u", login.getId())
|
||||||
|
.map((row, meta) -> new Object[]{row.get("id", Long.class), row.get("email", String.class),
|
||||||
|
row.get("display_name", String.class), Boolean.TRUE.equals(row.get("is_shared", Boolean.class)),
|
||||||
|
row.get("kind", String.class)})
|
||||||
|
.all()
|
||||||
|
.concatMap(r -> unread((Long) r[0]).map(n -> new Entry((String) r[1], (String) r[2], (String) r[4], (Boolean) r[3], n)));
|
||||||
|
return own.flux().concatWith(others);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Long> unread(Long mailboxId) {
|
||||||
|
return db.sql("SELECT COUNT(*) AS n FROM messages m JOIN folders f ON f.id = m.folder_id "
|
||||||
|
+ "WHERE f.user_id = :u AND NOT m.is_read AND NOT m.is_deleted AND (NOT f.is_system OR f.name = 'INBOX')")
|
||||||
|
.bind("u", mailboxId).map((row, meta) -> row.get("n", Long.class)).one().defaultIfEmpty(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> grant(Long mailboxId, Long userId, String kind) {
|
||||||
|
return db.sql("INSERT INTO mailbox_access (mailbox_id, user_id, kind) VALUES (:m, :u, :k) "
|
||||||
|
+ "ON CONFLICT (mailbox_id, user_id) DO NOTHING")
|
||||||
|
.bind("m", mailboxId).bind("u", userId).bind("k", kind).then();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> revoke(Long mailboxId, Long userId) {
|
||||||
|
return db.sql("DELETE FROM mailbox_access WHERE mailbox_id = :m AND user_id = :u")
|
||||||
|
.bind("m", mailboxId).bind("u", userId).fetch().rowsUpdated().map(n -> n > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exactly these MEMBERs (ATTACHED ones stay — they know the password). */
|
||||||
|
@Transactional
|
||||||
|
public Mono<Void> setMembers(Long mailboxId, Collection<Long> memberIds) {
|
||||||
|
return db.sql("DELETE FROM mailbox_access WHERE mailbox_id = :m AND kind = :k").bind("m", mailboxId).bind("k", MEMBER).then()
|
||||||
|
.thenMany(Flux.fromIterable(memberIds).distinct().filter(id -> !id.equals(mailboxId))
|
||||||
|
.concatMap(id -> db.sql("INSERT INTO mailbox_access (mailbox_id, user_id, kind) VALUES (:m, :u, :k) "
|
||||||
|
+ "ON CONFLICT (mailbox_id, user_id) DO UPDATE SET kind = EXCLUDED.kind")
|
||||||
|
.bind("m", mailboxId).bind("u", id).bind("k", MEMBER).then()))
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<String> members(Long mailboxId) {
|
||||||
|
return db.sql("SELECT u.email FROM mailbox_access a JOIN users u ON u.id = a.user_id "
|
||||||
|
+ "WHERE a.mailbox_id = :m AND a.kind = :k ORDER BY u.email")
|
||||||
|
.bind("m", mailboxId).bind("k", MEMBER).map((row, meta) -> row.get("email", String.class)).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The login loses every MEMBER access (role removed in Jambotron); ATTACHED stays with the mailbox owner's say. */
|
||||||
|
public Mono<Long> removeEverywhere(Long userId) {
|
||||||
|
return db.sql("DELETE FROM mailbox_access WHERE user_id = :u AND kind = :k").bind("u", userId).bind("k", MEMBER)
|
||||||
|
.fetch().rowsUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reply from the web client: the original is answered, and by whom. */
|
||||||
|
public Mono<Void> markAnswered(Long messageId, Long mailboxId, String by) {
|
||||||
|
return db.sql("UPDATE messages SET is_answered = TRUE, answered_by = :b WHERE id = :id "
|
||||||
|
+ "AND folder_id IN (SELECT id FROM folders WHERE user_id = :u)")
|
||||||
|
.bind("b", by).bind("id", messageId).bind("u", mailboxId).then();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,9 +50,11 @@ public class SubmissionService {
|
|||||||
private final MailService mailService;
|
private final MailService mailService;
|
||||||
private final MailRouter router;
|
private final MailRouter router;
|
||||||
private final String hostname;
|
private final String hostname;
|
||||||
|
private final MailboxAccess access;
|
||||||
|
|
||||||
public SubmissionService(UserRepository users, MailService mailService, MailRouter router,
|
public SubmissionService(UserRepository users, MailService mailService, MailRouter router,
|
||||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname, MailboxAccess access) {
|
||||||
|
this.access = access;
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.mailService = mailService;
|
this.mailService = mailService;
|
||||||
this.router = router;
|
this.router = router;
|
||||||
@@ -60,6 +62,11 @@ public class SubmissionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
|
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
|
||||||
|
return send(fromEmail, fromEmail, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param login who sends (the member for a shared mailbox) — recorded as «answered by» on the original */
|
||||||
|
public Mono<MessageResponse> send(String fromEmail, String login, SendRequest request) {
|
||||||
List<String> to = addresses(request.getTo());
|
List<String> to = addresses(request.getTo());
|
||||||
List<String> cc = addresses(request.getCc());
|
List<String> cc = addresses(request.getCc());
|
||||||
cc.removeAll(to);
|
cc.removeAll(to);
|
||||||
@@ -78,7 +85,9 @@ public class SubmissionService {
|
|||||||
.subscribeOn(Schedulers.boundedElastic()))
|
.subscribeOn(Schedulers.boundedElastic()))
|
||||||
.flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc),
|
.flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc),
|
||||||
request.getSubject(), request.getBody(), request.isHtml(), messageId))
|
request.getSubject(), request.getBody(), request.isHtml(), messageId))
|
||||||
.flatMap(sent -> (draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
|
.flatMap(sent -> (request.getReplyToId() == null ? Mono.<Void>empty()
|
||||||
|
: access.markAnswered(request.getReplyToId(), user.getId(), login))
|
||||||
|
.then(draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
|
||||||
.then(mailService.getMessage(sent.getId(), user.getId()))));
|
.then(mailService.getMessage(sent.getId(), user.getId()))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +97,10 @@ public class SubmissionService {
|
|||||||
for (String address : concat(to, cc)) {
|
for (String address : concat(to, cc)) {
|
||||||
SmtpBackend.Route route = router.route(address, user.getEmail());
|
SmtpBackend.Route route = router.route(address, user.getEmail());
|
||||||
switch (route) {
|
switch (route) {
|
||||||
case LOCAL, OUTBOUND -> recipients.add(new SmtpBackend.Recipient(address, route));
|
case LOCAL, OUTBOUND, TRAP -> recipients.add(new SmtpBackend.Recipient(address, route));
|
||||||
case UNKNOWN_USER -> throw MailService.badRequest("No such mailbox: " + address);
|
case UNKNOWN_USER -> throw MailService.badRequest("No such mailbox: " + address);
|
||||||
case OUTBOUND_DISABLED -> throw MailService.badRequest("Sending outside is disabled on this server: " + address);
|
case OUTBOUND_DISABLED -> throw MailService.badRequest("Sending outside is disabled on this server: " + address);
|
||||||
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
|
case RELAY_DENIED, LIST_CLOSED -> throw new IllegalStateException("logged-in user denied: " + route);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String messageId = UUID.randomUUID() + "@" + hostname;
|
String messageId = UUID.randomUUID() + "@" + hostname;
|
||||||
|
|||||||
@@ -74,6 +74,40 @@ public class UserService {
|
|||||||
return userRepository.save(user).then();
|
return userRepository.save(user).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The mailbox, or empty (no error). */
|
||||||
|
public Mono<User> findOptional(String email) {
|
||||||
|
return userRepository.findByEmail(email.toLowerCase(java.util.Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
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> setShared(User user, boolean shared) {
|
||||||
|
user.setShared(shared);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<User> setTrap(User user, boolean trap) {
|
||||||
|
user.setTrap(trap);
|
||||||
|
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);
|
||||||
|
|||||||
@@ -7,16 +7,26 @@ import com.mailserver.domain.User;
|
|||||||
import com.mailserver.repository.UserRepository;
|
import com.mailserver.repository.UserRepository;
|
||||||
import com.mailserver.service.DkimService;
|
import com.mailserver.service.DkimService;
|
||||||
import com.mailserver.service.LocalDelivery;
|
import com.mailserver.service.LocalDelivery;
|
||||||
|
import com.mailserver.service.MailLists;
|
||||||
|
import com.mailserver.service.MailboxAccess;
|
||||||
import com.mailserver.service.UserService;
|
import com.mailserver.service.UserService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where mail goes — one rule for SMTP and for the web client:
|
* Where mail goes — one rule for SMTP and for the web client:
|
||||||
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
|
* <ul>
|
||||||
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox →
|
* <li>a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);</li>
|
||||||
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
|
* <li>a mailing list of ours ({@code alerts-prod@}) → a copy to every member, into the list's folder
|
||||||
|
* (or the "+tag" folder); a closed list takes mail only from logged-in senders;</li>
|
||||||
|
* <li>a TRAP sender (staging@) and an address that is not ours → nothing leaves: one copy into the sender's INBOX
|
||||||
|
* (our own mailboxes and lists still get their mail);</li>
|
||||||
|
* <li>catch-all set → everything else into the catch-all mailbox; otherwise our domain → "no such user",
|
||||||
|
* someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class MailRouter implements SmtpBackend {
|
public class MailRouter implements SmtpBackend {
|
||||||
@@ -28,9 +38,13 @@ public class MailRouter implements SmtpBackend {
|
|||||||
private final OutboundQueue queue;
|
private final OutboundQueue queue;
|
||||||
private final DkimService dkim;
|
private final DkimService dkim;
|
||||||
private final SmtpDeliveryClient client;
|
private final SmtpDeliveryClient client;
|
||||||
|
private final MailLists lists;
|
||||||
|
private final MailboxAccess access;
|
||||||
|
|
||||||
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
|
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
|
||||||
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client) {
|
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists,
|
||||||
|
MailboxAccess access) {
|
||||||
|
this.access = access;
|
||||||
this.domains = domains;
|
this.domains = domains;
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
@@ -38,19 +52,30 @@ public class MailRouter implements SmtpBackend {
|
|||||||
this.queue = queue;
|
this.queue = queue;
|
||||||
this.dkim = dkim;
|
this.dkim = dkim;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
this.lists = lists;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean authenticate(String username, String password) {
|
public String authenticate(String username, String password) {
|
||||||
User user = users.findByEmail(username.toLowerCase(Locale.ROOT)).block();
|
User mailbox = access.login(username, password);
|
||||||
return user != null && user.isActive() && userService.checkPassword(user, password);
|
return mailbox == null ? null : mailbox.getEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Route route(String recipient, String authenticatedUser) {
|
public Route route(String recipient, String authenticatedUser) {
|
||||||
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
|
if (domains.isLocal(recipient)) {
|
||||||
|
String base = MailDomains.baseOf(recipient);
|
||||||
|
if (mailboxExists(recipient) || mailboxExists(base)) {
|
||||||
return Route.LOCAL;
|
return Route.LOCAL;
|
||||||
}
|
}
|
||||||
|
MailLists.MailList list = lists.find(base).block();
|
||||||
|
if (list != null) {
|
||||||
|
return list.openToOutside() || authenticatedUser != null ? Route.LOCAL : Route.LIST_CLOSED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isTrap(authenticatedUser)) {
|
||||||
|
return Route.TRAP;
|
||||||
|
}
|
||||||
if (domains.catchAll() != null) {
|
if (domains.catchAll() != null) {
|
||||||
return Route.LOCAL;
|
return Route.LOCAL;
|
||||||
}
|
}
|
||||||
@@ -66,13 +91,13 @@ public class MailRouter implements SmtpBackend {
|
|||||||
@Override
|
@Override
|
||||||
public void accept(Envelope envelope, byte[] message) throws Exception {
|
public void accept(Envelope envelope, byte[] message) throws Exception {
|
||||||
boolean trusted = envelope.authenticatedUser() != null;
|
boolean trusted = envelope.authenticatedUser() != null;
|
||||||
|
// one copy per mailbox + folder, even when an address and a list both lead there
|
||||||
|
Set<Target> targets = new LinkedHashSet<>();
|
||||||
byte[] signed = null;
|
byte[] signed = null;
|
||||||
for (Recipient recipient : envelope.recipients()) {
|
for (Recipient recipient : envelope.recipients()) {
|
||||||
switch (recipient.route()) {
|
switch (recipient.route()) {
|
||||||
case LOCAL -> {
|
case LOCAL -> targets.addAll(targets(recipient.address()));
|
||||||
String mailbox = mailbox(recipient.address());
|
case TRAP -> targets.add(new Target(envelope.authenticatedUser().toLowerCase(Locale.ROOT), null));
|
||||||
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
|
|
||||||
}
|
|
||||||
case OUTBOUND -> {
|
case OUTBOUND -> {
|
||||||
if (signed == null) {
|
if (signed == null) {
|
||||||
signed = dkim.sign(message);
|
signed = dkim.sign(message);
|
||||||
@@ -82,14 +107,56 @@ public class MailRouter implements SmtpBackend {
|
|||||||
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
|
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (Target target : targets) {
|
||||||
|
localDelivery.deliver(message, target.mailbox(), target.folder(), envelope.remoteIp(), envelope.mailFrom(), trusted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mailbox that stores mail to this address: its own if it exists here, otherwise the catch-all one. */
|
/** Mailbox + folder (null — INBOX) that store mail to a local address. */
|
||||||
String mailbox(String address) {
|
record Target(String mailbox, String folder) {
|
||||||
if (domains.catchAll() == null || (domains.isLocal(address) && mailboxExists(address))) {
|
|
||||||
return address;
|
|
||||||
}
|
}
|
||||||
return domains.catchAll();
|
|
||||||
|
Set<Target> targets(String address) {
|
||||||
|
Set<Target> result = new LinkedHashSet<>();
|
||||||
|
String lower = address.toLowerCase(Locale.ROOT);
|
||||||
|
if (domains.isLocal(lower)) {
|
||||||
|
if (mailboxExists(lower)) {
|
||||||
|
result.add(new Target(lower, null));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String base = MailDomains.baseOf(lower);
|
||||||
|
String tag = MailDomains.tagOf(address);
|
||||||
|
if (mailboxExists(base)) {
|
||||||
|
result.add(new Target(base, tag));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
MailLists.MailList list = lists.find(base).block();
|
||||||
|
if (list != null) {
|
||||||
|
for (String member : list.members()) {
|
||||||
|
result.add(new Target(member, tag != null ? tag : list.folder()));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (domains.catchAll() != null) {
|
||||||
|
result.add(new Target(domains.catchAll(), null));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mailbox that stores mail to this address: its own (without "+tag") if it exists here, otherwise the catch-all one. */
|
||||||
|
String mailbox(String address) {
|
||||||
|
Set<Target> found = targets(address);
|
||||||
|
return found.isEmpty() ? address : found.iterator().next().mailbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The logged-in sender is a trap mailbox. */
|
||||||
|
boolean isTrap(String authenticatedUser) {
|
||||||
|
if (authenticatedUser == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
User user = users.findByEmail(authenticatedUser.toLowerCase(Locale.ROOT)).block();
|
||||||
|
return user != null && user.isTrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean mailboxExists(String address) {
|
private boolean mailboxExists(String address) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,12 @@ public interface SmtpBackend {
|
|||||||
|
|
||||||
/** Where a recipient goes, decided at RCPT TO. */
|
/** Where a recipient goes, decided at RCPT TO. */
|
||||||
enum Route {
|
enum Route {
|
||||||
/** A mailbox of ours (or the catch-all mailbox) — store it. */
|
/** A mailbox or mailing list of ours (or the catch-all mailbox) — store it. */
|
||||||
LOCAL,
|
LOCAL,
|
||||||
|
/** The sender is a trap mailbox and the address is not ours: one copy into the sender's own INBOX. */
|
||||||
|
TRAP,
|
||||||
|
/** A mailing list of ours that takes mail only from logged-in senders. */
|
||||||
|
LIST_CLOSED,
|
||||||
/** Someone else's address, sender is logged in — queue for delivery. */
|
/** Someone else's address, sender is logged in — queue for delivery. */
|
||||||
OUTBOUND,
|
OUTBOUND,
|
||||||
/** Address of our domain that does not exist. */
|
/** Address of our domain that does not exist. */
|
||||||
@@ -28,8 +32,11 @@ public interface SmtpBackend {
|
|||||||
record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List<Recipient> recipients) {
|
record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List<Recipient> recipients) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** true — the address and password belong to an active mailbox. */
|
/**
|
||||||
boolean authenticate(String username, String password);
|
* {@code login} or {@code mailbox*login} with the login's password → the mailbox address the session works as,
|
||||||
|
* or null.
|
||||||
|
*/
|
||||||
|
String authenticate(String username, String password);
|
||||||
|
|
||||||
Route route(String recipient, String authenticatedUser);
|
Route route(String recipient, String authenticatedUser);
|
||||||
|
|
||||||
|
|||||||
@@ -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,33 @@ 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);
|
||||||
return blocking(() -> backend.authenticate(username, password))
|
Duration wait = guard.retryAfter(remoteIp, username);
|
||||||
.map(ok -> {
|
if (!wait.isZero()) {
|
||||||
if (ok) {
|
// Blocked: the password is not even checked, the connection is closed
|
||||||
authenticatedUser = username;
|
log.warn("SMTP {}: login as {} refused, blocked for {}", remoteIp, username, LoginGuard.human(wait));
|
||||||
log.info("SMTP {}: authenticated as {}", remoteIp, username);
|
closing = true;
|
||||||
return "235 2.7.0 Authentication successful\r\n";
|
return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait));
|
||||||
}
|
}
|
||||||
|
return blocking(() -> java.util.Optional.ofNullable(backend.authenticate(username, password)))
|
||||||
|
.flatMap(found -> {
|
||||||
|
if (found.isPresent()) {
|
||||||
|
guard.success(remoteIp, username);
|
||||||
|
authenticatedUser = found.get();
|
||||||
|
log.info("SMTP {}: authenticated as {}{}", remoteIp, found.get(),
|
||||||
|
found.get().equalsIgnoreCase(username) ? "" : " (login " + username + ")");
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +439,7 @@ public class SmtpServer {
|
|||||||
String user = authenticatedUser;
|
String user = authenticatedUser;
|
||||||
return blocking(() -> backend.route(address, user))
|
return blocking(() -> backend.route(address, user))
|
||||||
.map(route -> switch (route) {
|
.map(route -> switch (route) {
|
||||||
case LOCAL, OUTBOUND -> {
|
case LOCAL, OUTBOUND, TRAP -> {
|
||||||
recipients.add(new SmtpBackend.Recipient(address, route));
|
recipients.add(new SmtpBackend.Recipient(address, route));
|
||||||
yield "250 2.1.5 Recipient OK\r\n";
|
yield "250 2.1.5 Recipient OK\r\n";
|
||||||
}
|
}
|
||||||
@@ -422,6 +449,7 @@ public class SmtpServer {
|
|||||||
yield "550 5.7.1 Relaying denied: authentication required\r\n";
|
yield "550 5.7.1 Relaying denied: authentication required\r\n";
|
||||||
}
|
}
|
||||||
case OUTBOUND_DISABLED -> "550 5.7.1 Outbound delivery is disabled on this server\r\n";
|
case OUTBOUND_DISABLED -> "550 5.7.1 Outbound delivery is disabled on this server\r\n";
|
||||||
|
case LIST_CLOSED -> "550 5.7.1 This list takes mail only from logged-in senders\r\n";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,20 @@ public class JwtUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String generateToken(String email) {
|
public String generateToken(String email) {
|
||||||
|
return generateToken(email, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param by the login that opened this mailbox (shared / attached mailbox, administrator's OPEN), or null —
|
||||||
|
* the mailbox itself logged in
|
||||||
|
*/
|
||||||
|
public String generateToken(String email, String by) {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiry = new Date(now.getTime() + EXPIRATION_MS);
|
Date expiry = new Date(now.getTime() + EXPIRATION_MS);
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.subject(email)
|
.subject(email)
|
||||||
|
.claim("by", by)
|
||||||
.issuedAt(now)
|
.issuedAt(now)
|
||||||
.expiration(expiry)
|
.expiration(expiry)
|
||||||
.signWith(signingKey)
|
.signWith(signingKey)
|
||||||
@@ -38,6 +47,12 @@ public class JwtUtils {
|
|||||||
return claims.getSubject();
|
return claims.getSubject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "by" claim: who opened this mailbox, or null. */
|
||||||
|
public String getByFromToken(String token) {
|
||||||
|
Object by = Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token).getPayload().get("by");
|
||||||
|
return by == null ? null : by.toString();
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isValidToken(String token) {
|
public boolean isValidToken(String token) {
|
||||||
try {
|
try {
|
||||||
Jwts.parser()
|
Jwts.parser()
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ 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:}
|
||||||
|
# Service API for Jambotron (/api/service/**, staff mailboxes and lists); empty — the API is off.
|
||||||
|
# In the compose files: generated by secrets-init into the secrets volume (file SERVICE_TOKEN).
|
||||||
|
mailserver.service-token=${SERVICE_TOKEN:}
|
||||||
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
||||||
|
|
||||||
# Attachments
|
# Attachments
|
||||||
@@ -47,6 +52,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 +90,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,3 @@
|
|||||||
|
-- Trap mailbox: what it sends is NOT delivered to anyone — every copy lands in its own INBOX (staging@: test mail
|
||||||
|
-- of a staging stand never reaches real people). Switched on by an administrator in Settings → MAILBOXES.
|
||||||
|
ALTER TABLE users ADD COLUMN is_trap BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Mailing lists (alerts-prod@ …): mail to the list address → a copy into the mailbox of EVERY member,
|
||||||
|
-- into the list's folder (created on first mail). Each member has their own copy — their own read/unread.
|
||||||
|
-- open_to_outside = false: only logged-in senders (Kuma, Jambotron) may write to the list.
|
||||||
|
CREATE TABLE mail_lists (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
address VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
folder VARCHAR(50),
|
||||||
|
description VARCHAR(255),
|
||||||
|
open_to_outside BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE mail_list_members (
|
||||||
|
list_id BIGINT NOT NULL REFERENCES mail_lists(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (list_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_mail_list_members_user ON mail_list_members(user_id);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Several mailboxes for one login (75.9):
|
||||||
|
-- shared mailbox (support@, billing@): no login of its own, members open it from their mailbox; read/unread is
|
||||||
|
-- common — a team inbox; answered_by shows who replied;
|
||||||
|
-- attached mailbox: a login adds another mailbox it knows the password of.
|
||||||
|
ALTER TABLE users ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE mailbox_access (
|
||||||
|
mailbox_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
-- MEMBER: given by an administrator / Jambotron; ATTACHED: added by the user with the mailbox password
|
||||||
|
kind VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (mailbox_id, user_id),
|
||||||
|
CHECK (mailbox_id <> user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_mailbox_access_user ON mailbox_access(user_id);
|
||||||
|
|
||||||
|
-- who answered a message (web client reply; mostly for shared mailboxes)
|
||||||
|
ALTER TABLE messages ADD COLUMN answered_by VARCHAR(255);
|
||||||
@@ -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,19 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** "+tag" addresses: the base address is the mailbox, the tag is the folder. */
|
||||||
|
class MailDomainsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void baseAndTag() {
|
||||||
|
assertThat(MailDomains.baseOf("Bob+Prod@Mail.test")).isEqualTo("bob@mail.test");
|
||||||
|
assertThat(MailDomains.tagOf("Bob+Prod@Mail.test")).isEqualTo("Prod");
|
||||||
|
assertThat(MailDomains.baseOf("bob@mail.test")).isEqualTo("bob@mail.test");
|
||||||
|
assertThat(MailDomains.tagOf("bob@mail.test")).isNull();
|
||||||
|
assertThat(MailDomains.tagOf("bob+@mail.test")).isNull();
|
||||||
|
assertThat(MailDomains.tagOf("+x@mail.test")).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,222 @@
|
|||||||
|
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 com.mailserver.service.MailLists lists = mock(com.mailserver.service.MailLists.class);
|
||||||
|
private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
|
||||||
|
private final com.mailserver.service.MailDirectory directory = new com.mailserver.service.MailDirectory(users, lists, domains, access);
|
||||||
|
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists, access);
|
||||||
|
|
||||||
|
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(lists.exists(anyString())).thenReturn(Mono.just(false));
|
||||||
|
when(access.members(anyLong())).thenReturn(Flux.empty());
|
||||||
|
when(lists.exists("alerts@jambocheck.com")).thenReturn(Mono.just(true));
|
||||||
|
when(users.findOptional(anyString())).thenReturn(Mono.empty());
|
||||||
|
when(users.findOptional("alice@jambocheck.com")).thenReturn(Mono.just(alice));
|
||||||
|
when(lists.save(anyString(), any(), any(), any(Boolean.class), any())).thenAnswer(i ->
|
||||||
|
Mono.just(new com.mailserver.service.MailLists.MailList(5L, i.getArgument(0), i.getArgument(1), i.getArgument(2),
|
||||||
|
i.getArgument(3), List.of("alice@jambocheck.com"))));
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
when(users.setTrap(any(), any(Boolean.class))).thenAnswer(i -> {
|
||||||
|
User u = i.getArgument(0);
|
||||||
|
u.setTrap(i.getArgument(1));
|
||||||
|
return Mono.just(u);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mailboxAndListShareOneAddressSpaceNoPlusInNames() {
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "alerts@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "bob+x@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(create("admin@jambocheck.com", "jambotron_prod@jambocheck.com", "").mailbox().email()).isEqualTo("jambotron_prod@jambocheck.com");
|
||||||
|
|
||||||
|
var request = new MailboxAdminController.ListRequest("prod", "alerts", false, List.of("Alice@jambocheck.com"));
|
||||||
|
assertThat(controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block().members())
|
||||||
|
.containsExactly("alice@jambocheck.com");
|
||||||
|
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alice@jambocheck.com", Mono.just(request)).block()))
|
||||||
|
.isEqualTo(HttpStatus.CONFLICT);
|
||||||
|
var unknown = new MailboxAdminController.ListRequest("prod", null, false, List.of("ghost@jambocheck.com"));
|
||||||
|
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(unknown)).block()))
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
var badFolder = new MailboxAdminController.ListRequest("Trash", null, false, List.of());
|
||||||
|
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(badFolder)).block()))
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(status(() -> controller.saveList(as("alice@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block()))
|
||||||
|
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sharedMailboxHasNoPasswordShownMembersAreSet() {
|
||||||
|
when(users.setShared(any(), any(Boolean.class))).thenAnswer(i -> {
|
||||||
|
User u = i.getArgument(0);
|
||||||
|
u.setShared(i.getArgument(1));
|
||||||
|
return Mono.just(u);
|
||||||
|
});
|
||||||
|
var created = controller.create(as("admin@jambocheck.com"),
|
||||||
|
Mono.just(new MailboxAdminController.CreateRequest("support@jambocheck.com", "Support", "", true))).block();
|
||||||
|
assertThat(created.password()).isNull();
|
||||||
|
assertThat(created.mailbox().shared()).isTrue();
|
||||||
|
|
||||||
|
when(access.setMembers(anyLong(), any())).thenReturn(Mono.empty());
|
||||||
|
controller.members(as("admin@jambocheck.com"), 2L,
|
||||||
|
Mono.just(new MailboxAdminController.MembersRequest(List.of("Alice@jambocheck.com")))).block();
|
||||||
|
verify(access).setMembers(eq(2L), eq(List.of(2L)));
|
||||||
|
assertThat(status(() -> controller.members(as("admin@jambocheck.com"), 2L,
|
||||||
|
Mono.just(new MailboxAdminController.MembersRequest(List.of("ghost@jambocheck.com")))).block())).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administratorSwitchesTrapOthersCanNot() {
|
||||||
|
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
|
||||||
|
assertThat(alice.isTrap()).isTrue();
|
||||||
|
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(false))).block().trap()).isFalse();
|
||||||
|
assertThat(status(() -> controller.trap(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block()))
|
||||||
|
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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", "", null))).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", null))).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 administratorDeletesAMailboxButNotHisOwnOthersCanNot() {
|
||||||
|
when(mail.deleteMailbox(any())).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
controller.delete(as("admin@jambocheck.com"), 2L).block();
|
||||||
|
verify(mail).deleteMailbox(alice);
|
||||||
|
|
||||||
|
assertThat(status(() -> controller.delete(as("admin@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(status(() -> controller.delete(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
assertThat(status(() -> controller.delete(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
verify(mail, never()).deleteMailbox(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administratorOpensAnyMailboxOthersCanNot() {
|
||||||
|
when(jwt.generateToken("alice@jambocheck.com", "admin@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, null))).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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.service.MailDirectory;
|
||||||
|
import com.mailserver.service.MailLists;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
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.catchThrowable;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/** Service API for Jambotron: token only; account created once with a password; switched off → out of the lists. */
|
||||||
|
class ServiceControllerTest {
|
||||||
|
|
||||||
|
private static final String AUTH = "Bearer s3cret-token";
|
||||||
|
|
||||||
|
private final UserService users = mock(UserService.class);
|
||||||
|
private final MailLists lists = mock(MailLists.class);
|
||||||
|
private final MailDomains domains = new MailDomains("jambocheck.com", "");
|
||||||
|
private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
|
||||||
|
private final MailDirectory directory = new MailDirectory(users, lists, domains, access);
|
||||||
|
private final User existing = user(7L, "jambotronpw@jambocheck.com");
|
||||||
|
|
||||||
|
ServiceControllerTest() {
|
||||||
|
when(users.findOptional(anyString())).thenReturn(Mono.empty());
|
||||||
|
when(users.findOptional("jambotronpw@jambocheck.com")).thenReturn(Mono.just(existing));
|
||||||
|
when(lists.exists(anyString())).thenReturn(Mono.just(false));
|
||||||
|
when(lists.listsOf(anyLong())).thenReturn(Flux.just("alerts-prod@jambocheck.com"));
|
||||||
|
when(lists.removeEverywhere(anyLong())).thenReturn(Mono.just(1L));
|
||||||
|
when(access.removeEverywhere(anyLong())).thenReturn(Mono.just(1L));
|
||||||
|
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(8L, 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);
|
||||||
|
});
|
||||||
|
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServiceController api(String token) {
|
||||||
|
return new ServiceController(token, users, lists, directory, access);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void offWithoutTokenWrongTokenRefused() {
|
||||||
|
assertThat(status(() -> api("").account(AUTH, "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
assertThat(status(() -> api("s3cret-token").account("Bearer nope", "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
assertThat(status(() -> api("s3cret-token").account(null, "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
assertThat(api("s3cret-token").account(AUTH, "jambotronpw@jambocheck.com").block().lists())
|
||||||
|
.containsExactly("alerts-prod@jambocheck.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newAccountGetsPasswordOnceExistingDoesNot() {
|
||||||
|
var created = api("s3cret-token").saveAccount(AUTH, "moder@jambocheck.com",
|
||||||
|
Mono.just(new ServiceController.AccountRequest("Moder", true))).block();
|
||||||
|
assertThat(created.getStatusCode().value()).isEqualTo(201);
|
||||||
|
assertThat(created.getBody().password()).hasSizeGreaterThan(20);
|
||||||
|
|
||||||
|
var again = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
|
||||||
|
Mono.just(new ServiceController.AccountRequest(null, null))).block();
|
||||||
|
assertThat(again.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(again.getBody().password()).isNull();
|
||||||
|
verify(lists, never()).removeEverywhere(anyLong());
|
||||||
|
|
||||||
|
assertThat(status(() -> api("s3cret-token").saveAccount(AUTH, "x@gmail.com", Mono.empty()))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void switchedOffLeavesEveryList() {
|
||||||
|
var off = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
|
||||||
|
Mono.just(new ServiceController.AccountRequest(null, false))).block();
|
||||||
|
assertThat(off.getBody().account().active()).isFalse();
|
||||||
|
verify(lists).removeEverywhere(7L);
|
||||||
|
verify(access).removeEverywhere(7L);
|
||||||
|
|
||||||
|
var reset = api("s3cret-token").password(AUTH, "jambotronpw@jambocheck.com").block();
|
||||||
|
assertThat(reset.password()).isNotBlank();
|
||||||
|
verify(users).setPassword(existing, reset.password());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpStatus status(Runnable call) {
|
||||||
|
Throwable error = 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,24 @@
|
|||||||
|
package com.mailserver.security;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** The login behind a session: own session, or a member who opened a shared mailbox. */
|
||||||
|
class ActorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownAndOpened() {
|
||||||
|
var own = new UsernamePasswordAuthenticationToken("bob@x", null, List.of());
|
||||||
|
assertThat(Actor.login(own)).isEqualTo("bob@x");
|
||||||
|
assertThat(Actor.opened(own)).isFalse();
|
||||||
|
|
||||||
|
var opened = new UsernamePasswordAuthenticationToken("support@x", null, List.of());
|
||||||
|
opened.setDetails("bob@x");
|
||||||
|
assertThat(Actor.login(opened)).isEqualTo("bob@x");
|
||||||
|
assertThat(Actor.opened(opened)).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,21 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** A "+tag" never puts mail into a system folder and never makes a strange folder name. */
|
||||||
|
class DeliveryFolderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyPlainOwnFolderNames() {
|
||||||
|
assertThat(MailService.deliveryFolderName("prod")).isEqualTo("prod");
|
||||||
|
assertThat(MailService.deliveryFolderName("alerts.db-1")).isEqualTo("alerts.db-1");
|
||||||
|
assertThat(MailService.deliveryFolderName(null)).isNull();
|
||||||
|
assertThat(MailService.deliveryFolderName("inbox")).isNull();
|
||||||
|
assertThat(MailService.deliveryFolderName("Trash")).isNull();
|
||||||
|
assertThat(MailService.deliveryFolderName("spam")).isNull();
|
||||||
|
assertThat(MailService.deliveryFolderName("../x")).isNull();
|
||||||
|
assertThat(MailService.deliveryFolderName("a".repeat(51))).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package com.mailserver.smtp;
|
|||||||
import com.mailserver.config.MailDomains;
|
import com.mailserver.config.MailDomains;
|
||||||
import com.mailserver.delivery.OutboundQueue;
|
import com.mailserver.delivery.OutboundQueue;
|
||||||
import com.mailserver.delivery.SmtpDeliveryClient;
|
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
import com.mailserver.repository.UserRepository;
|
import com.mailserver.repository.UserRepository;
|
||||||
import com.mailserver.service.DkimService;
|
import com.mailserver.service.DkimService;
|
||||||
import com.mailserver.service.LocalDelivery;
|
import com.mailserver.service.LocalDelivery;
|
||||||
|
import com.mailserver.service.MailLists;
|
||||||
import com.mailserver.service.UserService;
|
import com.mailserver.service.UserService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -17,6 +19,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -29,13 +32,33 @@ class MailRouterTest {
|
|||||||
private final LocalDelivery localDelivery = mock(LocalDelivery.class);
|
private final LocalDelivery localDelivery = mock(LocalDelivery.class);
|
||||||
private final OutboundQueue queue = mock(OutboundQueue.class);
|
private final OutboundQueue queue = mock(OutboundQueue.class);
|
||||||
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
|
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
|
||||||
|
private final MailLists lists = mock(MailLists.class);
|
||||||
|
|
||||||
|
private SmtpDeliveryClient.Mode mode = SmtpDeliveryClient.Mode.OFF;
|
||||||
|
|
||||||
|
private static User user(String email, boolean trap) {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setTrap(trap);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
private MailRouter router(String catchAll) {
|
private MailRouter router(String catchAll) {
|
||||||
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
|
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
|
||||||
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
|
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
|
||||||
when(client.mode()).thenReturn(SmtpDeliveryClient.Mode.OFF);
|
when(users.findByEmail(anyString())).thenReturn(Mono.empty());
|
||||||
|
when(users.findByEmail("bob@mail.test")).thenReturn(Mono.just(user("bob@mail.test", false)));
|
||||||
|
when(users.findByEmail("staging@mail.test")).thenReturn(Mono.just(user("staging@mail.test", true)));
|
||||||
|
when(users.existsByEmail("staging@mail.test")).thenReturn(Mono.just(true));
|
||||||
|
when(users.existsByEmail("ann@mail.test")).thenReturn(Mono.just(true));
|
||||||
|
when(client.mode()).thenReturn(mode);
|
||||||
|
when(lists.find(anyString())).thenReturn(Mono.empty());
|
||||||
|
when(lists.find("alerts-prod@mail.test")).thenReturn(Mono.just(new MailLists.MailList(1L, "alerts-prod@mail.test",
|
||||||
|
"prod", null, false, List.of("bob@mail.test", "ann@mail.test"))));
|
||||||
|
when(lists.find("talk@mail.test")).thenReturn(Mono.just(new MailLists.MailList(2L, "talk@mail.test",
|
||||||
|
null, null, true, List.of("bob@mail.test"))));
|
||||||
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
|
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
|
||||||
queue, mock(DkimService.class), client);
|
queue, mock(DkimService.class), client, lists, mock(com.mailserver.service.MailboxAccess.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -61,8 +84,70 @@ class MailRouterTest {
|
|||||||
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
|
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
|
||||||
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
|
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
|
||||||
|
|
||||||
verify(localDelivery).deliver(any(), eq("trap@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
|
verify(localDelivery).deliver(any(), eq("trap@mail.test"), isNull(), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
|
||||||
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
|
verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
|
||||||
verify(queue, never()).enqueue(any(), any(), any());
|
verify(queue, never()).enqueue(any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trapMailboxKeepsOutsideMailButOurAddressesStillGetIt() throws Exception {
|
||||||
|
mode = SmtpDeliveryClient.Mode.MX;
|
||||||
|
MailRouter router = router("");
|
||||||
|
|
||||||
|
// an ordinary mailbox still sends outside; the trap one keeps outside mail, ours is delivered
|
||||||
|
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND);
|
||||||
|
assertThat(router.route("user@gmail.com", "staging@mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
|
||||||
|
assertThat(router.route("user@gmail.com", "Staging@Mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
|
||||||
|
assertThat(router.route("nobody@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
|
||||||
|
assertThat(router.route("bob@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||||
|
assertThat(router.route("alerts-prod@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||||
|
|
||||||
|
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "staging@mail.test", "staging@mail.test", List.of(
|
||||||
|
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.TRAP),
|
||||||
|
new SmtpBackend.Recipient("other@yahoo.com", SmtpBackend.Route.TRAP),
|
||||||
|
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
|
||||||
|
|
||||||
|
verify(localDelivery).deliver(any(), eq("staging@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
|
||||||
|
verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
|
||||||
|
verify(queue, never()).enqueue(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plusTagGoesToThatFolderOfTheMailbox() throws Exception {
|
||||||
|
MailRouter router = router("");
|
||||||
|
|
||||||
|
assertThat(router.route("bob+prod@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||||
|
assertThat(router.route("nobody+prod@mail.test", null)).isEqualTo(SmtpBackend.Route.UNKNOWN_USER);
|
||||||
|
|
||||||
|
router.accept(new SmtpBackend.Envelope("10.0.0.5", "x", "a@b.c", null, List.of(
|
||||||
|
new SmtpBackend.Recipient("Bob+Prod@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
|
||||||
|
|
||||||
|
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("Prod"), eq("10.0.0.5"), eq("a@b.c"), eq(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listGivesEveryMemberOwnCopyInTheListFolderClosedListOnlyForLoggedIn() throws Exception {
|
||||||
|
MailRouter router = router("");
|
||||||
|
|
||||||
|
assertThat(router.route("alerts-prod@mail.test", null)).isEqualTo(SmtpBackend.Route.LIST_CLOSED);
|
||||||
|
assertThat(router.route("alerts-prod@mail.test", "monitor@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||||
|
assertThat(router.route("talk@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||||
|
|
||||||
|
// list + one member directly: the member gets the list copy AND the direct one (different folders), once each
|
||||||
|
router.accept(new SmtpBackend.Envelope("10.0.0.5", "kuma", "monitor@mail.test", "monitor@mail.test", List.of(
|
||||||
|
new SmtpBackend.Recipient("alerts-prod@mail.test", SmtpBackend.Route.LOCAL),
|
||||||
|
new SmtpBackend.Recipient("ALERTS-PROD@mail.test", SmtpBackend.Route.LOCAL),
|
||||||
|
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: down\r\n\r\nbody".getBytes());
|
||||||
|
|
||||||
|
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("prod"), any(), any(), eq(true));
|
||||||
|
verify(localDelivery).deliver(any(), eq("ann@mail.test"), eq("prod"), any(), any(), eq(true));
|
||||||
|
verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), any(), any(), eq(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listTagOverridesTheListFolder() {
|
||||||
|
MailRouter router = router("");
|
||||||
|
assertThat(router.targets("alerts-prod+db@mail.test")).containsExactly(
|
||||||
|
new MailRouter.Target("bob@mail.test", "db"), new MailRouter.Target("ann@mail.test", "db"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -42,8 +43,8 @@ class SmtpServerTest {
|
|||||||
volatile boolean failStore;
|
volatile boolean failStore;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean authenticate(String username, String password) {
|
public String authenticate(String username, String password) {
|
||||||
return password.equals(passwords.get(username));
|
return password.equals(passwords.get(username)) ? username : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -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())) {
|
||||||
|
|||||||
@@ -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 SERVICE_TOKEN; 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 /secrets/SERVICE_TOKEN
|
||||||
|
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:
|
||||||
@@ -16,11 +16,11 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
umask 077
|
umask 077
|
||||||
for name in DB_PASSWORD JWT_SECRET; do
|
for name in DB_PASSWORD JWT_SECRET SERVICE_TOKEN; do
|
||||||
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
|
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
|
||||||
done
|
done
|
||||||
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
|
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
|
||||||
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
|
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
|
||||||
volumes:
|
volumes:
|
||||||
- secrets:/secrets
|
- secrets:/secrets
|
||||||
|
|
||||||
|
|||||||
@@ -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 SERVICE_TOKEN; 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 /secrets/SERVICE_TOKEN
|
||||||
|
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:
|
||||||
@@ -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, MailList, Mailbox, MailboxCreated, MailboxEntry, 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,48 @@ 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, shared = false): Observable<MailboxCreated> {
|
||||||
|
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password, shared });
|
||||||
|
}
|
||||||
|
setMailboxMembers(id: number, members: string[]): Observable<Mailbox> {
|
||||||
|
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/members`, { members });
|
||||||
|
}
|
||||||
|
// the logged-in user's mailboxes (left menu)
|
||||||
|
myMailboxes(): Observable<MailboxEntry[]> { return this.http.get<MailboxEntry[]>('/api/mailboxes'); }
|
||||||
|
mailboxSession(email: string): Observable<{ token: string; email: string }> {
|
||||||
|
return this.http.post<{ token: string; email: string }>(`/api/mailboxes/${encodeURIComponent(email)}/session`, {});
|
||||||
|
}
|
||||||
|
attachMailbox(email: string, password: string): Observable<MailboxEntry> {
|
||||||
|
return this.http.post<MailboxEntry>('/api/mailboxes/attach', { email, password });
|
||||||
|
}
|
||||||
|
leaveMailbox(email: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`/api/mailboxes/${encodeURIComponent(email)}`);
|
||||||
|
}
|
||||||
|
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`, {});
|
||||||
|
}
|
||||||
|
mailLists(): Observable<MailList[]> { return this.http.get<MailList[]>('/api/admin/mailboxes/lists'); }
|
||||||
|
saveMailList(address: string, list: { folder: string; description: string; openToOutside: boolean; members: string[] }): Observable<MailList> {
|
||||||
|
return this.http.put<MailList>(`/api/admin/mailboxes/lists/${encodeURIComponent(address)}`, list);
|
||||||
|
}
|
||||||
|
deleteMailList(address: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`/api/admin/mailboxes/lists/${encodeURIComponent(address)}`);
|
||||||
|
}
|
||||||
|
setMailboxTrap(id: number, trap: boolean): Observable<Mailbox> {
|
||||||
|
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/trap`, { trap });
|
||||||
|
}
|
||||||
|
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
|
||||||
|
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
||||||
|
}
|
||||||
|
deleteMailbox(id: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`/api/admin/mailboxes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 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 }); }
|
||||||
@@ -42,8 +84,8 @@ export class ApiService {
|
|||||||
// compose
|
// compose
|
||||||
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); }
|
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); }
|
||||||
updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
|
updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
|
||||||
send(draft: Draft, draftId: number | null): Observable<Message> {
|
send(draft: Draft, draftId: number | null, replyToId: number | null = null): Observable<Message> {
|
||||||
return this.http.post<Message>('/api/send', { ...draft, draftId });
|
return this.http.post<Message>('/api/send', { ...draft, draftId, replyToId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachments
|
// attachments
|
||||||
|
|||||||
@@ -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,44 @@ 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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to another mailbox of the same login (left menu). Going back to the mailbox an administrator came
|
||||||
|
* from ends the administrator's visit.
|
||||||
|
*/
|
||||||
|
switchTo(response: TokenResponse): void {
|
||||||
|
if (this.openedBy() && response.email === this.openedBy()) {
|
||||||
|
this.backToAdmin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.store(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 +73,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 {
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export interface Message {
|
|||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
hasAttachments: boolean;
|
hasAttachments: boolean;
|
||||||
attachmentCount: number;
|
attachmentCount: number;
|
||||||
|
answeredBy?: string | null; // login that answered it from the web client (shared mailboxes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MailboxAccess.Entry — a mailbox of the logged-in user (left menu) */
|
||||||
|
export interface MailboxEntry {
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
kind: 'OWN' | 'MEMBER' | 'ATTACHED';
|
||||||
|
shared: boolean;
|
||||||
|
unread: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
@@ -53,6 +63,38 @@ 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;
|
||||||
|
trap: boolean; // what it sends to outside addresses stays in its own INBOX (staging@)
|
||||||
|
shared: boolean; // shared mailbox: no login of its own, members open it
|
||||||
|
members: string[]; // who may open it
|
||||||
|
usedBytes: number;
|
||||||
|
quotaBytes: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */
|
||||||
|
export interface MailList {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
folder: string | null;
|
||||||
|
description: string | null;
|
||||||
|
openToOutside: boolean;
|
||||||
|
members: 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 {
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ export class ComposePage {
|
|||||||
readonly body = signal('');
|
readonly body = signal('');
|
||||||
readonly showCc = signal(false);
|
readonly showCc = signal(false);
|
||||||
readonly draftId = signal<number | null>(null);
|
readonly draftId = signal<number | null>(null);
|
||||||
|
/** the message being answered: marked answered (and by whom) when this one is sent */
|
||||||
|
private replyToId: number | null = null;
|
||||||
readonly attachments = signal<Attachment[]>([]);
|
readonly attachments = signal<Attachment[]>([]);
|
||||||
readonly dirty = signal(false);
|
readonly dirty = signal(false);
|
||||||
readonly saving = signal(false);
|
readonly saving = signal(false);
|
||||||
@@ -207,6 +209,7 @@ export class ComposePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fromReply(m: Message, all: boolean): void {
|
private fromReply(m: Message, all: boolean): void {
|
||||||
|
this.replyToId = m.id;
|
||||||
const me = (this.state.profile()?.email ?? '').toLowerCase();
|
const me = (this.state.profile()?.email ?? '').toLowerCase();
|
||||||
this.to.set(m.senderEmail);
|
this.to.set(m.senderEmail);
|
||||||
if (all) {
|
if (all) {
|
||||||
@@ -306,7 +309,7 @@ export class ComposePage {
|
|||||||
const go = () => {
|
const go = () => {
|
||||||
this.sending.set(true);
|
this.sending.set(true);
|
||||||
this.error.set('');
|
this.error.set('');
|
||||||
this.api.send(this.current(), this.draftId()).subscribe({
|
this.api.send(this.current(), this.draftId(), this.replyToId).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.done = true;
|
this.done = true;
|
||||||
this.toasts.ok('Message sent');
|
this.toasts.ok('Message sent');
|
||||||
|
|||||||
@@ -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)' }}
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
}
|
}
|
||||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Date</dt>
|
<dt class="text-slate-500 font-mono text-xs pt-0.5">Date</dt>
|
||||||
<dd class="text-slate-400 font-mono text-xs pt-0.5">{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}</dd>
|
<dd class="text-slate-400 font-mono text-xs pt-0.5">{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}</dd>
|
||||||
|
@if (m.answeredBy) {
|
||||||
|
<dt class="text-slate-500 font-mono text-xs pt-0.5">Answered</dt>
|
||||||
|
<dd class="text-emerald-300 font-mono text-xs pt-0.5 flex items-center gap-1"><mat-icon class="!text-sm">reply</mat-icon> by {{ m.answeredBy }}</dd>
|
||||||
|
}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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 { MailList, Mailbox, errorText } from '../../core/models';
|
||||||
|
import { ToastService } from '../../ui/toast.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings → LISTS (administrators): alerts-prod@ → a copy to every member, into the list's folder.
|
||||||
|
* Lists kept by Jambotron are edited here too, but Jambotron puts its own member set back on its next sync.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-lists-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="lists-title">
|
||||||
|
<h2 id="lists-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-2 border-b border-cyan-500/20 pb-2">
|
||||||
|
<mat-icon class="text-pink-500">forward_to_inbox</mat-icon> LISTS
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-slate-400 mb-4">
|
||||||
|
Mail to a list goes to every member — each gets their own copy (own read/unread) in the list's folder.
|
||||||
|
<span class="font-mono">list+tag@</span> puts it into folder «tag» instead. Closed list: only logged-in senders (Kuma, Jambotron).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form (ngSubmit)="save()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr] gap-3 mb-3">
|
||||||
|
<div><label class="synth-label" for="ls-address">Address</label>
|
||||||
|
<div class="flex items-stretch gap-2">
|
||||||
|
<input id="ls-address" name="address" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
|
||||||
|
placeholder="alerts-prod" autocomplete="off" required [readonly]="editing()">
|
||||||
|
<span class="flex items-center text-cyan-300 font-mono">@</span>
|
||||||
|
<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="ls-folder">Folder (empty — INBOX)</label>
|
||||||
|
<input id="ls-folder" name="folder" class="synth-input" [(ngModel)]="folder" maxlength="50" placeholder="prod" autocomplete="off"></div>
|
||||||
|
<div><label class="synth-label" for="ls-about">Description</label>
|
||||||
|
<input id="ls-about" name="about" class="synth-input" [(ngModel)]="description" maxlength="255" autocomplete="off"></div>
|
||||||
|
<fieldset class="md:col-span-3">
|
||||||
|
<legend class="synth-label">Members</legend>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
@for (m of mailboxes(); track m.id) {
|
||||||
|
<label class="flex items-center gap-1.5 text-sm font-mono" [class.text-slate-500]="!m.active">
|
||||||
|
<input type="checkbox" [checked]="members().has(m.email)" (change)="flip(m.email)"> {{ m.email }}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="md:col-span-3 flex flex-wrap items-center gap-3">
|
||||||
|
<label class="flex items-center gap-1.5 text-sm"><input type="checkbox" name="open" [(ngModel)]="openToOutside"> open to anyone (not only logged-in senders)</label>
|
||||||
|
<span class="ml-auto"></span>
|
||||||
|
@if (editing()) { <button type="button" class="ghost-btn !py-1" (click)="reset()"><mat-icon>close</mat-icon> CANCEL</button> }
|
||||||
|
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()">
|
||||||
|
<mat-icon>{{ editing() ? 'save' : 'add' }}</mat-icon> {{ editing() ? 'SAVE' : 'CREATE' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
|
||||||
|
|
||||||
|
<ul class="divide-y divide-cyan-500/10">
|
||||||
|
@for (l of lists(); track l.id) {
|
||||||
|
<li class="py-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
|
<span class="font-mono text-sm text-cyan-300">{{ l.address }}</span>
|
||||||
|
<span class="text-xs font-mono text-slate-400">→ {{ l.folder || 'INBOX' }}</span>
|
||||||
|
@if (l.openToOutside) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300">OPEN</span> }
|
||||||
|
<span class="text-sm text-slate-400">{{ l.description }}</span>
|
||||||
|
<span class="ml-auto"></span>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="edit(l)" [attr.aria-label]="'Edit ' + l.address"><mat-icon>edit</mat-icon></button>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="remove(l)" [attr.aria-label]="'Delete ' + l.address"><mat-icon>delete</mat-icon></button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-mono text-slate-500 mt-0.5">{{ l.members.length ? l.members.join(', ') : 'no members — mail to it is dropped' }}</div>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="py-2 text-sm text-slate-500">No lists yet.</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ListsSection {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
private toasts = inject(ToastService);
|
||||||
|
|
||||||
|
readonly lists = signal<MailList[]>([]);
|
||||||
|
readonly mailboxes = signal<Mailbox[]>([]);
|
||||||
|
readonly members = signal<Set<string>>(new Set());
|
||||||
|
readonly local = signal('');
|
||||||
|
readonly domains = signal<string[]>([]);
|
||||||
|
readonly domain = computed(() => this.domains()[0] ?? '');
|
||||||
|
readonly editing = signal(false);
|
||||||
|
readonly error = signal('');
|
||||||
|
readonly busy = signal(false);
|
||||||
|
folder = '';
|
||||||
|
description = '';
|
||||||
|
openToOutside = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.api.config().subscribe({ next: c => this.domains.set(c.domains), error: () => {} });
|
||||||
|
this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: () => {} });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.api.mailLists().subscribe({ next: list => this.lists.set(list), error: e => this.error.set(errorText(e)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
flip(email: string): void {
|
||||||
|
const next = new Set(this.members());
|
||||||
|
if (next.has(email)) { next.delete(email); } else { next.add(email); }
|
||||||
|
this.members.set(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(l: MailList): void {
|
||||||
|
this.local.set(l.address.split('@')[0]);
|
||||||
|
this.domains.set([l.address.split('@')[1], ...this.domains().filter(d => d !== l.address.split('@')[1])]);
|
||||||
|
this.folder = l.folder ?? '';
|
||||||
|
this.description = l.description ?? '';
|
||||||
|
this.openToOutside = l.openToOutside;
|
||||||
|
this.members.set(new Set(l.members));
|
||||||
|
this.editing.set(true);
|
||||||
|
this.error.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.local.set('');
|
||||||
|
this.folder = this.description = '';
|
||||||
|
this.openToOutside = false;
|
||||||
|
this.members.set(new Set());
|
||||||
|
this.editing.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
const local = this.local().replace(/@.*$/, '');
|
||||||
|
if (!local) { this.error.set('Enter an address'); return; }
|
||||||
|
const address = local + '@' + this.domain();
|
||||||
|
this.error.set('');
|
||||||
|
this.busy.set(true);
|
||||||
|
this.api.saveMailList(address, {
|
||||||
|
folder: this.folder.trim(), description: this.description.trim(), openToOutside: this.openToOutside,
|
||||||
|
members: [...this.members()]
|
||||||
|
}).subscribe({
|
||||||
|
next: saved => { this.busy.set(false); this.toasts.ok('List ' + saved.address + ' saved'); this.reset(); this.load(); },
|
||||||
|
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(l: MailList): void {
|
||||||
|
if (!confirm('Delete list ' + l.address + '? Mail to it will be refused.')) return;
|
||||||
|
this.api.deleteMailList(l.address).subscribe({
|
||||||
|
next: () => { this.toasts.ok('List ' + l.address + ' deleted'); this.load(); },
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
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">@</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>
|
||||||
|
<label class="md:col-span-4 flex items-center gap-1.5 text-sm text-slate-300">
|
||||||
|
<input type="checkbox" name="shared" [(ngModel)]="shared">
|
||||||
|
shared mailbox (support@, billing@): no password of its own — members open it from their mailbox, read/unread is common
|
||||||
|
</label>
|
||||||
|
</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> }
|
||||||
|
@if (m.shared) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/50 text-cyan-300"
|
||||||
|
[title]="'Members: ' + (m.members.join(', ') || 'none')">SHARED · {{ m.members.length }}</span> }
|
||||||
|
@if (!m.shared && m.members.length) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/30 text-cyan-300/80"
|
||||||
|
[title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</span> }
|
||||||
|
@if (m.trap) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300"
|
||||||
|
title="What it sends is kept in its own INBOX — nothing leaves the server">TRAP</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)="editMembers(m)" [attr.aria-expanded]="membersOf() === m.id"
|
||||||
|
[attr.aria-label]="'Who may open ' + m.email" title="Who may open this mailbox"><mat-icon>group</mat-icon></button>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="trap(m)" [class.text-amber-300]="m.trap"
|
||||||
|
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
|
||||||
|
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
|
||||||
|
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></button>
|
||||||
|
@if (!m.shared) {
|
||||||
|
<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>
|
||||||
|
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="remove(m)"
|
||||||
|
[attr.aria-label]="'Delete ' + m.email" title="Delete the mailbox with all its mail">
|
||||||
|
<mat-icon>delete</mat-icon></button>
|
||||||
|
}
|
||||||
|
@if (membersOf() === m.id) {
|
||||||
|
<div class="basis-full mt-1 p-2 rounded-lg border border-cyan-500/20">
|
||||||
|
<div class="text-xs text-slate-400 mb-1">Who may open <span class="font-mono text-cyan-300">{{ m.email }}</span> from their own mailbox:</div>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
@for (o of mailboxes(); track o.id) {
|
||||||
|
@if (o.id !== m.id && !o.shared) {
|
||||||
|
<label class="flex items-center gap-1.5 text-sm font-mono">
|
||||||
|
<input type="checkbox" [checked]="picked().has(o.email)" (change)="pick(o.email)"> {{ o.email }}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button type="button" class="cyber-btn px-3 py-1 rounded-lg text-xs font-bold" (click)="saveMembers(m)"><mat-icon>save</mat-icon> SAVE</button>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="membersOf.set(null)">CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</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 = '';
|
||||||
|
shared = false;
|
||||||
|
/** mailbox whose members are being edited */
|
||||||
|
readonly membersOf = signal<number | null>(null);
|
||||||
|
readonly picked = signal<Set<string>>(new Set());
|
||||||
|
|
||||||
|
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, this.shared).subscribe({
|
||||||
|
next: created => {
|
||||||
|
this.busy.set(false);
|
||||||
|
this.local.set('');
|
||||||
|
this.name = this.password = '';
|
||||||
|
this.shared = false;
|
||||||
|
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))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */
|
||||||
|
trap(m: Mailbox): void {
|
||||||
|
if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return;
|
||||||
|
this.api.setMailboxTrap(m.id, !m.trap).subscribe({
|
||||||
|
next: () => { this.toasts.ok('Trap ' + (m.trap ? 'off' : 'on') + ' for ' + m.email); this.load(); },
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editMembers(m: Mailbox): void {
|
||||||
|
if (this.membersOf() === m.id) { this.membersOf.set(null); return; }
|
||||||
|
this.picked.set(new Set(m.members));
|
||||||
|
this.membersOf.set(m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(email: string): void {
|
||||||
|
const next = new Set(this.picked());
|
||||||
|
if (next.has(email)) { next.delete(email); } else { next.add(email); }
|
||||||
|
this.picked.set(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMembers(m: Mailbox): void {
|
||||||
|
this.api.setMailboxMembers(m.id, [...this.picked()]).subscribe({
|
||||||
|
next: () => { this.toasts.ok('Members of ' + m.email + ' saved'); this.membersOf.set(null); this.load(); },
|
||||||
|
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))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(m: Mailbox): void {
|
||||||
|
if (!confirm('Delete ' + m.email + ' with ALL its mail? This can not be undone.')) return;
|
||||||
|
this.api.deleteMailbox(m.id).subscribe({
|
||||||
|
next: () => { this.toasts.ok(m.email + ' deleted'); 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,13 @@ 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';
|
||||||
|
import { ListsSection } from './lists.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, ListsSection],
|
||||||
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 +43,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 +111,11 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (state.profile()?.admin) {
|
||||||
|
<app-mailboxes-section />
|
||||||
|
<app-lists-section />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -112,6 +131,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));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { filter } from 'rxjs';
|
|||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { AuthService } from '../../core/auth.service';
|
import { AuthService } from '../../core/auth.service';
|
||||||
import { MailStateService } from '../../core/mail-state.service';
|
import { MailStateService } from '../../core/mail-state.service';
|
||||||
import { Folder, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models';
|
import { Folder, MailboxEntry, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models';
|
||||||
import { BackgroundComponent } from '../../ui/background.component';
|
import { BackgroundComponent } from '../../ui/background.component';
|
||||||
import { DialogService } from '../../ui/dialog.service';
|
import { DialogService } from '../../ui/dialog.service';
|
||||||
import { THEMES, ThemeService } from '../../ui/theme.service';
|
import { THEMES, ThemeService } from '../../ui/theme.service';
|
||||||
@@ -69,9 +69,56 @@ 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">
|
||||||
|
<div class="flex items-center justify-between mb-2 px-1">
|
||||||
|
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2>
|
||||||
|
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of"
|
||||||
|
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())">
|
||||||
|
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-1 mb-3" aria-label="Your mailboxes">
|
||||||
|
@for (b of boxes(); track b.email) {
|
||||||
|
<li class="flex items-center gap-1">
|
||||||
|
<button type="button" (click)="switchTo(b)" [attr.aria-current]="b.email === auth.email() ? 'true' : null"
|
||||||
|
class="flex-grow flex items-center gap-2 px-3 py-1.5 rounded-lg border text-left font-rajdhani font-semibold min-w-0"
|
||||||
|
[class.border-pink-500/40]="b.email === auth.email()" [class.bg-pink-500/15]="b.email === auth.email()"
|
||||||
|
[class.text-pink-200]="b.email === auth.email()" [class.border-transparent]="b.email !== auth.email()"
|
||||||
|
[class.text-slate-300]="b.email !== auth.email()"
|
||||||
|
[matTooltip]="b.email + (b.shared ? ' — shared' : b.kind === 'ATTACHED' ? ' — attached' : '')">
|
||||||
|
<mat-icon class="!text-lg shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
|
||||||
|
<span class="flex-grow truncate text-sm">{{ b.email }}</span>
|
||||||
|
@if (b.unread) {
|
||||||
|
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300" [attr.aria-label]="b.unread + ' unread'">{{ b.unread }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (b.kind !== 'OWN') {
|
||||||
|
<button type="button" class="text-slate-500 hover:text-pink-400" [attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'"
|
||||||
|
matTooltip="Remove from my mailboxes" (click)="leave(b)"><mat-icon class="!text-base">close</mat-icon></button>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if (attachOpen()) {
|
||||||
|
<form class="space-y-2 mb-4 p-2 rounded-lg border border-cyan-500/20" (ngSubmit)="attach()" aria-label="Add a mailbox">
|
||||||
|
<label class="synth-label" for="att-email">Mailbox</label>
|
||||||
|
<input id="att-email" name="attEmail" class="synth-input !py-1.5" [(ngModel)]="attachEmail" autocomplete="off" placeholder="name@domain">
|
||||||
|
<label class="synth-label" for="att-pw">Its password</label>
|
||||||
|
<input id="att-pw" name="attPw" type="password" class="synth-input !py-1.5" [(ngModel)]="attachPassword" autocomplete="off">
|
||||||
|
<button type="submit" class="cyber-btn w-full py-1.5 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
<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
|
||||||
</a>
|
</a>
|
||||||
@@ -156,6 +203,11 @@ export class ShellComponent {
|
|||||||
|
|
||||||
readonly themes = THEMES;
|
readonly themes = THEMES;
|
||||||
readonly menuOpen = signal(false);
|
readonly menuOpen = signal(false);
|
||||||
|
/** own, shared and attached mailboxes of the login (shown when there is more than the own one, or to add one) */
|
||||||
|
readonly boxes = signal<MailboxEntry[]>([]);
|
||||||
|
readonly attachOpen = signal(false);
|
||||||
|
attachEmail = '';
|
||||||
|
attachPassword = '';
|
||||||
query = '';
|
query = '';
|
||||||
|
|
||||||
readonly links = [
|
readonly links = [
|
||||||
@@ -186,7 +238,8 @@ export class ShellComponent {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.state.refreshFolders();
|
this.state.refreshFolders();
|
||||||
this.state.refreshProfile();
|
this.state.refreshProfile();
|
||||||
const timer = setInterval(() => this.state.refreshFolders(), 30000);
|
this.loadBoxes();
|
||||||
|
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000);
|
||||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||||
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
|
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
|
||||||
const q = this.router.parseUrl(this.router.url).queryParams['q'];
|
const q = this.router.parseUrl(this.router.url).queryParams['q'];
|
||||||
@@ -247,6 +300,57 @@ export class ShellComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadBoxes(): void {
|
||||||
|
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another mailbox of this login: its session, full reload — fresh folders and profile. */
|
||||||
|
switchTo(b: MailboxEntry): void {
|
||||||
|
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
|
||||||
|
this.api.mailboxSession(b.email).subscribe({
|
||||||
|
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(): void {
|
||||||
|
const email = this.attachEmail.trim().toLowerCase();
|
||||||
|
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; }
|
||||||
|
this.api.attachMailbox(email, this.attachPassword).subscribe({
|
||||||
|
next: b => {
|
||||||
|
this.attachEmail = this.attachPassword = '';
|
||||||
|
this.attachOpen.set(false);
|
||||||
|
this.toasts.ok(b.email + ' added to your mailboxes');
|
||||||
|
this.loadBoxes();
|
||||||
|
},
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(b: MailboxEntry): void {
|
||||||
|
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
|
||||||
|
? 'It disappears from your menu. An administrator can give it back.'
|
||||||
|
: 'It disappears from your menu. Add it again with its password.', 'Remove', true).subscribe(ok => {
|
||||||
|
if (ok === null) return;
|
||||||
|
this.api.leaveMailbox(b.email).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toasts.ok(b.email + ' removed');
|
||||||
|
if (b.email === this.auth.email()) {
|
||||||
|
const own = this.boxes().find(x => x.kind === 'OWN');
|
||||||
|
if (own) { this.switchTo(own); return; }
|
||||||
|
}
|
||||||
|
this.loadBoxes();
|
||||||
|
},
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
backToAdmin(): void {
|
||||||
|
this.auth.backToAdmin();
|
||||||
|
location.assign('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.state.reset();
|
this.state.reset();
|
||||||
this.auth.logout();
|
this.auth.logout();
|
||||||
|
|||||||
Reference in New Issue
Block a user