Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
152e93d997 | ||
|
|
eaffffd4cc | ||
|
|
b72be91a8b | ||
|
|
73c03b1d6d | ||
|
|
17d489e400 | ||
|
|
37f6b3ef3d | ||
|
|
69b62b5769 | ||
|
|
12c2223c15 | ||
|
|
db0249eb74 | ||
|
|
29b1beabe0 | ||
|
|
f605fbfd85 | ||
|
|
be6fed4986 | ||
|
|
44e2ced266 | ||
|
|
e54e140046 | ||
|
|
e1427c062c | ||
|
|
fdd6b8a51f | ||
|
|
f1cf20f439 | ||
|
|
5f8a8607a9 | ||
|
|
c8cf5c195f | ||
|
|
3805be7b42 | ||
|
|
f744def6ed | ||
|
|
165edf1a1e |
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Java / Gradle
|
# Java / Gradle
|
||||||
*.class
|
*.class
|
||||||
*.jar
|
*.jar
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
*.war
|
*.war
|
||||||
*.ear
|
*.ear
|
||||||
*.log
|
*.log
|
||||||
@@ -46,3 +47,9 @@ dkim-keys/
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# local data of the dev server
|
||||||
|
backend/attachments/
|
||||||
|
backend/dkim-keys/
|
||||||
|
attachments/
|
||||||
|
dkim-keys/
|
||||||
|
|||||||
@@ -1,440 +1,207 @@
|
|||||||
# Mail Server
|
# Reactive Mail Server
|
||||||
|
|
||||||
Прототип почтового сервера на **Spring Boot + Angular** с REST API для управления письмами.
|
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
|
||||||
|
|
||||||
## Архитектура
|
Назначение — наша настоящая почта **https://mail.jambocheck.com** (prod Jambotron, системная ВМ, адрес `103.109.235.154`) и тестовый проект на Jambotron.
|
||||||
|
|
||||||
|
## Забыл пароль — где взять
|
||||||
|
|
||||||
|
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**.
|
||||||
|
|
||||||
|
**Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
|
||||||
|
|
||||||
|
Пароли остальных ящиков (`staging@`, `no-reply@`, `monitor@`) — в репозитории Jambotron: `INFRASTRUCTURE/SECRETS.md`, раздел «Почта mail.jambocheck.com».
|
||||||
|
|
||||||
|
| Ситуация | Что делать |
|
||||||
|
| :--- | :--- |
|
||||||
|
| Пароль `admin@` ещё не меняли | файл `/app/data/initial-passwords.txt` **внутри контейнера** `mail` на ВМ почты (в этом репозитории его нет; лежит в томе `data`, передеплой его не стирает). С компьютера: `ssh root@103.109.235.150`, затем `incus exec --project jambotron-prod incust-prod-react-mail-server -- docker exec jambotron-109cf3bb-mail-1 cat /app/data/initial-passwords.txt`. В логе `mail` строка `Mailbox created…` есть **только до первого передеплоя** |
|
||||||
|
| Пароль `admin@` сменили и забыли | новый пароль прямо в базе почты, ящик и письма остаются — попросить α/Ω; команда — Jambotron, `INFRASTRUCTURE/test-stands.md`, раздел «Почта mail.jambocheck.com» |
|
||||||
|
| Забыт пароль другого ящика (`staging@`, `no-reply@`, `monitor@`, свой) | войти под `admin@` → Настройки → MAILBOXES → ключик у ящика → новый пароль (показывается один раз) и вписать его туда, где ящик настроен |
|
||||||
|
|
||||||
|
Всё о prod-почте (порты, DNS, кто через неё шлёт) — Jambotron, `INFRASTRUCTURE/test-stands.md`.
|
||||||
|
|
||||||
|
## Как устроено
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
SMTP-клиент / чужой сервер ──► SmtpServer (Netty, порт 3025) ──┐
|
||||||
│ Angular Frontend │
|
├─► MailRouter ─┬─► LocalDelivery → свой ящик (PostgreSQL)
|
||||||
│ (веб-почта: список, чтение, написание, контакты) │
|
Веб-почта (Angular) ──► REST /api ──► SubmissionService ──────┘ └─► OutboundQueue → DeliveryWorker → SMTP получателя
|
||||||
└──────────────────────┬──────────────────────────────┘
|
|
||||||
│ REST API (JSON)
|
|
||||||
┌──────────────────────▼──────────────────────────────┐
|
|
||||||
│ Spring Boot Backend │
|
|
||||||
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
|
|
||||||
│ │ Users │ │ Messages │ │ Contacts │ │
|
|
||||||
│ └────────────┘ └────────────┘ └──────────────┘ │
|
|
||||||
│ ┌────────────┐ ┌────────────┐ │
|
|
||||||
│ │ Folders │ │Attachments │ │
|
|
||||||
│ └────────────┘ └────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌────────▼────────┐ │
|
|
||||||
│ │ JavaMailSender │ │
|
|
||||||
│ │ (SMTP отправка)│ │
|
|
||||||
│ └─────────────────┘ │
|
|
||||||
└──────────────────────┬──────────────────────────────┘
|
|
||||||
│ JPA
|
|
||||||
┌──────────────────────▼──────────────────────────────┐
|
|
||||||
│ PostgreSQL 17 │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Запуск
|
- **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке.
|
||||||
|
- **TLS.** `STARTTLS` на порту SMTP (RFC 3207) и, если задан `SMTPS_PORT`, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После `STARTTLS` сессия начинается заново (новый `EHLO`), а команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается (CVE-2011-0411). `AUTH` предлагается только внутри TLS (или без него при `SMTP_AUTH_WITHOUT_TLS=true`). Сертификат — свой (`MAIL_TLS_CERT` + `MAIL_TLS_KEY`, например Let's Encrypt) или самоподписанный: создаётся один раз в `MAIL_TLS_DIR` и потом переиспользуется. Свой сертификат сервер перечитывает сам, когда файлы меняются (проверка раз в минуту): обновлённый Let's Encrypt подхватывается без перезапуска, недописанный файл не ломает TLS — остаётся прежний сертификат.
|
||||||
|
- **Не открытый релей.**
|
||||||
|
- Письмо на свой домен принимается от кого угодно.
|
||||||
|
- Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`.
|
||||||
|
- Вошедший пользователь пишет только от своего адреса, иначе `553`.
|
||||||
|
- **Маршрут** (`MailRouter`) один для SMTP и веб-почты:
|
||||||
|
- есть такой ящик в нашем домене → в него;
|
||||||
|
- задан `MAIL_CATCH_ALL` → всё остальное (и чужие домены) — в ящик-ловушку, наружу ничего;
|
||||||
|
- наш домен без такого ящика → «нет такого пользователя»;
|
||||||
|
- чужой домен → очередь.
|
||||||
|
- **Отправка наружу** (`OUTBOUND_MODE`):
|
||||||
|
- `off` — ничего не уходит;
|
||||||
|
- `mx` — напрямую на MX получателя, порт 25, STARTTLS по возможности;
|
||||||
|
- `smarthost` — через провайдера (Resend, SES) с логином и проверкой TLS.
|
||||||
|
- **Очередь** — таблица `outbound_queue`. Временная ошибка — повтор через 1 мин, 5 мин, 15 мин, 1 ч, 3 ч, 6 ч, 12 ч. Постоянная ошибка или исчерпанные повторы — уведомление отправителю во входящие.
|
||||||
|
- **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT <selector>._domainkey.<domain>`.
|
||||||
|
- **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `404`). Регистрация по умолчанию закрыта, ящики — только в своих доменах.
|
||||||
|
|
||||||
### 1. Docker Compose (рекомендуется)
|
## IMAP
|
||||||
|
|
||||||
|
Почтовые программы (Thunderbird, почта на телефоне) работают с теми же ящиками, что и веб-почта.
|
||||||
|
|
||||||
|
- **Порт** `IMAP_PORT` (3143) со `STARTTLS`, и неявный TLS на `IMAPS_PORT` (993), если задан. Сертификат тот же, что у SMTP. Вход (`LOGIN`, `AUTHENTICATE PLAIN`) — только внутри TLS, без него сервер отвечает `LOGINDISABLED`.
|
||||||
|
- **Возможности:** IMAP4rev1, LITERAL+, IDLE (новые письма приходят сами, проверка раз в 5 с), UIDPLUS, MOVE, UNSELECT, NAMESPACE, SPECIAL-USE (Отправленные, Черновики, Корзина, Спам клиент находит сам), ID.
|
||||||
|
- **UID** выдаёт база (триггер): письмо, перенесённое в папку из веба или по IMAP, получает новый, больший UID — клиент его не пропустит. Флаги `\Seen`, `\Flagged`, `\Answered`, `\Deleted`; `\Draft` — у писем в «Черновиках».
|
||||||
|
- **Письмо целиком** (`BODY[]`) — ровно в том виде, в каком оно пришло по SMTP или через `APPEND`. Для писем, написанных в веб-почте, MIME собирается один раз и сохраняется: размер и байты не меняются между запросами.
|
||||||
|
- Каждая команда выполняется на рабочем потоке, не на сетевом. Команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается.
|
||||||
|
- Папки — плоские (без вложенных), имена — modified UTF-7, кириллица работает.
|
||||||
|
|
||||||
|
## Веб-почта
|
||||||
|
|
||||||
|
Angular 22 в стиле [synthwave-шаблона](frontend/src/assets/synthwave-design-showcase.html): стеклянные панели, неон, движущаяся сетка (выключается в настройках и при `prefers-reduced-motion`), четыре палитры и высокий контраст. Все таблицы — грид [`@jambotron/aria-grid`](https://gitea.jambotron.com/jambotron_gang/jambotron-aria-grid) (сортировка, страницы, выбор строк, массовые действия), пакет — из реестра npm Gitea (`frontend/.npmrc`).
|
||||||
|
|
||||||
|
- **Вход и регистрация.** Регистрация (если открыта, `MAIL_REGISTRATION`) — имя ящика в одном из `MAIL_DOMAINS`, после неё вход сразу. Служебные имена (`postmaster`, `abuse`, `admin`…) заняты. Пароль — от 8 символов.
|
||||||
|
- **Папки.** Входящие, Черновики, Отправленные, Спам, Корзина и свои (создать, переименовать, удалить — письма уходят в Корзину). Счётчики непрочитанных обновляются раз в 30 с.
|
||||||
|
- **Список писем.** Поиск по теме, отправителю, получателям и тексту; флажок; прочитано/не прочитано; перенос в папку, в Спам и обратно; удаление (в Корзину, из Корзины — насовсем); очистка Корзины и Спама.
|
||||||
|
- **Письмо.** HTML-письма — в изолированном `iframe` без скриптов, внешние картинки — только по кнопке (отправитель не узнаёт, что письмо открыли). Вложения скачиваются. Заблокировать отправителя — его письма сразу идут в Спам.
|
||||||
|
- **Написать.** Несколько адресов в «Кому» и «Копия», подсказки из контактов, ответ, ответ всем, пересылка. Черновик сохраняется сам каждые 15 с; файлы (до 10 МБ) прикрепляются к черновику и уходят вместе с письмом.
|
||||||
|
- **Контакты, заблокированные отправители, настройки** (имя, пароль, место, оформление).
|
||||||
|
|
||||||
|
API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api/folders`, `/api/messages` (`PATCH` — `read`/`flagged`/`folderId`), `/api/drafts`, `/api/send` (`draftId` — с вложениями черновика), `/api/contacts`, `/api/spam/blacklist`.
|
||||||
|
|
||||||
|
## Настройки (переменные окружения)
|
||||||
|
|
||||||
|
Секреты без значений по умолчанию — без них сервер не стартует. Их можно передать переменными или файлами в `MAILSERVER_SECRETS_DIR` (имя файла = имя переменной).
|
||||||
|
|
||||||
|
| Переменная | Что | По умолчанию |
|
||||||
|
|---|---|---|
|
||||||
|
| `DB_PASSWORD` | пароль PostgreSQL | **обязательно** |
|
||||||
|
| `JWT_SECRET` | ключ токенов входа, ≥ 32 байт | **обязательно** |
|
||||||
|
| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER` | база | `localhost`, `5432`, `mailserver`, `mail` |
|
||||||
|
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
|
||||||
|
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
|
||||||
|
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
|
||||||
|
| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) |
|
||||||
|
| `HTTPS_PORT` | веб-клиент и API по HTTPS тем же сертификатом, что SMTP/IMAP (в `docker-compose.public.yml` — 8443 → 443: `https://mail.<домен>`); 0 — только HTTP на `server.port` | 0 |
|
||||||
|
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
|
||||||
|
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
|
||||||
|
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
|
||||||
|
| `SMTP_PORT` | порт SMTP | `3025` |
|
||||||
|
| `MAIL_TLS` | TLS для SMTP: `STARTTLS` и порт `SMTPS_PORT` | `true` |
|
||||||
|
| `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ (PKCS#8, `RSA PRIVATE KEY` или `EC PRIVATE KEY`); перечитывается при изменении; без них — самоподписанный | — |
|
||||||
|
| `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) |
|
||||||
|
| `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` |
|
||||||
|
| `IMAP_ENABLED`, `IMAP_PORT`, `IMAPS_PORT` | IMAP: включён; порт со STARTTLS; порт неявного TLS (`0` — выключен) | `true`, `3143`, `0` |
|
||||||
|
| `IMAP_AUTH_WITHOUT_TLS` | вход в IMAP без TLS — **только** в закрытой сети | `false` |
|
||||||
|
| `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` |
|
||||||
|
| `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` |
|
||||||
|
| `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` |
|
||||||
|
| `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` |
|
||||||
|
| `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` |
|
||||||
|
| `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — |
|
||||||
|
| `LOGIN_GUARD` | защита от перебора паролей (см. ниже) | `true` |
|
||||||
|
| `LOGIN_GUARD_MAX_PER_IP`, `LOGIN_GUARD_MAX_PER_ACCOUNT` | неверных паролей за окно до блокировки: с одного IP; на один ящик с любых IP | `10`, `20` |
|
||||||
|
| `LOGIN_GUARD_WINDOW`, `LOGIN_GUARD_BLOCK`, `LOGIN_GUARD_MAX_BLOCK` | окно подсчёта; первая блокировка; потолок (каждая следующая блокировка того же IP вдвое дольше) | `15m`, `15m`, `24h` |
|
||||||
|
| `LOGIN_GUARD_TRUSTED_IPS` | IP через запятую, которые не блокируются по IP (ящик всё равно считается) | — |
|
||||||
|
|
||||||
|
### Защита от перебора паролей
|
||||||
|
|
||||||
|
Один счётчик на всё, где проверяется пароль: `AUTH` в SMTP (25/587/465), `LOGIN`/`AUTHENTICATE` в IMAP, вход в веб-почту, смена пароля.
|
||||||
|
|
||||||
|
- **По IP:** 10 неверных паролей за 15 минут → IP заблокирован на 15 минут, следующая блокировка — 30 минут, потом час… до суток. Сутки тишины — счёт сначала.
|
||||||
|
- **По ящику:** 20 неверных паролей на один ящик с разных IP (распределённый перебор) → ящик закрыт на 15 минут, **кроме** IP, с которых в него уже входили успешно (30 дней) — так перебором нельзя запереть владельца из его же почтовой программы.
|
||||||
|
- Пока блокировка — пароль **не проверяется** вовсе: SMTP `421 4.7.0` и обрыв, IMAP `* BYE` + `NO [UNAVAILABLE]`, веб `429` с `Retry-After`. Неверный пароль отвечается через секунду; после 3 неверных в одном соединении SMTP и IMAP его закрывают.
|
||||||
|
- «Нет такого ящика» и «неверный пароль» снаружи не отличить, оба считаются.
|
||||||
|
- В логе: `login guard: IP … blocked for 15 min after 10 failed logins (block #1)`.
|
||||||
|
- `X-Forwarded-For` веб-входа принимается только от прокси в частной сети (Caddy Jambotron приходит через docker/WireGuard); с публичного адреса заголовок отбрасывается — подменой IP лимит не обойти.
|
||||||
|
- Счётчики в памяти: перезапуск их сбрасывает.
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd mail-server
|
docker compose up -d # PostgreSQL на 127.0.0.1:5433
|
||||||
docker compose up --build
|
./gradlew :backend:bootRun --args='--spring.profiles.active=dev' # API :8080, SMTP :3025, admin@mailserver.local / admin123
|
||||||
|
cd frontend && npm install && npx ng serve # http://localhost:4200, /api → :8080
|
||||||
|
./gradlew :backend:test # тесты (база не нужна)
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервисы:
|
Профиль `dev` — только для своего компьютера: известные пароли, открытая регистрация, `POST /api/simulate/receive` (письмо в ящик без SMTP).
|
||||||
- **REST API**: http://localhost:8080
|
|
||||||
- **SMTP (приём)**: localhost:3025
|
|
||||||
- **IMAP (доступ к письмам)**: localhost:143
|
|
||||||
- **Mailpit UI**: http://localhost:8025 (SMTP на порту 1025)
|
|
||||||
- **PostgreSQL**: localhost:5432
|
|
||||||
|
|
||||||
### 2. Локально
|
## Деплой на Jambotron (тест)
|
||||||
|
|
||||||
Требования:
|
В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`.
|
||||||
- Java 21
|
|
||||||
- PostgreSQL 17
|
- Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет.
|
||||||
|
- Ящики `admin@mail.test` (первый) и `trap@mail.test` (ловушка) создаются при старте, их пароли печатаются в лог сервиса `mail` при первом запуске (в Jambotron: проект → Сервисы → `mail` → Логи) и лежат в файле `/app/data/initial-passwords.txt` контейнера `mail` (том `data`, права 600): `docker exec <контейнер mail> cat /app/data/initial-passwords.txt`. Прочитать и сменить пароль в настройках.
|
||||||
|
- Регистрация открыта (`MAIL_REGISTRATION=true`): любой может завести ящик в `MAIL_DOMAINS`. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — `MAIL_REGISTRATION=false`.
|
||||||
|
- Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
|
||||||
|
- Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`).
|
||||||
|
|
||||||
|
### Ловушка почты для dev и staging (75.4)
|
||||||
|
|
||||||
|
В `docker-compose.jambotron.yml` включена ловушка: `MAIL_CATCH_ALL=trap@mail.test`. Письмо на любой адрес, у которого здесь нет своего ящика, — `user@gmail.com`, `nobody@mail.test` — ложится в ящик `trap@mail.test`; письма между ящиками стенда идут как обычно. Наружу не уходит ничего (`OUTBOUND_MODE=off`), порт 25 и репутация IP не нужны. Выключить — `MAIL_CATCH_ALL=` (пусто).
|
||||||
|
|
||||||
|
Чтобы staging Jambotron слал письма сюда вместо Resend, его `spring.mail` смотрит на SMTP этого сервера: хост — адрес, по которому staging видит SMTP 3025, без логина и без TLS. Как: в Jambotron, карточка «Домены» проекта → «Внутренние TCP-порты» → сервис `mail`, порт `3025` (Jambotron PR #781). Адрес `vmIp:vmPort` доступен только в сети WireGuard Jambotron, из интернета — нет.
|
||||||
|
|
||||||
|
Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
|
||||||
|
|
||||||
|
### Готовый Docker-образ
|
||||||
|
|
||||||
|
Как у htop-web: Drone (`.drone.yml`) собирает `backend/Dockerfile`. На PR образ только собирается, на `master` уходит в реестр Gitea:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea.jambotron.com/jambotron_gang/reactive-mail-server:latest
|
||||||
|
gitea.jambotron.com/jambotron_gang/reactive-mail-server:<первые 8 символов коммита master>
|
||||||
|
```
|
||||||
|
|
||||||
|
Реестр публичный: `docker pull` без логина.
|
||||||
|
|
||||||
|
`docker-compose.image.yml` — тот же стенд, что `docker-compose.jambotron.yml`, но сервис `mail` берётся из образа, на ВМ ничего не собирается (деплой быстрее, Node и Gradle на ВМ не нужны). В мастере деплоя Jambotron выбрать этот файл; домен — сервису `mail`, порт `8080`. По умолчанию `latest`, он скачивается заново при каждом деплое (`pull_policy: always`); закрепить сборку — переменная `MAIL_IMAGE_TAG=<8 символов коммита>`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Запустите PostgreSQL
|
docker compose -f docker-compose.image.yml up -d
|
||||||
docker run -d --name mail-db -p 5432:5432 \
|
|
||||||
-e POSTGRES_DB=mailserver \
|
|
||||||
-e POSTGRES_USER=mail \
|
|
||||||
-e POSTGRES_PASSWORD=secret \
|
|
||||||
postgres:17
|
|
||||||
|
|
||||||
# Запустите Mailpit для тестовой отправки
|
|
||||||
docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
|
|
||||||
|
|
||||||
# Соберите и запустите бэкенд
|
|
||||||
cd backend
|
|
||||||
./gradlew bootRun
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
### Настоящая почта в интернете (75.6)
|
||||||
|
|
||||||
### Тестовый пользователь
|
`docker-compose.public.yml` — приём писем для `MAIL_DOMAINS` и отправка наружу прямо на MX получателей (`OUTBOUND_MODE=mx`, порт 25), с подписью DKIM; регистрация закрыта, ловушки нет. Нужна своя ВМ с публичным адресом (Jambotron 60.8: «Публичный адрес», SMTP «да» — порт 25 по умолчанию закрыт). На этом адресе открыты:
|
||||||
|
|
||||||
При первом запуске приложения автоматически создаётся тестовый пользователь:
|
| Порт | Что |
|
||||||
|
|
||||||
- **Email:** `admin@mailserver.local`
|
|
||||||
- **Пароль:** `admin123`
|
|
||||||
|
|
||||||
### Регистрация
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/register \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"test@example.com","password":"secret123","displayName":"Test User"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Логин
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/login \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"test@example.com","password":"secret123"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Ответ:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "eyJhbGci...",
|
|
||||||
"email": "test@example.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Все защищённые эндпоинты требуют заголовок `Authorization: Bearer <token>`.
|
|
||||||
|
|
||||||
### Получить папки
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/api/folders \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Получить письма из папки
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:8080/api/messages?folderId=1&page=0&size=20" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Отправить письмо
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/send \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer <token>" \
|
|
||||||
-d '{"to":"recipient@example.com","subject":"Hello","body":"Test message"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Симулировать входящее письмо (для тестов)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/simulate/receive \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"senderEmail":"sender@example.com",
|
|
||||||
"senderName":"Sender",
|
|
||||||
"recipientEmail":"test@example.com",
|
|
||||||
"subject":"Incoming test",
|
|
||||||
"body":"This is a test message"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Отметить как прочитанное
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PUT http://localhost:8080/api/messages/1/read \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Удалить письмо
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://localhost:8080/api/messages/1 \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Загрузить вложение к письму
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/messages/1/attachments \
|
|
||||||
-H "Authorization: Bearer <token>" \
|
|
||||||
-F "file=@/path/to/file.pdf"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Получить список вложений письма
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/api/messages/1/attachments \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Скачать вложение
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/api/attachments/1/download \
|
|
||||||
-H "Authorization: Bearer <token>" \
|
|
||||||
-o file.pdf
|
|
||||||
```
|
|
||||||
|
|
||||||
### Удалить вложение
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://localhost:8080/api/attachments/1 \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Поиск по письмам
|
|
||||||
|
|
||||||
Поиск поддерживает два режима: простой поиск по ключевому слову и расширенный поиск с отдельными фильтрами.
|
|
||||||
|
|
||||||
**Простой поиск** — ищет по ключевому слову в теме, теле, отправителе и получателе:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:8080/api/messages/search?folderId=1&keyword=hello" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Расширенный поиск** — используйте отдельные параметры для точной фильтрации:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Поиск по теме
|
|
||||||
curl "http://localhost:8080/api/messages/search?folderId=1&subject=report" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
|
|
||||||
# Поиск по тексту письма
|
|
||||||
curl "http://localhost:8080/api/messages/search?folderId=1&body=invoice" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
|
|
||||||
# Поиск по отправителю
|
|
||||||
curl "http://localhost:8080/api/messages/search?folderId=1&sender=company.com" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
|
|
||||||
# Комбинированный поиск (все фильтры применяются одновременно)
|
|
||||||
curl "http://localhost:8080/api/messages/search?folderId=1&subject=report&sender=company.com" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Все параметры поиска (`keyword`, `subject`, `body`, `sender`, `recipient`) необязательны. Если ни один параметр не указан, возвращаются все письма папки.
|
|
||||||
|
|
||||||
### Отправить письмо через SMTP-сервер
|
|
||||||
|
|
||||||
Встроенный SMTP-сервер слушает порт **3025**. Любое письмо, отправленное на этот порт, будет сохранено в базе данных, если получатель зарегистрирован в системе.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Отправить письмо через telnet
|
|
||||||
telnet localhost 3025
|
|
||||||
```
|
|
||||||
|
|
||||||
Или с помощью curl:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --url 'smtp://localhost:3025' \
|
|
||||||
--mail-from 'sender@example.com' \
|
|
||||||
--mail-rcpt 'test@example.com' \
|
|
||||||
-T <(echo -e 'From: sender@example.com\nTo: test@example.com\nSubject: SMTP Test\n\nHello from SMTP!')
|
|
||||||
```
|
|
||||||
|
|
||||||
Письмо появится во входящих (INBOX) пользователя `test@example.com`.
|
|
||||||
|
|
||||||
### Управление чёрным списком
|
|
||||||
|
|
||||||
Добавьте отправителя в чёрный список — письма от него будут автоматически помечаться как спам.
|
|
||||||
|
|
||||||
**Получить список заблокированных отправителей:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/api/spam/blacklist \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Добавить отправителя в чёрный список:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/spam/blacklist \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer <token>" \
|
|
||||||
-d '{"senderEmail":"spammer@example.com","reason":"Спам"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Удалить отправителя из чёрного списка:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:8080/api/spam/blacklist?senderEmail=spammer@example.com" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Письма от заблокированных отправителей автоматически получают флаг `spam: true` и могут быть отфильтрованы на стороне клиента.
|
|
||||||
|
|
||||||
### Подключение через IMAP-сервер
|
|
||||||
|
|
||||||
Встроенный IMAP-сервер слушает порт **143**. Вы можете подключиться к нему из любого почтового клиента (Thunderbird, Outlook, Apple Mail) или через telnet для тестирования.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Подключение через telnet
|
|
||||||
telnet localhost 143
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример сессии IMAP:
|
|
||||||
|
|
||||||
```
|
|
||||||
a LOGIN test@example.com secret123
|
|
||||||
a OK LOGIN completed
|
|
||||||
|
|
||||||
a LIST "" "*"
|
|
||||||
* LIST (\HasNoChildren) "." "INBOX"
|
|
||||||
* LIST (\HasNoChildren) "." "Sent"
|
|
||||||
a OK LIST completed
|
|
||||||
|
|
||||||
a SELECT "INBOX"
|
|
||||||
* 5 EXISTS
|
|
||||||
* 0 RECENT
|
|
||||||
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
|
|
||||||
a OK [READ-WRITE] SELECT completed
|
|
||||||
|
|
||||||
a FETCH 1 (BODY[])
|
|
||||||
* 1 FETCH (UID 1 FLAGS (\Seen) BODY[] {123
|
|
||||||
Hello, this is a test message.
|
|
||||||
))
|
|
||||||
a OK FETCH completed
|
|
||||||
|
|
||||||
a SEARCH ALL
|
|
||||||
* SEARCH 1 2 3 4 5
|
|
||||||
a OK SEARCH completed
|
|
||||||
|
|
||||||
a LOGOUT
|
|
||||||
* BYE IMAP4rev1 server terminating
|
|
||||||
a OK LOGOUT completed
|
|
||||||
```
|
|
||||||
|
|
||||||
Поддерживаемые IMAP-команды:
|
|
||||||
- **LOGIN** — аутентификация по email и паролю
|
|
||||||
- **SELECT / EXAMINE** — выбор папки для работы
|
|
||||||
- **FETCH** — получение заголовков и тела писем
|
|
||||||
- **SEARCH** — поиск по письмам
|
|
||||||
- **STORE** — изменение флагов (прочитано, помечено, удалено)
|
|
||||||
- **EXPUNGE** — удаление помеченных писем
|
|
||||||
- **LIST / LSUB** — список папок
|
|
||||||
- **STATUS** — информация о папке (количество писем, непрочитанных)
|
|
||||||
- **CAPABILITY** — список поддерживаемых возможностей
|
|
||||||
- **LOGOUT** — выход из сессии
|
|
||||||
|
|
||||||
Порт IMAP настраивается через `mailserver.imap.port` в `application.properties` (по умолчанию 143).
|
|
||||||
|
|
||||||
### DKIM / SPF / DMARC — защита от подделки писем
|
|
||||||
|
|
||||||
Почтовый сервер поддерживает три стандарта защиты электронной почты:
|
|
||||||
|
|
||||||
**DKIM (DomainKeys Identified Mail)** — криптографическая подпись писем.
|
|
||||||
- При отправке письма автоматически подписываются RSA-ключом (2048 бит)
|
|
||||||
- При получении входящих писем проверяется DKIM-подпись отправителя
|
|
||||||
- Ключи хранятся в директории `./dkim-keys/` (генерируются автоматически при первом запуске)
|
|
||||||
- Публичный ключ публикуется в DNS TXT-записи: `_default._domainkey.<domain>`
|
|
||||||
|
|
||||||
**SPF (Sender Policy Framework)** — проверка разрешённых серверов отправителя.
|
|
||||||
- При получении письма проверяется SPF DNS-запись домена отправителя
|
|
||||||
- Поддерживаемые механизмы: `all`, `ip4`, `ip6`, `mx`, `a`, `include`
|
|
||||||
- Локальные адреса (127.0.0.1, 192.168.x.x) пропускаются без проверки
|
|
||||||
|
|
||||||
**DMARC (Domain-based Message Authentication, Reporting & Conformance)** — политика обработки.
|
|
||||||
- Объединяет результаты SPF и DKIM с проверкой alignment (совпадение доменов)
|
|
||||||
- Поддерживаемые политики: `none` (мониторинг), `quarantine` (спам), `reject` (отклонить)
|
|
||||||
- Если SPF и DKIM не пройдены, применяется политика DMARC из DNS
|
|
||||||
|
|
||||||
**Результат проверки:**
|
|
||||||
- Письма, не прошедшие SPF/DKIM/DMARC, автоматически помечаются флагом `spam: true`
|
|
||||||
- Логируются результаты всех трёх проверок для каждого входящего письма
|
|
||||||
|
|
||||||
**Настройки в `application.properties`:**
|
|
||||||
```
|
|
||||||
mailserver.dkim.enabled=true
|
|
||||||
mailserver.dkim.domain=localhost
|
|
||||||
mailserver.dkim.keys-dir=./dkim-keys
|
|
||||||
mailserver.spf.enabled=true
|
|
||||||
mailserver.spf.local-bypass=true
|
|
||||||
mailserver.dmarc.enabled=true
|
|
||||||
mailserver.dmarc.local-bypass=true
|
|
||||||
```
|
|
||||||
|
|
||||||
**Публикация DKIM-ключа в DNS:**
|
|
||||||
При первом запуске сервер выводит в лог публичный ключ DKIM. Добавьте его как TXT-запись:
|
|
||||||
```
|
|
||||||
_name: _default._domainkey.yourdomain.com
|
|
||||||
type: TXT
|
|
||||||
value: v=DKIM1; k=rsa; p=MIIBojANBgkqhkiG9w0BAQ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Модели данных
|
|
||||||
|
|
||||||
| Сущность | Описание |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| **User** | Почтовый ящик, пароль, квота |
|
| 25 | SMTP: письма от других серверов (STARTTLS) |
|
||||||
| **Folder** | INBOX, Sent, Drafts, Trash, Spam |
|
| 587 | submission для почтовых программ (STARTTLS + вход) — тот же SMTP, что 25 |
|
||||||
| **Message** | Заголовки, тело, флаги (read, flagged, spam) |
|
| 465 | SMTPS, неявный TLS |
|
||||||
| **BlacklistEntry** | Заблокированный отправитель, привязка к пользователю |
|
| 993 | IMAPS, неявный TLS |
|
||||||
| **Attachment** | Имя, MIME-тип, размер, путь к файлу |
|
| 80 | только Let's Encrypt (HTTP-01), сервис `acme` |
|
||||||
| **Contact** | Имя, email, телефон, компания |
|
|
||||||
|
|
||||||
## Деплой на VPS
|
Сертификат для `MAIL_HOSTNAME` выпускает и продлевает сервис `acme` (lego, ключ EC256, продление за 30 дней, проверка дважды в сутки); хук кладёт пару в том `tls`, сервер перечитывает её сам. `mail` стартует после первого сертификата. Веб-почта (сервис `mail`, порт `8080`) — через Caddy Jambotron под **другим** именем, например `webmail.<домен>`: `MAIL_HOSTNAME` смотрит на публичный адрес ВМ, а не на Caddy.
|
||||||
|
|
||||||
### Caddy Docker Proxy
|
Переменные: `MAIL_DOMAINS` (например `jambocheck.com`), `MAIL_HOSTNAME` (`mail.jambocheck.com`), `ACME_EMAIL` (по умолчанию `postmaster@<домен>`).
|
||||||
|
|
||||||
Проект использует **Caddy Docker Proxy** для автоматического HTTPS и reverse proxy. Caddy сам генерирует конфиг из `labels` в `docker-compose.prod.yml` — отдельный Caddyfile не нужен.
|
DNS до первого запуска:
|
||||||
|
|
||||||
**Как это работает:**
|
| Запись | Значение |
|
||||||
- Контейнер `mail-server` имеет `labels` с настройками Caddy
|
|---|---|
|
||||||
- `lucaslorentz/caddy-docker-proxy` следит за Docker через socket и автоматически создаёт виртуальные хосты
|
| `A mail.<домен>` | публичный адрес ВМ |
|
||||||
- HTTPS-сертификат от Let's Encrypt получается автоматически
|
| 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`) |
|
||||||
|
|
||||||
**Ключевые labels:**
|
Ящик `admin@<домен>` — пароль в логе `mail` при первом запуске и в `/app/data/initial-passwords.txt`.
|
||||||
|
|
||||||
| Label | Описание |
|
## Не сделано
|
||||||
|-------|----------|
|
|
||||||
| `caddy: mail.jambotron.com` | Домен, на котором доступен сервис |
|
|
||||||
| `caddy.route.api.reverse_proxy` | Проксирует `/api/*` на Spring Boot (порт 8080) |
|
|
||||||
| `caddy.route.static.root` | Корневая директория для Angular фронтенда |
|
|
||||||
| `caddy.route.static.file_server` | Раздаёт статические файлы Angular |
|
|
||||||
|
|
||||||
**Запуск на VPS:**
|
- [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем.
|
||||||
|
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
|
||||||
```bash
|
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
|
||||||
# Клонируйте репозиторий
|
- [x] Защита входа от перебора: SMTP, IMAP, веб (лимит по IP и по ящику, см. «Защита от перебора паролей»).
|
||||||
git clone https://gitea.jambotron.com/jambotron_gang/reactive-mail-server.git
|
- [ ] Защита регистрации от массового заведения ящиков (лимит, капча) — пока регистрация на публичной почте закрыта.
|
||||||
cd reactive-mail-server
|
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
|
||||||
|
- [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5).
|
||||||
# Соберите фронтенд
|
|
||||||
cd frontend && npm install && npx ng build --configuration=production
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# Запустите
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
**Перед запуском:**
|
|
||||||
1. Замените `mail.jambotron.com` на ваш домен в `docker-compose.prod.yml`
|
|
||||||
2. Настройте PTR (Reverse DNS) у хостера — IP → `mail.jambotron.com`
|
|
||||||
3. Добавьте DNS-записи: A (`mail` → IP), MX (`@` → `mail.jambotron.com`), TXT (`v=spf1 ip4:<IP> -all`)
|
|
||||||
4. Откройте порты: 80, 443, 25 (SMTP relay), 3025 (SMTP приём)
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- [x] Хэширование паролей (BCrypt)
|
|
||||||
- [x] Аутентификация (JWT)
|
|
||||||
- [x] Приём SMTP (встроенный сервер на WebFlux TcpServer)
|
|
||||||
- [x] IMAP-сервер (встроенный, порт 143)
|
|
||||||
- [x] Вложения (загрузка/скачивание)
|
|
||||||
- [x] Поиск по письмам (по теме, телу, отправителю, получателю)
|
|
||||||
- [x] Angular фронтенд (v22 + Tailwind CSS + Synthwave дизайн)
|
|
||||||
- [x] Антиспам (чёрный список отправителей)
|
|
||||||
- [x] DKIM/SPF/DMARC (подпись, верификация, DNS-проверка)
|
|
||||||
- [x] SMTP релей (доставка на внешнюю почту)
|
|
||||||
- [x] Контакты (адресная книга)
|
|
||||||
- [x] Reply / Forward
|
|
||||||
- [x] Пагинация писем
|
|
||||||
- [x] HTML/Markdown режим
|
|
||||||
- [x] Caddy Docker Proxy (автоматический HTTPS)
|
|
||||||
- [ ] TLS шифрование SMTP/IMAP
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
+32
-14
@@ -1,17 +1,35 @@
|
|||||||
# Stage 1: Build
|
# Build context: repository root (docker compose: context ".", dockerfile backend/Dockerfile)
|
||||||
FROM eclipse-temurin:21-jdk-alpine AS build
|
# No Alpine images: on Jambotron staging VMs (kernel 6.18.55-virt) binaries from Alpine 3.24.3
|
||||||
WORKDIR /app
|
# (musl 1.2.6) die with "Bus error" — java survived, curl in the healthcheck did not (2026-11-05)
|
||||||
COPY backend/build.gradle ./
|
|
||||||
COPY gradlew ./
|
|
||||||
COPY gradle gradle
|
|
||||||
COPY backend/src src
|
|
||||||
RUN chmod +x gradlew
|
|
||||||
RUN ./gradlew bootJar --no-daemon
|
|
||||||
|
|
||||||
# Stage 2: Run
|
# 1. Web client (Angular) — served by the backend from classpath:/static
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM node:22-bookworm-slim AS web
|
||||||
|
WORKDIR /web
|
||||||
|
COPY frontend/package*.json frontend/.npmrc ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npx ng build --configuration=production
|
||||||
|
|
||||||
|
# 2. Backend
|
||||||
|
FROM eclipse-temurin:21-jdk AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/build/libs/*.jar app.jar
|
COPY gradlew settings.gradle ./
|
||||||
RUN mkdir -p /app/attachments /app/dkim-keys
|
COPY gradle gradle
|
||||||
|
COPY backend/build.gradle backend/
|
||||||
|
COPY backend/src backend/src
|
||||||
|
COPY --from=web /web/dist/frontend/browser backend/src/main/resources/static
|
||||||
|
RUN chmod +x gradlew && ./gradlew :backend:bootJar --no-daemon -x test
|
||||||
|
|
||||||
|
# 3. Runtime: not root; data in /app/data (volume)
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
# Ubuntu image, curl included; own user: "mail" already exists (uid 8)
|
||||||
|
RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/nologin app \
|
||||||
|
&& mkdir -p /app/data/attachments /app/data/dkim-keys /app/data/tls && chown -R app:app /app/data
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/backend/build/libs/*.jar app.jar
|
||||||
|
USER app
|
||||||
|
ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys MAIL_PASSWORDS_FILE=/app/data/initial-passwords.txt MAIL_TLS_DIR=/app/data/tls
|
||||||
EXPOSE 8080 3025
|
EXPOSE 8080 3025
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:8080/actuator/health || exit 1
|
||||||
|
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
|
||||||
|
|||||||
+14
-1
@@ -23,6 +23,8 @@ dependencies {
|
|||||||
// R2DBC — реактивный доступ к PostgreSQL (вместо JPA/Hibernate)
|
// R2DBC — реактивный доступ к PostgreSQL (вместо JPA/Hibernate)
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
|
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
// health для проверки контейнера (только /actuator/health)
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
// SMTP сервер на Spring — встроенный, без внешних зависимостей
|
// SMTP сервер на Spring — встроенный, без внешних зависимостей
|
||||||
implementation 'org.eclipse.angus:angus-mail:2.0.3'
|
implementation 'org.eclipse.angus:angus-mail:2.0.3'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
@@ -35,6 +37,12 @@ dependencies {
|
|||||||
implementation 'org.bouncycastle:bcpkix-jdk18on:1.78.1'
|
implementation 'org.bouncycastle:bcpkix-jdk18on:1.78.1'
|
||||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
||||||
|
|
||||||
|
// DKIM: подпись исходящих (simple-java-mail) и проверка входящих (Apache James jDKIM)
|
||||||
|
implementation 'org.simplejavamail:utils-mail-dkim:3.4.0'
|
||||||
|
implementation 'org.apache.james.jdkim:apache-jdkim-library:0.5'
|
||||||
|
// DNS: MX-записи для доставки
|
||||||
|
implementation 'dnsjava:dnsjava:3.6.3'
|
||||||
|
|
||||||
// R2DBC PostgreSQL драйвер
|
// R2DBC PostgreSQL драйвер
|
||||||
runtimeOnly 'org.postgresql:r2dbc-postgresql:1.0.4.RELEASE'
|
runtimeOnly 'org.postgresql:r2dbc-postgresql:1.0.4.RELEASE'
|
||||||
|
|
||||||
@@ -43,10 +51,15 @@ dependencies {
|
|||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
|
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
testImplementation 'com.icegreen:greenmail:2.0.1'
|
testImplementation 'com.icegreen:greenmail:2.1.15'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only the executable jar (the plain jar would make two files in build/libs)
|
||||||
|
tasks.named('jar') {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,91 @@ import com.mailserver.repository.UserRepository;
|
|||||||
import com.mailserver.service.UserService;
|
import com.mailserver.service.UserService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailboxes the server needs on start:
|
||||||
|
* <ul>
|
||||||
|
* <li>the first mailbox on an empty database — {@code mailserver.admin.email} (default admin@<first domain>)
|
||||||
|
* with {@code mailserver.admin.password};</li>
|
||||||
|
* <li>the catch-all mailbox ({@code mailserver.catch-all}) if it does not exist yet — mail to unknown addresses is
|
||||||
|
* stored there, so it must exist.</li>
|
||||||
|
* </ul>
|
||||||
|
* A generated password is printed to the log once (visible in Jambotron: project → Services → mail → Logs) and appended
|
||||||
|
* to {@code mailserver.passwords-file} (mode 600, in the data volume) — change it after the first login.
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class DataInitializer {
|
public class DataInitializer {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository) {
|
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains,
|
||||||
|
@Value("${mailserver.admin.email:}") String email,
|
||||||
|
@Value("${mailserver.admin.password:}") String password,
|
||||||
|
@Value("${mailserver.passwords-file:}") String passwordsFile) {
|
||||||
return args -> {
|
return args -> {
|
||||||
userRepository.count().block(); // Ждём инициализации
|
Path file = passwordsFile.isBlank() ? null : Path.of(passwordsFile);
|
||||||
if (userRepository.count().block() == 0) {
|
if (userRepository.count().block() == 0) {
|
||||||
log.info("База данных пуста, создаю тестового пользователя...");
|
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase(Locale.ROOT);
|
||||||
userService.register("admin@mailserver.local", "admin123", "Администратор").block();
|
create(userService, address, password, "Administrator", file);
|
||||||
log.info("✓ Тестовый пользователь создан: admin@mailserver.local / admin123");
|
}
|
||||||
|
String catchAll = domains.catchAll();
|
||||||
|
if (catchAll != null && !Boolean.TRUE.equals(userRepository.existsByEmail(catchAll).block())) {
|
||||||
|
create(userService, catchAll, "", "Mail trap", file);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void create(UserService userService, String address, String password, String name, Path file)
|
||||||
|
throws IOException {
|
||||||
|
boolean generated = password.isBlank();
|
||||||
|
String secret = generated ? randomPassword() : password;
|
||||||
|
userService.register(address, secret, name).block();
|
||||||
|
if (!generated) {
|
||||||
|
log.info("Mailbox created: {}", address);
|
||||||
|
} else if (file != null) {
|
||||||
|
appendPassword(file, address, secret);
|
||||||
|
// also in the log: the owner sees it in Jambotron (project → Services → mail → Logs) without a shell
|
||||||
|
log.warn("Mailbox created: {} — password: {} (also in {}; change it)", address, secret, file);
|
||||||
|
} else {
|
||||||
|
log.warn("Mailbox created: {} — password: {} (shown once, change it)", address, secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void appendPassword(Path file, String address, String password) throws IOException {
|
||||||
|
if (file.getParent() != null) {
|
||||||
|
Files.createDirectories(file.getParent());
|
||||||
|
}
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
Files.createFile(file);
|
||||||
|
try {
|
||||||
|
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
|
||||||
|
} catch (UnsupportedOperationException ignored) {
|
||||||
|
// not a POSIX file system (Windows) — nothing to restrict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Files.writeString(file, Instant.now() + " " + address + " " + password + System.lineSeparator(),
|
||||||
|
StandardCharsets.UTF_8, StandardOpenOption.APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String randomPassword() {
|
||||||
|
byte[] bytes = new byte[18];
|
||||||
|
new SecureRandom().nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import com.mailserver.security.LoginGuard;
|
||||||
|
import com.mailserver.imap.DbImapStore;
|
||||||
|
import com.mailserver.imap.ImapServer;
|
||||||
|
import com.mailserver.imap.ImapStore;
|
||||||
|
import com.mailserver.repository.AttachmentRepository;
|
||||||
|
import com.mailserver.repository.MessageRepository;
|
||||||
|
import com.mailserver.repository.UserRepository;
|
||||||
|
import com.mailserver.service.MailService;
|
||||||
|
import com.mailserver.service.MessageSources;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.r2dbc.core.DatabaseClient;
|
||||||
|
import org.springframework.util.unit.DataSize;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/** IMAP (75.2): STARTTLS on mailserver.imap.port, implicit TLS on mailserver.imaps.port when set. */
|
||||||
|
@Configuration
|
||||||
|
public class ImapConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
|
||||||
|
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
||||||
|
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||||
|
@ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard,
|
||||||
|
@Value("${mailserver.imap.host:0.0.0.0}") String host,
|
||||||
|
@Value("${mailserver.imap.port:3143}") int port,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||||
|
@Value("${mailserver.imap.auth-without-tls:false}") boolean authWithoutTls,
|
||||||
|
@Value("${mailserver.imap.max-literal:50MB}") DataSize maxLiteral,
|
||||||
|
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
|
||||||
|
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
|
||||||
|
return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false,
|
||||||
|
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||||
|
@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, LoginGuard guard,
|
||||||
|
@Value("${mailserver.imap.host:0.0.0.0}") String host,
|
||||||
|
@Value("${mailserver.imaps.port}") int port,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||||
|
@Value("${mailserver.imap.max-literal:50MB}") DataSize maxLiteral,
|
||||||
|
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
|
||||||
|
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
|
||||||
|
return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true,
|
||||||
|
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,580 +0,0 @@
|
|||||||
package com.mailserver.config;
|
|
||||||
|
|
||||||
import com.mailserver.repository.FolderRepository;
|
|
||||||
import com.mailserver.repository.MessageRepository;
|
|
||||||
import com.mailserver.repository.UserRepository;
|
|
||||||
import com.mailserver.service.UserService;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import jakarta.annotation.PreDestroy;
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.net.ServerSocket;
|
|
||||||
import java.net.Socket;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Встроенный IMAP-сервер для почтового сервера.
|
|
||||||
* Поддерживает основные команды IMAP4rev1: LOGIN, SELECT, FETCH, SEARCH, STORE, EXPUNGE, LOGOUT.
|
|
||||||
* Запускается при старте приложения и останавливается при завершении.
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
public class ImapServerConfig {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ImapServerConfig.class);
|
|
||||||
|
|
||||||
@Value("${mailserver.imap.enabled:true}")
|
|
||||||
private boolean enabled;
|
|
||||||
|
|
||||||
@Value("${mailserver.imap.port:143}")
|
|
||||||
private int port;
|
|
||||||
|
|
||||||
@Value("${mailserver.imap.hostname:localhost}")
|
|
||||||
private String hostname;
|
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final FolderRepository folderRepository;
|
|
||||||
private final MessageRepository messageRepository;
|
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
private ServerSocket serverSocket;
|
|
||||||
private ExecutorService executorService;
|
|
||||||
|
|
||||||
public ImapServerConfig(UserRepository userRepository, FolderRepository folderRepository,
|
|
||||||
MessageRepository messageRepository, UserService userService) {
|
|
||||||
this.userRepository = userRepository;
|
|
||||||
this.folderRepository = folderRepository;
|
|
||||||
this.messageRepository = messageRepository;
|
|
||||||
this.userService = userService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
public void start() {
|
|
||||||
if (!enabled) {
|
|
||||||
log.info("IMAP-сервер отключён (mailserver.imap.enabled=false)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
executorService = Executors.newCachedThreadPool();
|
|
||||||
|
|
||||||
try {
|
|
||||||
serverSocket = new ServerSocket(port);
|
|
||||||
log.info("IMAP-сервер запущен на порту {} (хост: {})", port, hostname);
|
|
||||||
|
|
||||||
// Принимаем подключения в цикле
|
|
||||||
while (!serverSocket.isClosed()) {
|
|
||||||
try {
|
|
||||||
Socket clientSocket = serverSocket.accept();
|
|
||||||
executorService.submit(() -> handleClient(clientSocket));
|
|
||||||
} catch (IOException e) {
|
|
||||||
if (!serverSocket.isClosed()) {
|
|
||||||
log.error("Ошибка при принятии подключения", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
if (serverSocket == null || !serverSocket.isClosed()) {
|
|
||||||
log.error("Не удалось запустить IMAP-сервер на порту {}", port, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
public void stop() {
|
|
||||||
log.info("Остановка IMAP-сервера...");
|
|
||||||
try {
|
|
||||||
if (serverSocket != null && !serverSocket.isClosed()) {
|
|
||||||
serverSocket.close();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Ошибка при остановке IMAP-сервера", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (executorService != null) {
|
|
||||||
executorService.shutdown();
|
|
||||||
try {
|
|
||||||
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
|
|
||||||
executorService.shutdownNow();
|
|
||||||
}
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
executorService.shutdownNow();
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info("IMAP-сервер остановлен");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleClient(Socket socket) {
|
|
||||||
String clientAddress = socket.getInetAddress().getHostAddress();
|
|
||||||
log.info("Новое IMAP-подключение от {}", clientAddress);
|
|
||||||
|
|
||||||
try (socket;
|
|
||||||
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
|
|
||||||
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
|
|
||||||
|
|
||||||
// Отправляем приветствие
|
|
||||||
out.println("* OK IMAP4rev1 Server Ready");
|
|
||||||
|
|
||||||
String username = null;
|
|
||||||
String line;
|
|
||||||
|
|
||||||
while (username == null && (line = in.readLine()) != null) {
|
|
||||||
String[] parts = parseCommand(line);
|
|
||||||
if (parts == null) continue;
|
|
||||||
|
|
||||||
String tag = parts[0];
|
|
||||||
String command = parts[1].toUpperCase();
|
|
||||||
|
|
||||||
switch (command) {
|
|
||||||
case "LOGIN" -> {
|
|
||||||
if (parts.length >= 4) {
|
|
||||||
String email = parts[2].toLowerCase();
|
|
||||||
String password = parts[3];
|
|
||||||
|
|
||||||
// Проверяем пароль через BCrypt
|
|
||||||
com.mailserver.domain.User user = userRepository.findByEmail(email).block();
|
|
||||||
if (user != null && userService.checkPassword(user, password)) {
|
|
||||||
username = email;
|
|
||||||
out.println(tag + " OK LOGIN completed");
|
|
||||||
log.info("Пользователь {} успешно авторизован через IMAP", email);
|
|
||||||
} else {
|
|
||||||
out.println(tag + " NO LOGIN failed: invalid credentials");
|
|
||||||
log.warn("Неудачная попытка IMAP-авторизации для пользователя {}", email);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out.println(tag + " BAD Invalid LOGIN command");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "CAPABILITY" -> {
|
|
||||||
out.println("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE LITERAL+");
|
|
||||||
out.println(tag + " OK Capability completed");
|
|
||||||
}
|
|
||||||
case "LOGOUT" -> {
|
|
||||||
out.println("* BYE IMAP4rev1 server terminating");
|
|
||||||
out.println(tag + " OK LOGOUT completed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
default -> {
|
|
||||||
if (username == null) {
|
|
||||||
out.println(tag + " BAD Authentication required");
|
|
||||||
} else {
|
|
||||||
handleAuthenticatedCommand(tag, command, parts, username, out, in);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Ошибка при обработке IMAP-подключения от {}", clientAddress, e);
|
|
||||||
} finally {
|
|
||||||
log.info("IMAP-подключение от {} закрыто", clientAddress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleAuthenticatedCommand(String tag, String command, String[] parts,
|
|
||||||
String username, PrintWriter out, BufferedReader in) {
|
|
||||||
switch (command) {
|
|
||||||
case "SELECT" -> handleSelect(tag, parts, username, out);
|
|
||||||
case "EXAMINE" -> handleSelect(tag, parts, username, out); // EXAMINE = SELECT в read-only режиме
|
|
||||||
case "FETCH" -> handleFetch(tag, parts, username, out);
|
|
||||||
case "SEARCH" -> handleSearch(tag, parts, username, out);
|
|
||||||
case "STORE" -> handleStore(tag, parts, username, out);
|
|
||||||
case "EXPUNGE" -> handleExpunge(tag, username, out);
|
|
||||||
case "LIST" -> handleList(tag, username, out);
|
|
||||||
case "LSUB" -> handleList(tag, username, out); // LSUB = LIST для подписок
|
|
||||||
case "STATUS" -> handleStatus(tag, parts, username, out);
|
|
||||||
case "ID" -> {
|
|
||||||
out.println("* ID (\"name\" \"MailServer IMAP\" \"version\" \"1.0\")");
|
|
||||||
out.println(tag + " OK ID completed");
|
|
||||||
}
|
|
||||||
default -> {
|
|
||||||
out.println(tag + " BAD Unknown command: " + command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleSelect(String tag, String[] parts, String username, PrintWriter out) {
|
|
||||||
if (parts.length < 3) {
|
|
||||||
out.println(tag + " BAD Invalid SELECT command");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String folderName = parts[2].replace("\"", "");
|
|
||||||
com.mailserver.domain.User userTemp = userRepository.findByEmail(username).block();
|
|
||||||
if (userTemp == null) {
|
|
||||||
out.println(tag + " NO User not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName(
|
|
||||||
userTemp.getId(), folderName).block();
|
|
||||||
|
|
||||||
if (folder == null) {
|
|
||||||
out.println(tag + " NO [NONEXISTENT] Mailbox does not exist");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
long messageCount = messageRepository.findByFolderId(folder.getId(),
|
|
||||||
org.springframework.data.domain.PageRequest.of(0, 1)).count().block();
|
|
||||||
|
|
||||||
out.println("* " + messageCount + " EXISTS");
|
|
||||||
out.println("* 0 RECENT");
|
|
||||||
out.println("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");
|
|
||||||
out.println(tag + " [OK] [UNSEEN 1] " + (messageCount > 0 ? "There are unseen messages in the mailbox." : "The mailbox has no unseen messages."));
|
|
||||||
out.println(tag + " OK [READ-WRITE] SELECT completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleFetch(String tag, String[] parts, String username, PrintWriter out) {
|
|
||||||
if (parts.length < 4) {
|
|
||||||
out.println(tag + " BAD Invalid FETCH command");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String messageId = parts[2];
|
|
||||||
String item = parts[3];
|
|
||||||
|
|
||||||
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
|
|
||||||
if (user == null) {
|
|
||||||
out.println(tag + " NO User not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем все сообщения пользователя
|
|
||||||
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
|
|
||||||
java.util.List<com.mailserver.domain.Message> allMessages = new java.util.ArrayList<>();
|
|
||||||
for (com.mailserver.domain.Folder folder : folders) {
|
|
||||||
allMessages.addAll(messageRepository.findByFolderId(folder.getId(),
|
|
||||||
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Находим сообщение по UID или индексу
|
|
||||||
com.mailserver.domain.Message message = null;
|
|
||||||
if (item.contains("UID")) {
|
|
||||||
// UID FETCH
|
|
||||||
try {
|
|
||||||
long uid = Long.parseLong(messageId);
|
|
||||||
for (com.mailserver.domain.Message msg : allMessages) {
|
|
||||||
if (msg.getId().equals(uid)) {
|
|
||||||
message = msg;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
out.println(tag + " BAD Invalid UID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// FETCH по индексу (1-based)
|
|
||||||
try {
|
|
||||||
int index = Integer.parseInt(messageId) - 1;
|
|
||||||
if (index >= 0 && index < allMessages.size()) {
|
|
||||||
message = allMessages.get(index);
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
out.println(tag + " BAD Invalid message set");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message == null) {
|
|
||||||
out.println(tag + " NO Message not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Формируем ответ FETCH
|
|
||||||
StringBuilder flags = new StringBuilder();
|
|
||||||
if (message.isRead()) flags.append("\\Seen ");
|
|
||||||
if (message.isFlagged()) flags.append("\\Flagged ");
|
|
||||||
if (message.isSpam()) flags.append("\\Deleted ");
|
|
||||||
if (message.getBody() != null && !message.getBody().isEmpty()) flags.append("\\Answered ");
|
|
||||||
|
|
||||||
out.println("* " + messageId + " FETCH (");
|
|
||||||
out.println(" UID " + message.getId());
|
|
||||||
out.println(" FLAGS (" + flags.toString().trim() + ")");
|
|
||||||
out.println(" INTERNALDATE \"" + formatImapDate(message.getReceivedAt()) + "\"");
|
|
||||||
out.println(" RFC822.SIZE " + (message.getMessageSize() != null ? message.getMessageSize() : 0));
|
|
||||||
out.println(" ENVELOPE (");
|
|
||||||
out.println(" \"" + escapeString(message.getReceivedAt() != null ? message.getReceivedAt().toString() : "") + "\"");
|
|
||||||
out.println(" \"" + escapeString(message.getSubject() != null ? message.getSubject() : "") + "\"");
|
|
||||||
out.println(" (\"" + escapeString(message.getSenderName() != null ? message.getSenderName() : "") + "\" 0 \"\" \"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")");
|
|
||||||
out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")");
|
|
||||||
out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")");
|
|
||||||
out.println(" (\"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")");
|
|
||||||
out.println(" NIL");
|
|
||||||
out.println(" NIL");
|
|
||||||
out.println(" NIL");
|
|
||||||
out.println(" )");
|
|
||||||
|
|
||||||
// Если запрошено тело сообщения
|
|
||||||
if (item.contains("BODY[]") || item.contains("RFC822")) {
|
|
||||||
out.println(" BODY[] {");
|
|
||||||
String body = message.getBody() != null ? message.getBody() : "";
|
|
||||||
out.println(body.length());
|
|
||||||
out.println(body);
|
|
||||||
out.println(")");
|
|
||||||
}
|
|
||||||
|
|
||||||
out.println(")");
|
|
||||||
out.println(tag + " OK FETCH completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleSearch(String tag, String[] parts, String username, PrintWriter out) {
|
|
||||||
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
|
|
||||||
if (user == null) {
|
|
||||||
out.println(tag + " NO User not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
com.mailserver.domain.Folder inbox = folderRepository.findByUserIdAndName(user.getId(), "INBOX").block();
|
|
||||||
if (inbox == null) {
|
|
||||||
out.println(tag + " OK SEARCH completed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Простой поиск по ключевому слову
|
|
||||||
String keyword = null;
|
|
||||||
for (int i = 2; i < parts.length; i++) {
|
|
||||||
if (!parts[i].equals("ALL") && !parts[i].equals("FROM") && !parts[i].equals("SUBJECT") && !parts[i].equals("BODY")) {
|
|
||||||
keyword = parts[i];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
java.util.List<Long> messageIds;
|
|
||||||
if (keyword != null && !keyword.isEmpty()) {
|
|
||||||
var results = messageRepository.searchInFolder(inbox.getId(), keyword,
|
|
||||||
org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block();
|
|
||||||
messageIds = results.stream()
|
|
||||||
.map(com.mailserver.domain.Message::getId)
|
|
||||||
.collect(java.util.stream.Collectors.toList());
|
|
||||||
} else {
|
|
||||||
var results = messageRepository.findByFolderId(inbox.getId(),
|
|
||||||
org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block();
|
|
||||||
messageIds = results.stream()
|
|
||||||
.map(com.mailserver.domain.Message::getId)
|
|
||||||
.collect(java.util.stream.Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder ids = new StringBuilder();
|
|
||||||
for (Long id : messageIds) {
|
|
||||||
ids.append(id).append(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
out.println("* SEARCH " + ids.toString().trim());
|
|
||||||
out.println(tag + " OK SEARCH completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleStore(String tag, String[] parts, String username, PrintWriter out) {
|
|
||||||
if (parts.length < 5) {
|
|
||||||
out.println(tag + " BAD Invalid STORE command");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String messageId = parts[2];
|
|
||||||
String item = parts[3].toUpperCase();
|
|
||||||
String value = parts[4].toUpperCase();
|
|
||||||
|
|
||||||
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
|
|
||||||
if (user == null) {
|
|
||||||
out.println(tag + " NO User not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем все сообщения пользователя
|
|
||||||
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
|
|
||||||
java.util.List<com.mailserver.domain.Message> allMessages = new java.util.ArrayList<>();
|
|
||||||
for (com.mailserver.domain.Folder folder : folders) {
|
|
||||||
allMessages.addAll(messageRepository.findByFolderId(folder.getId(),
|
|
||||||
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block());
|
|
||||||
}
|
|
||||||
|
|
||||||
com.mailserver.domain.Message message = null;
|
|
||||||
try {
|
|
||||||
int index = Integer.parseInt(messageId) - 1;
|
|
||||||
if (index >= 0 && index < allMessages.size()) {
|
|
||||||
message = allMessages.get(index);
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
out.println(tag + " BAD Invalid message set");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message == null) {
|
|
||||||
out.println(tag + " NO Message not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean saved = false;
|
|
||||||
if (item.contains("+FLAGS") || item.contains("FLAGS")) {
|
|
||||||
if (value.contains("\\SEEN")) {
|
|
||||||
message.setRead(true);
|
|
||||||
saved = true;
|
|
||||||
}
|
|
||||||
if (value.contains("\\FLAGGED")) {
|
|
||||||
message.setFlagged(true);
|
|
||||||
saved = true;
|
|
||||||
}
|
|
||||||
if (value.contains("\\DELETED")) {
|
|
||||||
message.setSpam(true); // Используем spam как флаг удаления для IMAP
|
|
||||||
saved = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.contains("-FLAGS")) {
|
|
||||||
if (value.contains("\\SEEN")) {
|
|
||||||
message.setRead(false);
|
|
||||||
saved = true;
|
|
||||||
}
|
|
||||||
if (value.contains("\\FLAGGED")) {
|
|
||||||
message.setFlagged(false);
|
|
||||||
saved = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saved) {
|
|
||||||
messageRepository.save(message).block();
|
|
||||||
}
|
|
||||||
|
|
||||||
out.println("* " + messageId + " FETCH (FLAGS (" + formatFlags(message) + "))");
|
|
||||||
out.println(tag + " OK STORE completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleExpunge(String tag, String username, PrintWriter out) {
|
|
||||||
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
|
|
||||||
if (user == null) {
|
|
||||||
out.println(tag + " NO User not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
|
|
||||||
for (com.mailserver.domain.Folder folder : folders) {
|
|
||||||
var messages = messageRepository.findByFolderId(folder.getId(),
|
|
||||||
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block();
|
|
||||||
for (com.mailserver.domain.Message message : messages) {
|
|
||||||
if (message.isSpam()) {
|
|
||||||
messageRepository.delete(message).block();
|
|
||||||
folder.setMessageCount(Math.max(0, folder.getMessageCount() - 1));
|
|
||||||
folderRepository.save(folder).block();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out.println("* 0 EXPUNGE");
|
|
||||||
out.println(tag + " OK EXPUNGE completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleList(String tag, String username, PrintWriter out) {
|
|
||||||
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
|
|
||||||
if (user == null) {
|
|
||||||
out.println(tag + " NO User not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
|
|
||||||
for (com.mailserver.domain.Folder folder : folders) {
|
|
||||||
out.println("* LIST (\\HasNoChildren) \".\" \"" + folder.getName() + "\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
out.println(tag + " OK LIST completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleStatus(String tag, String[] parts, String username, PrintWriter out) {
|
|
||||||
if (parts.length < 4) {
|
|
||||||
out.println(tag + " BAD Invalid STATUS command");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String folderName = parts[2].replace("\"", "");
|
|
||||||
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
|
|
||||||
if (user == null) {
|
|
||||||
out.println(tag + " NO User not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName(user.getId(), folderName).block();
|
|
||||||
if (folder == null) {
|
|
||||||
out.println(tag + " NO [NONEXISTENT] Mailbox does not exist");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
long messageCount = messageRepository.findByFolderId(folder.getId(),
|
|
||||||
org.springframework.data.domain.PageRequest.of(0, 1)).count().block();
|
|
||||||
long unreadCount = messageRepository.countUnreadByFolderId(folder.getId()).block();
|
|
||||||
|
|
||||||
out.println("* STATUS " + folderName + " (MESSAGES " + messageCount + " UNSEEN " + unreadCount + " UIDNEXT " + (messageCount + 1) + " UIDVALIDITY 1)");
|
|
||||||
out.println(tag + " OK STATUS completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String[] parseCommand(String line) {
|
|
||||||
if (line == null || line.trim().isEmpty()) return null;
|
|
||||||
|
|
||||||
line = line.trim();
|
|
||||||
int spaceIndex = line.indexOf(' ');
|
|
||||||
if (spaceIndex == -1) return new String[]{line, ""};
|
|
||||||
|
|
||||||
String tag = line.substring(0, spaceIndex);
|
|
||||||
String rest = line.substring(spaceIndex + 1).trim();
|
|
||||||
|
|
||||||
// Разбираем команду и аргументы
|
|
||||||
String[] parts = new String[20]; // Максимум 20 аргументов
|
|
||||||
parts[0] = tag;
|
|
||||||
int index = 1;
|
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
while (i < rest.length() && index < parts.length) {
|
|
||||||
if (rest.charAt(i) == '"') {
|
|
||||||
// Строка в кавычках
|
|
||||||
int end = rest.indexOf('"', i + 1);
|
|
||||||
if (end == -1) break;
|
|
||||||
parts[index++] = rest.substring(i + 1, end);
|
|
||||||
i = end + 1;
|
|
||||||
} else if (Character.isWhitespace(rest.charAt(i))) {
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
int end = rest.indexOf(' ', i);
|
|
||||||
if (end == -1) end = rest.length();
|
|
||||||
parts[index++] = rest.substring(i, end);
|
|
||||||
i = end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обрезаем массив до нужного размера
|
|
||||||
String[] result = new String[index];
|
|
||||||
System.arraycopy(parts, 0, result, 0, index);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatImapDate(Instant instant) {
|
|
||||||
if (instant == null) return "";
|
|
||||||
// Формат IMAP: "dd-MMM-yyyy hh:mm:ss +0000"
|
|
||||||
String[] months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
|
|
||||||
java.time.LocalDate date = instant.atZone(java.time.ZoneOffset.UTC).toLocalDate();
|
|
||||||
java.time.LocalTime time = instant.atZone(java.time.ZoneOffset.UTC).toLocalTime();
|
|
||||||
|
|
||||||
return String.format("%02d-%s-%04d %02d:%02d:%02d +0000",
|
|
||||||
date.getDayOfMonth(), months[date.getMonthValue() - 1], date.getYear(),
|
|
||||||
time.getHour(), time.getMinute(), time.getSecond());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String escapeString(String str) {
|
|
||||||
if (str == null) return "";
|
|
||||||
return str.replace("\\", "\\\\").replace("\"", "\\\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatFlags(com.mailserver.domain.Message message) {
|
|
||||||
StringBuilder flags = new StringBuilder();
|
|
||||||
if (message.isRead()) flags.append("\\Seen ");
|
|
||||||
if (message.isFlagged()) flags.append("\\Flagged ");
|
|
||||||
if (message.isSpam()) flags.append("\\Deleted ");
|
|
||||||
return flags.toString().trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,17 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class JwtConfig {
|
public class JwtConfig {
|
||||||
|
|
||||||
@Value("${jwt.secret:MailServerSecretKeyForJWTTokenGenerationMustBeLongEnough2024!}")
|
/** JWT_SECRET from the environment, at least 32 bytes; no default — a known key would let anyone log in. */
|
||||||
private String jwtSecret;
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public JwtUtils jwtUtils() {
|
public JwtUtils jwtUtils(@Value("${jwt.secret:}") String jwtSecret) {
|
||||||
|
if (jwtSecret.getBytes(StandardCharsets.UTF_8).length < 32) {
|
||||||
|
throw new IllegalStateException("jwt.secret (JWT_SECRET) must be set, at least 32 bytes");
|
||||||
|
}
|
||||||
return new JwtUtils(jwtSecret);
|
return new JwtUtils(jwtSecret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domains whose mailboxes live on this server ({@code mailserver.domains}), and the optional catch-all mailbox
|
||||||
|
* ({@code mailserver.catch-all}): when set, mail to ANY address is stored there and nothing is delivered outside —
|
||||||
|
* a mail trap for dev/staging.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class MailDomains {
|
||||||
|
|
||||||
|
private final Set<String> domains;
|
||||||
|
private final String catchAll;
|
||||||
|
|
||||||
|
public MailDomains(@Value("${mailserver.domains:localhost}") String domains,
|
||||||
|
@Value("${mailserver.catch-all:}") String catchAll) {
|
||||||
|
this.domains = Arrays.stream(domains.split(","))
|
||||||
|
.map(d -> d.trim().toLowerCase(Locale.ROOT))
|
||||||
|
.filter(d -> !d.isEmpty())
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
this.catchAll = catchAll.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (this.domains.isEmpty()) {
|
||||||
|
throw new IllegalStateException("mailserver.domains is empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLocal(String address) {
|
||||||
|
return domains.contains(domainOf(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mailbox that receives everything, or null. */
|
||||||
|
public String catchAll() {
|
||||||
|
return catchAll.isEmpty() ? null : catchAll;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> domains() {
|
||||||
|
return domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String domainOf(String address) {
|
||||||
|
int at = address == null ? -1 : address.lastIndexOf('@');
|
||||||
|
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import com.mailserver.security.LoginGuard;
|
||||||
|
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||||
|
import com.mailserver.smtp.MailRouter;
|
||||||
|
import com.mailserver.smtp.SmtpServer;
|
||||||
|
import com.mailserver.smtp.SmtpTls;
|
||||||
|
import io.netty.handler.ssl.SslContext;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** SMTP server, outbound delivery client and the queue scheduler. */
|
||||||
|
@Configuration
|
||||||
|
@EnableScheduling
|
||||||
|
public class MailServerConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate for SMTP TLS: mailserver.tls.cert-file + key-file (PEM), or a self-signed one in mailserver.tls.dir.
|
||||||
|
* mailserver.tls.enabled=false — no TLS at all.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SmtpTlsContext smtpTlsContext(@Value("${mailserver.tls.enabled:true}") boolean enabled,
|
||||||
|
@Value("${mailserver.tls.cert-file:}") String certFile,
|
||||||
|
@Value("${mailserver.tls.key-file:}") String keyFile,
|
||||||
|
@Value("${mailserver.tls.dir:./tls}") String dir,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname) throws Exception {
|
||||||
|
return new SmtpTlsContext(enabled ? SmtpTls.serverContext(certFile, keyFile, Path.of(dir), hostname) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SmtpTlsContext(SslContext context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||||
|
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard,
|
||||||
|
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||||
|
@Value("${mailserver.smtp.port:3025}") int port,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||||
|
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
|
||||||
|
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
|
||||||
|
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
|
||||||
|
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout,
|
||||||
|
tls.context(), false), router, guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */
|
||||||
|
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||||
|
@ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}")
|
||||||
|
public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard,
|
||||||
|
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||||
|
@Value("${mailserver.smtps.port}") int port,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||||
|
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
|
||||||
|
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
|
||||||
|
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout,
|
||||||
|
tls.context(), true), router, guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mailserver.outbound.mode: off | mx | smarthost. For smarthost — host, port, username, password,
|
||||||
|
* tls (starttls | ssl | none).
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SmtpDeliveryClient smtpDeliveryClient(@Value("${mailserver.outbound.mode:off}") String mode,
|
||||||
|
@Value("${mailserver.outbound.host:}") String host,
|
||||||
|
@Value("${mailserver.outbound.port:587}") int port,
|
||||||
|
@Value("${mailserver.outbound.username:}") String username,
|
||||||
|
@Value("${mailserver.outbound.password:}") String password,
|
||||||
|
@Value("${mailserver.outbound.tls:starttls}") String tls,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||||
|
@Value("${mailserver.outbound.timeout-ms:30000}") int timeoutMs) {
|
||||||
|
SmtpDeliveryClient.Mode m = SmtpDeliveryClient.Mode.valueOf(mode.trim().toUpperCase(Locale.ROOT));
|
||||||
|
if (m == SmtpDeliveryClient.Mode.SMARTHOST && host.isBlank()) {
|
||||||
|
throw new IllegalStateException("mailserver.outbound.mode=smarthost needs mailserver.outbound.host");
|
||||||
|
}
|
||||||
|
return new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(m, host, port, username, password,
|
||||||
|
SmtpDeliveryClient.Tls.valueOf(tls.trim().toUpperCase(Locale.ROOT)), hostname, timeoutMs, 25));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ package com.mailserver.config;
|
|||||||
|
|
||||||
import com.mailserver.security.JwtAuthenticationFilter;
|
import com.mailserver.security.JwtAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||||
|
import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||||
@@ -16,15 +18,19 @@ import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
|||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
private final String corsOrigins;
|
||||||
|
|
||||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||||
|
@org.springframework.beans.factory.annotation.Value("${mailserver.cors.origins:}") String corsOrigins) {
|
||||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||||
|
this.corsOrigins = corsOrigins;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
configuration.setAllowedOrigins(java.util.Arrays.asList("http://localhost:4200", "http://localhost:4201"));
|
// The web client is served from the same origin; other origins only for development (ng serve)
|
||||||
|
configuration.setAllowedOrigins(java.util.Arrays.stream(corsOrigins.split(",")).map(String::trim).filter(o -> !o.isEmpty()).toList());
|
||||||
configuration.setAllowedMethods(java.util.Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
configuration.setAllowedMethods(java.util.Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
configuration.setAllowedHeaders(java.util.Arrays.asList("Authorization", "Content-Type", "Accept", "X-Requested-With"));
|
configuration.setAllowedHeaders(java.util.Arrays.asList("Authorization", "Content-Type", "Accept", "X-Requested-With"));
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(true);
|
||||||
@@ -38,10 +44,17 @@ public class SecurityConfig {
|
|||||||
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
|
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
|
||||||
http
|
http
|
||||||
.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||||
|
// JWT only: without this an API call with no token gets 401 + "WWW-Authenticate: Basic",
|
||||||
|
// and the browser shows its own login popup where our mailbox password does not work
|
||||||
|
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||||
|
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||||
|
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.authorizeExchange(exchange -> exchange
|
.authorizeExchange(exchange -> exchange
|
||||||
.pathMatchers("/api/login", "/api/register").permitAll()
|
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
|
||||||
.anyExchange().authenticated()
|
.pathMatchers("/api/**", "/actuator/**").authenticated()
|
||||||
|
// the web client (static files, SPA routes)
|
||||||
|
.anyExchange().permitAll()
|
||||||
)
|
)
|
||||||
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION);
|
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION);
|
||||||
|
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
package com.mailserver.config;
|
|
||||||
|
|
||||||
import com.mailserver.service.DkimService;
|
|
||||||
import com.mailserver.service.DmarcService;
|
|
||||||
import com.mailserver.service.MailService;
|
|
||||||
import com.mailserver.service.SmtpRelayService;
|
|
||||||
import com.mailserver.service.SpfService;
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import jakarta.annotation.PreDestroy;
|
|
||||||
import jakarta.mail.MessagingException;
|
|
||||||
import jakarta.mail.Session;
|
|
||||||
import jakarta.mail.internet.AddressException;
|
|
||||||
import jakarta.mail.internet.InternetAddress;
|
|
||||||
import jakarta.mail.internet.MimeMessage;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
import reactor.core.publisher.Sinks;
|
|
||||||
import reactor.netty.DisposableServer;
|
|
||||||
import reactor.netty.tcp.TcpServer;
|
|
||||||
|
|
||||||
import java.io.*;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Встроенный SMTP-сервер на Spring WebFlux (TcpServer).
|
|
||||||
* Поддерживает команды: HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT, RSET, NOOP.
|
|
||||||
* Обрабатывает входящие письма и сохраняет их через MailService.
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
public class SmtpServerConfig {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(SmtpServerConfig.class);
|
|
||||||
|
|
||||||
private final MailService mailService;
|
|
||||||
private final SmtpRelayService smtpRelayService;
|
|
||||||
private final DkimService dkimService;
|
|
||||||
private final SpfService spfService;
|
|
||||||
private final DmarcService dmarcService;
|
|
||||||
|
|
||||||
@Value("${mailserver.smtp.port:3025}")
|
|
||||||
private int smtpPort;
|
|
||||||
|
|
||||||
@Value("${mailserver.smtp.hostname:localhost}")
|
|
||||||
private String smtpHostname;
|
|
||||||
|
|
||||||
@Value("${mailserver.smtp.enabled:true}")
|
|
||||||
private boolean enabled;
|
|
||||||
|
|
||||||
private DisposableServer disposableServer;
|
|
||||||
|
|
||||||
private final Map<String, String> capabilities = new HashMap<>();
|
|
||||||
|
|
||||||
public SmtpServerConfig(MailService mailService, SmtpRelayService smtpRelayService,
|
|
||||||
DkimService dkimService,
|
|
||||||
SpfService spfService, DmarcService dmarcService) {
|
|
||||||
this.mailService = mailService;
|
|
||||||
this.smtpRelayService = smtpRelayService;
|
|
||||||
this.dkimService = dkimService;
|
|
||||||
this.spfService = spfService;
|
|
||||||
this.dmarcService = dmarcService;
|
|
||||||
capabilities.put("SIZE", "10485760");
|
|
||||||
capabilities.put("8BITMIME", "");
|
|
||||||
capabilities.put("ENHANCEDSTATUSCODES", "");
|
|
||||||
capabilities.put("PIPELINING", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
public void start() {
|
|
||||||
if (!enabled) {
|
|
||||||
log.info("SMTP-сервер отключён (mailserver.smtp.enabled=false)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
disposableServer = TcpServer.create()
|
|
||||||
.port(smtpPort)
|
|
||||||
.handle((inbound, outbound) ->
|
|
||||||
outbound.sendString(Mono.just("220 " + smtpHostname + " ESMTP ready\r\n"))
|
|
||||||
.then(handleSmtpSession(inbound, outbound))
|
|
||||||
)
|
|
||||||
.bind()
|
|
||||||
.block(java.time.Duration.ofSeconds(10));
|
|
||||||
log.info("SMTP-сервер запущен на порту {} (хост: {})", smtpPort, smtpHostname);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Не удалось запустить SMTP-сервер на порту {}: {}", smtpPort, e.getMessage());
|
|
||||||
// Не бросаем исключение, чтобы приложение могло стартовать без SMTP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
public void stop() {
|
|
||||||
if (disposableServer != null) {
|
|
||||||
try {
|
|
||||||
disposableServer.dispose();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Ошибка при остановке SMTP-сервера: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
log.info("SMTP-сервер остановлен");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<Void> handleSmtpSession(reactor.netty.NettyInbound inbound,
|
|
||||||
reactor.netty.NettyOutbound outbound) {
|
|
||||||
SmtpSession session = new SmtpSession();
|
|
||||||
|
|
||||||
return inbound.receive()
|
|
||||||
.asString(StandardCharsets.UTF_8)
|
|
||||||
.doOnNext(line -> log.debug("SMTP: команда: {}", line.trim()))
|
|
||||||
.concatMap(line -> {
|
|
||||||
line = line.trim();
|
|
||||||
if (line.isEmpty()) {
|
|
||||||
return Mono.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
String[] parts = line.split("\\s+", 2);
|
|
||||||
String command = parts[0].toUpperCase();
|
|
||||||
String argument = parts.length > 1 ? parts[1] : "";
|
|
||||||
|
|
||||||
switch (command) {
|
|
||||||
case "HELO": {
|
|
||||||
session.helo = true;
|
|
||||||
session.ehlo = false;
|
|
||||||
session.hostname = argument;
|
|
||||||
return Mono.just("250 " + smtpHostname + " Hello " + argument + "\r\n");
|
|
||||||
}
|
|
||||||
case "EHLO": {
|
|
||||||
session.helo = true;
|
|
||||||
session.ehlo = true;
|
|
||||||
session.hostname = argument;
|
|
||||||
return Mono.just(buildEhloResponse(argument));
|
|
||||||
}
|
|
||||||
case "MAIL": {
|
|
||||||
if (!session.helo) {
|
|
||||||
return Mono.just("503 Hello required first\r\n");
|
|
||||||
}
|
|
||||||
String mailFrom = extractEmail(argument);
|
|
||||||
if (mailFrom == null) {
|
|
||||||
return Mono.just("501 Syntax: MAIL FROM:<address>\r\n");
|
|
||||||
}
|
|
||||||
session.mailFrom = mailFrom;
|
|
||||||
return Mono.just("250 OK\r\n");
|
|
||||||
}
|
|
||||||
case "RCPT": {
|
|
||||||
if (!session.helo) {
|
|
||||||
return Mono.just("503 Hello required first\r\n");
|
|
||||||
}
|
|
||||||
if (session.mailFrom == null) {
|
|
||||||
return Mono.just("503 MAIL required first\r\n");
|
|
||||||
}
|
|
||||||
String rcptTo = extractEmail(argument);
|
|
||||||
if (rcptTo == null) {
|
|
||||||
return Mono.just("501 Syntax: RCPT TO:<address>\r\n");
|
|
||||||
}
|
|
||||||
session.recipients.add(rcptTo);
|
|
||||||
return Mono.just("250 OK\r\n");
|
|
||||||
}
|
|
||||||
case "DATA": {
|
|
||||||
if (!session.helo) {
|
|
||||||
return Mono.just("503 Hello required first\r\n");
|
|
||||||
}
|
|
||||||
if (session.mailFrom == null) {
|
|
||||||
return Mono.just("503 MAIL required first\r\n");
|
|
||||||
}
|
|
||||||
if (session.recipients.isEmpty()) {
|
|
||||||
return Mono.just("503 RCPT required first\r\n");
|
|
||||||
}
|
|
||||||
session.dataReady = true;
|
|
||||||
return Mono.just("354 Start mail input; end with <CRLF>.<CRLF>\r\n");
|
|
||||||
}
|
|
||||||
case "QUIT": {
|
|
||||||
session.quit = true;
|
|
||||||
return Mono.just("221 " + smtpHostname + " Bye\r\n");
|
|
||||||
}
|
|
||||||
case "RSET": {
|
|
||||||
session.reset();
|
|
||||||
return Mono.just("250 OK\r\n");
|
|
||||||
}
|
|
||||||
case "NOOP":
|
|
||||||
return Mono.just("250 OK\r\n");
|
|
||||||
case "VRFY":
|
|
||||||
return Mono.just("252 Cannot VRFY user but will accept message\r\n");
|
|
||||||
case "EXPN":
|
|
||||||
return Mono.just("252 Cannot EXPN user but will accept message\r\n");
|
|
||||||
default: {
|
|
||||||
// Если в режиме DATA, накапливаем тело
|
|
||||||
if (session.dataReady) {
|
|
||||||
if (line.equals(".")) {
|
|
||||||
session.dataReady = false;
|
|
||||||
String body = session.dataBody.toString();
|
|
||||||
for (String recipient : session.recipients) {
|
|
||||||
try {
|
|
||||||
processIncomingMail(session.mailFrom, recipient, body);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("SMTP: ошибка обработки письма для {}: {}", recipient, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
session.reset();
|
|
||||||
session.dataBody.setLength(0);
|
|
||||||
return Mono.just("250 OK: message accepted\r\n");
|
|
||||||
} else {
|
|
||||||
if (line.startsWith(".")) {
|
|
||||||
session.dataBody.append(line.substring(1));
|
|
||||||
} else {
|
|
||||||
session.dataBody.append(line);
|
|
||||||
}
|
|
||||||
session.dataBody.append("\n");
|
|
||||||
return Mono.empty();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Mono.just("502 Command not implemented\r\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(s -> !s.isEmpty())
|
|
||||||
.transform(outbound::sendString)
|
|
||||||
.then();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildEhloResponse(String hostname) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("250-").append(smtpHostname).append(" Hello ").append(hostname).append("\r\n");
|
|
||||||
for (Map.Entry<String, String> cap : capabilities.entrySet()) {
|
|
||||||
if (cap.getValue() != null && !cap.getValue().isEmpty()) {
|
|
||||||
sb.append("250-").append(cap.getKey()).append(" ").append(cap.getValue()).append("\r\n");
|
|
||||||
} else {
|
|
||||||
sb.append("250-").append(cap.getKey()).append("\r\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.append("250 AUTH LOGIN PLAIN\r\n");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processIncomingMail(String from, String recipient, String body) {
|
|
||||||
try {
|
|
||||||
Properties props = new Properties();
|
|
||||||
Session mailSession = Session.getDefaultInstance(props);
|
|
||||||
ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
|
|
||||||
MimeMessage mimeMessage = new MimeMessage(mailSession, bais);
|
|
||||||
|
|
||||||
String senderEmail = from;
|
|
||||||
String senderName = null;
|
|
||||||
InternetAddress[] fromAddresses = (InternetAddress[]) mimeMessage.getFrom();
|
|
||||||
if (fromAddresses != null && fromAddresses.length > 0) {
|
|
||||||
InternetAddress fromAddr = fromAddresses[0];
|
|
||||||
senderEmail = fromAddr.getAddress();
|
|
||||||
senderName = fromAddr.getPersonal();
|
|
||||||
}
|
|
||||||
|
|
||||||
String subject = mimeMessage.getSubject();
|
|
||||||
if (subject == null) subject = "";
|
|
||||||
|
|
||||||
Object content = mimeMessage.getContent();
|
|
||||||
String messageBody;
|
|
||||||
boolean isHtml = false;
|
|
||||||
|
|
||||||
if (content instanceof String) {
|
|
||||||
messageBody = (String) content;
|
|
||||||
} else if (content instanceof jakarta.mail.internet.MimeMultipart) {
|
|
||||||
messageBody = extractTextFromMultipart((jakarta.mail.internet.MimeMultipart) content);
|
|
||||||
isHtml = messageBody != null && (messageBody.contains("<html") || messageBody.contains("<HTML"));
|
|
||||||
} else {
|
|
||||||
messageBody = content != null ? content.toString() : "";
|
|
||||||
}
|
|
||||||
if (messageBody == null) messageBody = "";
|
|
||||||
|
|
||||||
boolean dkimPassed = dkimService.verifyMessage(mimeMessage);
|
|
||||||
String dkimDomain = null;
|
|
||||||
if (dkimPassed) {
|
|
||||||
String[] dkimHeaders = mimeMessage.getHeader("DKIM-Signature");
|
|
||||||
if (dkimHeaders != null && dkimHeaders.length > 0 && !dkimHeaders[0].isEmpty()) {
|
|
||||||
dkimDomain = extractDkimDomain(dkimHeaders[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SpfService.SpfResult spfResult = spfService.checkSpf(senderEmail, "127.0.0.1");
|
|
||||||
boolean spfPassed = spfResult == SpfService.SpfResult.PASS;
|
|
||||||
log.info("SPF проверка для {}: {} ({})", senderEmail, spfResult, spfResult.getDescription());
|
|
||||||
|
|
||||||
DmarcService.DmarcResult dmarcResult = dmarcService.checkDmarc(
|
|
||||||
senderEmail, "127.0.0.1", spfPassed, dkimPassed, dkimDomain);
|
|
||||||
log.info("DMARC проверка для {}: {} ({})", senderEmail, dmarcResult, dmarcResult.getDescription());
|
|
||||||
|
|
||||||
boolean isSpam = dmarcService.shouldMarkAsSpam(dmarcResult)
|
|
||||||
|| spfResult == SpfService.SpfResult.FAIL
|
|
||||||
|| spfResult == SpfService.SpfResult.SOFTFAIL;
|
|
||||||
|
|
||||||
if (isSpam) {
|
|
||||||
log.info("Письмо от {} помечено как спам (SPF: {}, DKIM: {}, DMARC: {})",
|
|
||||||
senderEmail, spfResult, dkimPassed, dmarcResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
mailService.receiveMessage(senderEmail, senderName, recipient, subject, messageBody, isHtml, isSpam).block();
|
|
||||||
log.info("SMTP: письмо сохранено от {} для {} (тема: {})", senderEmail, recipient, subject);
|
|
||||||
|
|
||||||
} catch (AddressException e) {
|
|
||||||
log.error("SMTP: ошибка адреса: {}", e.getMessage());
|
|
||||||
} catch (MessagingException e) {
|
|
||||||
log.error("SMTP: ошибка парсинга MIME: {}", e.getMessage());
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
// Получатель не найден в системе — пытаемся доставить через SMTP релей
|
|
||||||
log.info("SMTP: получатель {} не найден в системе, пытаемся доставить через релей", recipient);
|
|
||||||
try {
|
|
||||||
// Формируем MIME-контент для релея
|
|
||||||
Properties props = new Properties();
|
|
||||||
Session mailSession = Session.getDefaultInstance(props);
|
|
||||||
ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
|
|
||||||
MimeMessage mimeMessage = new MimeMessage(mailSession, bais);
|
|
||||||
|
|
||||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
||||||
mimeMessage.writeTo(baos);
|
|
||||||
String mimeContent = baos.toString(StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
smtpRelayService.relayMessage(from, recipient, mimeContent).block();
|
|
||||||
} catch (Exception relayEx) {
|
|
||||||
log.error("SMTP: релей не смог доставить письмо для {}: {}", recipient, relayEx.getMessage());
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("SMTP: ошибка обработки письма: {}", e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractTextFromMultipart(jakarta.mail.internet.MimeMultipart multipart)
|
|
||||||
throws MessagingException, IOException {
|
|
||||||
StringBuilder text = new StringBuilder();
|
|
||||||
for (int i = 0; i < multipart.getCount(); i++) {
|
|
||||||
var bodyPart = multipart.getBodyPart(i);
|
|
||||||
String contentType = bodyPart.getContentType();
|
|
||||||
if (contentType != null && contentType.toLowerCase().contains("text")) {
|
|
||||||
Object partContent = bodyPart.getContent();
|
|
||||||
if (partContent instanceof String) {
|
|
||||||
text.append(partContent);
|
|
||||||
} else if (partContent instanceof InputStream) {
|
|
||||||
try (InputStream is = (InputStream) partContent) {
|
|
||||||
text.append(is.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return text.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractDkimDomain(String dkimHeader) {
|
|
||||||
int index = dkimHeader.indexOf("d=");
|
|
||||||
if (index == -1) return null;
|
|
||||||
int start = index + 2;
|
|
||||||
int end = dkimHeader.indexOf(";", start);
|
|
||||||
if (end == -1) end = dkimHeader.length();
|
|
||||||
return dkimHeader.substring(start, end).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractEmail(String argument) {
|
|
||||||
int start = argument.indexOf('<');
|
|
||||||
int end = argument.indexOf('>');
|
|
||||||
if (start == -1 || end == -1) return null;
|
|
||||||
return argument.substring(start + 1, end).trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class SmtpSession {
|
|
||||||
boolean helo = false;
|
|
||||||
boolean ehlo = false;
|
|
||||||
boolean quit = false;
|
|
||||||
String hostname;
|
|
||||||
String mailFrom;
|
|
||||||
List<String> recipients = new ArrayList<>();
|
|
||||||
boolean dataReady = false;
|
|
||||||
StringBuilder dataBody = new StringBuilder();
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
mailFrom = null;
|
|
||||||
recipients.clear();
|
|
||||||
dataReady = false;
|
|
||||||
dataBody.setLength(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import org.springframework.web.server.WebFilter;
|
||||||
|
import org.springframework.web.server.WebFilterChain;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angular routes (/inbox, /message/5, /compose;to=a@b.c …) are pages of the web client: serve index.html for them.
|
||||||
|
* A last segment with a dot (before matrix parameters) is a file.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(-100)
|
||||||
|
public class SpaWebFilter implements WebFilter {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||||
|
String path = exchange.getRequest().getPath().pathWithinApplication().value();
|
||||||
|
boolean page = exchange.getRequest().getMethod().matches("GET")
|
||||||
|
&& !path.startsWith("/api") && !path.startsWith("/actuator")
|
||||||
|
&& !path.equals("/") && !path.substring(path.lastIndexOf('/') + 1).split(";")[0].contains(".");
|
||||||
|
if (page) {
|
||||||
|
return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build());
|
||||||
|
}
|
||||||
|
return chain.filter(exchange);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +1,202 @@
|
|||||||
package com.mailserver.controller;
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailAdmins;
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
import com.mailserver.dto.LoginRequest;
|
import com.mailserver.dto.LoginRequest;
|
||||||
import com.mailserver.dto.LoginResponse;
|
import com.mailserver.dto.LoginResponse;
|
||||||
import com.mailserver.domain.User;
|
import com.mailserver.dto.PasswordChangeRequest;
|
||||||
|
import com.mailserver.dto.ProfileResponse;
|
||||||
|
import com.mailserver.dto.ProfileUpdateRequest;
|
||||||
|
import com.mailserver.dto.RegisterRequest;
|
||||||
|
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.http.HttpHeaders;
|
||||||
|
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.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
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.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Login, registration, the logged-in user's profile and password; what the login page needs to know. */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api")
|
||||||
public class AuthenticationController {
|
public class AuthenticationController {
|
||||||
|
|
||||||
private final UserService userService;
|
private static final Logger log = LoggerFactory.getLogger(AuthenticationController.class);
|
||||||
private final JwtUtils jwtUtils;
|
/** Marker for "no such mailbox or wrong password": both look the same from outside. */
|
||||||
|
private static final User NO_USER = new User();
|
||||||
|
|
||||||
public AuthenticationController(UserService userService, JwtUtils jwtUtils) {
|
/** Service addresses nobody can register (RFC 2142 and ours). */
|
||||||
|
static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster",
|
||||||
|
"root", "admin", "noreply", "no-reply", "security", "support");
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
private final MailService mailService;
|
||||||
|
private final JwtUtils jwtUtils;
|
||||||
|
private final MailDomains domains;
|
||||||
|
private final boolean registrationEnabled;
|
||||||
|
private final LoginGuard guard;
|
||||||
|
private final MailAdmins admins;
|
||||||
|
|
||||||
|
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
||||||
|
@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.jwtUtils = jwtUtils;
|
this.jwtUtils = jwtUtils;
|
||||||
|
this.domains = domains;
|
||||||
|
this.registrationEnabled = registrationEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public: is registration open, and in which domains mailboxes can be created. */
|
||||||
|
@GetMapping("/config")
|
||||||
|
public Map<String, Object> config() {
|
||||||
|
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@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())
|
return requestMono.flatMap(request -> {
|
||||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials")))
|
String email = request.getEmail().trim().toLowerCase(Locale.ROOT);
|
||||||
|
Duration wait = guard.retryAfter(ip, email);
|
||||||
|
if (!wait.isZero()) {
|
||||||
|
log.warn("WEB {}: login as {} refused, blocked for {}", ip, email, LoginGuard.human(wait));
|
||||||
|
return Mono.just(tooMany(wait));
|
||||||
|
}
|
||||||
|
return userService.findByEmail(email)
|
||||||
|
.map(user -> userService.checkPassword(user, request.getPassword()) ? user : NO_USER)
|
||||||
|
.onErrorResume(IllegalArgumentException.class, e -> Mono.just(NO_USER))
|
||||||
|
.defaultIfEmpty(NO_USER)
|
||||||
.flatMap(user -> {
|
.flatMap(user -> {
|
||||||
|
if (user == NO_USER) {
|
||||||
|
guard.failure(ip, email);
|
||||||
|
log.warn("WEB {}: authentication failed for {}", ip, email);
|
||||||
|
Mono<ResponseEntity<?>> answer = Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
|
||||||
|
Duration delay = guard.failureDelay();
|
||||||
|
return delay.isZero() ? answer : answer.delayElement(delay);
|
||||||
|
}
|
||||||
|
guard.success(ip, email);
|
||||||
if (!user.isActive()) {
|
if (!user.isActive()) {
|
||||||
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
|
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
|
||||||
}
|
}
|
||||||
if (!userService.checkPassword(user, request.getPassword())) {
|
return Mono.just(ResponseEntity.ok(token(user)));
|
||||||
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
|
});
|
||||||
}
|
});
|
||||||
String token = jwtUtils.generateToken(user.getEmail());
|
|
||||||
LoginResponse response = new LoginResponse();
|
|
||||||
response.setToken(token);
|
|
||||||
response.setEmail(user.getEmail());
|
|
||||||
return Mono.just(ResponseEntity.ok(response));
|
|
||||||
})
|
|
||||||
.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). */
|
||||||
|
@PostMapping("/register")
|
||||||
|
public Mono<ResponseEntity<?>> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
|
||||||
|
if (!registrationEnabled) {
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "Registration is closed")));
|
||||||
|
}
|
||||||
|
return requestMono.flatMap(request -> {
|
||||||
|
String email = request.getEmail().trim().toLowerCase(Locale.ROOT);
|
||||||
|
String local = email.substring(0, email.indexOf('@'));
|
||||||
|
if (!domains.isLocal(email)) {
|
||||||
|
return Mono.just(ResponseEntity.badRequest()
|
||||||
|
.body(Map.of("error", "Mailboxes can only be created in: " + String.join(", ", domains.domains()))));
|
||||||
|
}
|
||||||
|
if (RESERVED.contains(local) || !local.matches("[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?")) {
|
||||||
|
return Mono.just(ResponseEntity.badRequest()
|
||||||
|
.body(Map.of("error", "This name is not available: letters, digits, dot, dash, underscore")));
|
||||||
|
}
|
||||||
|
return userService.register(email, request.getPassword(), request.getDisplayName())
|
||||||
|
.map(user -> ResponseEntity.status(HttpStatus.CREATED).body((Object) token(user)))
|
||||||
|
.onErrorResume(IllegalArgumentException.class, e ->
|
||||||
|
Mono.just(ResponseEntity.badRequest().body(Map.of("error", "This mailbox already exists"))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public Mono<ProfileResponse> me(Mono<Authentication> auth) {
|
||||||
|
return user(auth).flatMap(this::profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/me")
|
||||||
|
public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) {
|
||||||
|
return user(auth).zipWith(body)
|
||||||
|
.flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName()))
|
||||||
|
.flatMap(this::profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/me/password")
|
||||||
|
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body,
|
||||||
|
ServerHttpRequest http) {
|
||||||
|
String ip = clientIp(http);
|
||||||
|
return user(auth).zipWith(body)
|
||||||
|
.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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return mailService.usedBytes(user.getId())
|
||||||
|
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
|
||||||
|
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt(),
|
||||||
|
admins.isAdmin(user.getEmail())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<User> user(Mono<Authentication> auth) {
|
||||||
|
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||||
|
.flatMap(a -> userService.findByEmail(a.getName()))
|
||||||
|
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginResponse token(User user) {
|
||||||
|
LoginResponse response = new LoginResponse();
|
||||||
|
response.setToken(jwtUtils.generateToken(user.getEmail()));
|
||||||
|
response.setEmail(user.getEmail());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,30 @@ package com.mailserver.controller;
|
|||||||
import com.mailserver.dto.AttachmentResponse;
|
import com.mailserver.dto.AttachmentResponse;
|
||||||
import com.mailserver.dto.FolderResponse;
|
import com.mailserver.dto.FolderResponse;
|
||||||
import com.mailserver.dto.MessageResponse;
|
import com.mailserver.dto.MessageResponse;
|
||||||
import com.mailserver.dto.RegisterRequest;
|
import com.mailserver.dto.DraftRequest;
|
||||||
|
import com.mailserver.dto.FolderRequest;
|
||||||
|
import com.mailserver.dto.MessageUpdateRequest;
|
||||||
import com.mailserver.dto.SendRequest;
|
import com.mailserver.dto.SendRequest;
|
||||||
import com.mailserver.service.MailService;
|
import com.mailserver.service.MailService;
|
||||||
import com.mailserver.service.UserService;
|
import com.mailserver.service.UserService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.core.io.buffer.DataBuffer;
|
|
||||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
|
||||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.codec.multipart.FilePart;
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
import com.mailserver.service.SubmissionService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -46,180 +49,149 @@ public class MailController {
|
|||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final MailService mailService;
|
private final MailService mailService;
|
||||||
|
private final SubmissionService submissionService;
|
||||||
|
|
||||||
public MailController(UserService userService, MailService mailService) {
|
public MailController(UserService userService, MailService mailService, SubmissionService submissionService) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.mailService = mailService;
|
this.mailService = mailService;
|
||||||
|
this.submissionService = submissionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
|
private Mono<com.mailserver.domain.User> user(Mono<Authentication> auth) {
|
||||||
return authenticationMono.map(Authentication::getName);
|
return auth.flatMap(a -> userService.findByEmail(a.getName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Users ---
|
// --- Folders ---
|
||||||
|
|
||||||
@PostMapping("/register")
|
|
||||||
public Mono<?> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
|
|
||||||
return requestMono.flatMap(request ->
|
|
||||||
userService.register(request.getEmail(), request.getPassword(), request.getDisplayName())
|
|
||||||
.map(user -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED)
|
|
||||||
.body(Map.of("message", "User registered")))
|
|
||||||
.onErrorResume(IllegalArgumentException.class, e ->
|
|
||||||
Mono.just(org.springframework.http.ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("error", e.getMessage()))))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/folders")
|
@GetMapping("/folders")
|
||||||
public Flux<FolderResponse> getFolders(Mono<Authentication> authenticationMono) {
|
public Flux<FolderResponse> getFolders(Mono<Authentication> auth) {
|
||||||
return authenticationMono.flatMapMany(auth ->
|
return user(auth).flatMapMany(user -> mailService.folders(user.getId()));
|
||||||
userService.findByEmail(auth.getName())
|
}
|
||||||
.flatMapMany(user -> userService.getFolders(user.getId()))
|
|
||||||
);
|
@PostMapping("/folders")
|
||||||
|
public Mono<ResponseEntity<FolderResponse>> createFolder(Mono<Authentication> auth, @Valid @RequestBody Mono<FolderRequest> body) {
|
||||||
|
return user(auth).zipWith(body)
|
||||||
|
.flatMap(pair -> mailService.createFolder(pair.getT1().getId(), pair.getT2().name()))
|
||||||
|
.map(folder -> ResponseEntity.status(HttpStatus.CREATED).body(folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/folders/{id}")
|
||||||
|
public Mono<Map<String, String>> renameFolder(@PathVariable Long id, Mono<Authentication> auth,
|
||||||
|
@Valid @RequestBody Mono<FolderRequest> body) {
|
||||||
|
return user(auth).zipWith(body)
|
||||||
|
.flatMap(pair -> mailService.renameFolder(id, pair.getT1().getId(), pair.getT2().name()))
|
||||||
|
.thenReturn(Map.of("message", "Folder renamed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own folder only; its messages go to the Trash. */
|
||||||
|
@DeleteMapping("/folders/{id}")
|
||||||
|
public Mono<Map<String, String>> deleteFolder(@PathVariable Long id, Mono<Authentication> auth) {
|
||||||
|
return user(auth).flatMap(user -> mailService.deleteFolder(id, user.getId()))
|
||||||
|
.thenReturn(Map.of("message", "Folder deleted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trash or Spam: removes all its messages for good. */
|
||||||
|
@DeleteMapping("/folders/{id}/messages")
|
||||||
|
public Mono<Map<String, Object>> emptyFolder(@PathVariable Long id, Mono<Authentication> auth) {
|
||||||
|
return user(auth).flatMap(user -> mailService.emptyFolder(id, user.getId()))
|
||||||
|
.map(count -> Map.<String, Object>of("deleted", count));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Messages ---
|
// --- Messages ---
|
||||||
|
|
||||||
|
/** Newest first, bodies cut to a preview. */
|
||||||
@GetMapping("/messages")
|
@GetMapping("/messages")
|
||||||
public Flux<MessageResponse> getMessages(
|
public Flux<MessageResponse> getMessages(Mono<Authentication> auth, @RequestParam Long folderId,
|
||||||
Mono<Authentication> authenticationMono,
|
@RequestParam(defaultValue = "" + MailService.LIST_LIMIT) int limit) {
|
||||||
@RequestParam Long folderId,
|
return user(auth).flatMapMany(user -> mailService.listMessages(folderId, user.getId(), limit));
|
||||||
@RequestParam(defaultValue = "0") int page,
|
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
|
||||||
return authenticationMono.flatMapMany(auth ->
|
|
||||||
userService.findByEmail(auth.getName())
|
|
||||||
.flatMapMany(user -> mailService.getMessages(folderId, user.getId(), page, size))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/messages/search")
|
@GetMapping("/messages/search")
|
||||||
public Flux<MessageResponse> searchMessages(
|
public Flux<MessageResponse> searchMessages(
|
||||||
Mono<Authentication> authenticationMono,
|
Mono<Authentication> auth,
|
||||||
@RequestParam Long folderId,
|
@RequestParam Long folderId,
|
||||||
@RequestParam(required = false) String keyword,
|
@RequestParam(required = false) String keyword,
|
||||||
@RequestParam(required = false) String subject,
|
@RequestParam(required = false) String subject,
|
||||||
@RequestParam(required = false) String body,
|
@RequestParam(required = false) String body,
|
||||||
@RequestParam(required = false) String sender,
|
@RequestParam(required = false) String sender,
|
||||||
@RequestParam(required = false) String recipient,
|
@RequestParam(required = false) String recipient) {
|
||||||
@RequestParam(defaultValue = "0") int page,
|
return user(auth).flatMapMany(user -> keyword != null && !keyword.isBlank()
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
? mailService.searchMessages(folderId, user.getId(), keyword.trim(), 0, MailService.LIST_LIMIT)
|
||||||
return authenticationMono.flatMapMany(auth ->
|
: mailService.searchMessagesWithFilters(folderId, user.getId(), subject, body, sender, recipient,
|
||||||
userService.findByEmail(auth.getName())
|
0, MailService.LIST_LIMIT));
|
||||||
.flatMapMany(user -> {
|
|
||||||
if (keyword != null && !keyword.trim().isEmpty()) {
|
|
||||||
return mailService.searchMessages(folderId, user.getId(), keyword.trim(), page, size);
|
|
||||||
}
|
|
||||||
return mailService.searchMessagesWithFilters(folderId, user.getId(),
|
|
||||||
subject, body, sender, recipient, page, size);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/messages/{id}")
|
@GetMapping("/messages/{id}")
|
||||||
public Mono<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
|
public Mono<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> auth) {
|
||||||
return authenticationMono.flatMap(auth ->
|
return user(auth).flatMap(user -> mailService.getMessage(id, user.getId()));
|
||||||
userService.findByEmail(auth.getName())
|
|
||||||
.flatMap(user -> mailService.getMessage(id, user.getId()))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/send")
|
/** read / flagged / folderId — only the fields that are set. */
|
||||||
public Mono<Object> sendMessage(
|
@PatchMapping("/messages/{id}")
|
||||||
Mono<Authentication> authenticationMono,
|
public Mono<MessageResponse> updateMessage(@PathVariable Long id, Mono<Authentication> auth,
|
||||||
@Valid @RequestBody Mono<SendRequest> requestMono) {
|
@RequestBody Mono<MessageUpdateRequest> body) {
|
||||||
return authenticationMono.zipWith(requestMono)
|
return user(auth).zipWith(body)
|
||||||
.flatMap(pair -> {
|
.flatMap(pair -> mailService.updateMessage(id, pair.getT1().getId(), pair.getT2()));
|
||||||
String email = pair.getT1().getName();
|
|
||||||
SendRequest request = pair.getT2();
|
|
||||||
return mailService.sendMessage(email, request)
|
|
||||||
.map(response -> (Object) org.springframework.http.ResponseEntity.ok(response))
|
|
||||||
.onErrorResume(e -> {
|
|
||||||
log.error("Ошибка при отправке письма: {}", e.getMessage(), e);
|
|
||||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("error", e.getMessage())));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/messages/{id}/read")
|
@PutMapping("/messages/{id}/read")
|
||||||
public Mono<?> markAsRead(@PathVariable Long id, Mono<Authentication> authenticationMono) {
|
public Mono<Map<String, String>> markAsRead(@PathVariable Long id, Mono<Authentication> auth) {
|
||||||
return authenticationMono.flatMap(auth ->
|
return user(auth).flatMap(user -> mailService.markAsRead(id, user.getId()))
|
||||||
userService.findByEmail(auth.getName())
|
.thenReturn(Map.of("message", "Marked as read"));
|
||||||
.flatMap(user -> mailService.markAsRead(id, user.getId())
|
|
||||||
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Marked as read")))))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Into the Trash; from the Trash — for good. */
|
||||||
@DeleteMapping("/messages/{id}")
|
@DeleteMapping("/messages/{id}")
|
||||||
public Mono<?> deleteMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
|
public Mono<Map<String, String>> deleteMessage(@PathVariable Long id, Mono<Authentication> auth) {
|
||||||
return authenticationMono.flatMap(auth ->
|
return user(auth).flatMap(user -> mailService.deleteMessage(id, user.getId()))
|
||||||
userService.findByEmail(auth.getName())
|
.thenReturn(Map.of("message", "Message deleted"));
|
||||||
.flatMap(user -> mailService.deleteMessage(id, user.getId())
|
|
||||||
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message deleted")))))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Simulate incoming message (для тестов) ---
|
@PostMapping("/send")
|
||||||
|
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
|
||||||
|
return auth.zipWith(requestMono)
|
||||||
|
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/simulate/receive")
|
// --- Drafts ---
|
||||||
public Mono<?> simulateReceive(@RequestBody Mono<Map<String, String>> bodyMono) {
|
|
||||||
return bodyMono.flatMap(body ->
|
@PostMapping("/drafts")
|
||||||
mailService.receiveMessage(
|
public Mono<MessageResponse> createDraft(Mono<Authentication> auth, @Valid @RequestBody Mono<DraftRequest> body) {
|
||||||
body.get("senderEmail"),
|
return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), null, pair.getT2()));
|
||||||
body.get("senderName"),
|
}
|
||||||
body.get("recipientEmail"),
|
|
||||||
body.get("subject"),
|
@PutMapping("/drafts/{id}")
|
||||||
body.get("body"),
|
public Mono<MessageResponse> updateDraft(@PathVariable Long id, Mono<Authentication> auth,
|
||||||
"true".equals(body.get("html"))
|
@Valid @RequestBody Mono<DraftRequest> body) {
|
||||||
)
|
return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), id, pair.getT2()));
|
||||||
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message received"))))
|
|
||||||
.onErrorResume(e ->
|
|
||||||
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Attachments ---
|
// --- Attachments ---
|
||||||
|
|
||||||
|
/** A file for a draft (form field "file"); stored on disk, the draft gets the record. */
|
||||||
@PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public Mono<?> uploadAttachment(
|
public Mono<ResponseEntity<AttachmentResponse>> uploadAttachment(
|
||||||
@PathVariable Long messageId,
|
@PathVariable Long messageId,
|
||||||
Mono<Authentication> authenticationMono,
|
Mono<Authentication> auth,
|
||||||
@RequestBody Mono<Flux<FilePart>> filePartsMono) {
|
@RequestPart("file") Mono<FilePart> fileMono) {
|
||||||
return authenticationMono.zipWith(filePartsMono)
|
return user(auth).zipWith(fileMono).flatMap(pair -> {
|
||||||
.flatMap(pair -> {
|
var user = pair.getT1();
|
||||||
String email = pair.getT1().getName();
|
FilePart file = pair.getT2();
|
||||||
return userService.findByEmail(email)
|
String originalFilename = safeFilename(file.filename());
|
||||||
.flatMapMany(user -> pair.getT2())
|
Path filePath = Paths.get(mailService.getAttachmentsDir(), UUID.randomUUID() + "_" + originalFilename);
|
||||||
.next()
|
String contentType = file.headers().getContentType() != null
|
||||||
.flatMap(file -> {
|
? file.headers().getContentType().toString() : "application/octet-stream";
|
||||||
String originalFilename = file.filename();
|
// the draft must be the user's before anything is written
|
||||||
String uniqueName = UUID.randomUUID() + "_" + originalFilename;
|
return mailService.ownedDraft(messageId, user.getId())
|
||||||
Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName);
|
.then(Mono.fromCallable(() -> Files.createDirectories(filePath.getParent()))
|
||||||
try {
|
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()))
|
||||||
Files.createDirectories(filePath.getParent());
|
.then(file.transferTo(filePath))
|
||||||
} catch (IOException e) {
|
.then(Mono.fromCallable(() -> Files.size(filePath))
|
||||||
return Mono.error(new RuntimeException("Failed to create directory", e));
|
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()))
|
||||||
}
|
.flatMap(size -> mailService.saveAttachmentReactive(messageId, user.getId(), originalFilename,
|
||||||
|
contentType, size, filePath.toString()))
|
||||||
return DataBufferUtils.join(file.content())
|
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||||
.flatMap(dataBuffer -> {
|
});
|
||||||
byte[] bytes = new byte[dataBuffer.readableByteCount()];
|
|
||||||
dataBuffer.read(bytes);
|
|
||||||
DataBufferUtils.release(dataBuffer);
|
|
||||||
try {
|
|
||||||
Files.write(filePath, bytes);
|
|
||||||
} catch (IOException e) {
|
|
||||||
return Mono.error(new RuntimeException("Failed to save file", e));
|
|
||||||
}
|
|
||||||
return userService.findByEmail(email)
|
|
||||||
.flatMap(user -> mailService.saveAttachmentReactive(
|
|
||||||
messageId, user.getId(),
|
|
||||||
originalFilename, file.headers().getContentType().toString(),
|
|
||||||
bytes.length, filePath.toString()))
|
|
||||||
.map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/messages/{messageId}/attachments")
|
@GetMapping("/messages/{messageId}/attachments")
|
||||||
@@ -233,39 +205,40 @@ public class MailController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/attachments/{attachmentId}/download")
|
@GetMapping("/attachments/{attachmentId}/download")
|
||||||
public Mono<Object> downloadAttachment(
|
public Mono<ResponseEntity<byte[]>> downloadAttachment(
|
||||||
@PathVariable Long attachmentId,
|
@PathVariable Long attachmentId,
|
||||||
Mono<Authentication> authenticationMono) {
|
Mono<Authentication> authenticationMono) {
|
||||||
return authenticationMono.flatMap(auth ->
|
return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName()))
|
||||||
userService.findByEmail(auth.getName())
|
.flatMap(user -> mailService.getAttachment(attachmentId, user.getId()))
|
||||||
.flatMap(user -> {
|
.flatMap(attachment -> Mono.fromCallable(() -> Files.readAllBytes(Paths.get(attachment.getFilePath())))
|
||||||
try {
|
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())
|
||||||
AttachmentResponse info = mailService.getAttachmentInfo(attachmentId, user.getId());
|
.map(bytes -> ResponseEntity.ok()
|
||||||
Path filePath = Paths.get(mailService.getAttachmentFilePath(attachmentId, user.getId()));
|
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||||
byte[] bytes;
|
.filename(attachment.getFilename(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
||||||
try {
|
.contentType(contentType(attachment.getContentType()))
|
||||||
bytes = Files.readAllBytes(filePath);
|
.body(bytes)));
|
||||||
} catch (IOException e) {
|
}
|
||||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("error", "Failed to read file: " + e.getMessage())));
|
private static MediaType contentType(String value) {
|
||||||
}
|
try {
|
||||||
DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes);
|
return value == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(value);
|
||||||
return Mono.just((Object) org.springframework.http.ResponseEntity.ok()
|
} catch (org.springframework.util.InvalidMimeTypeException e) {
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
return MediaType.APPLICATION_OCTET_STREAM;
|
||||||
"attachment; filename=\"" + info.getFilename() + "\"")
|
}
|
||||||
.contentType(MediaType.parseMediaType(
|
}
|
||||||
info.getContentType() != null ? info.getContentType() : "application/octet-stream"))
|
|
||||||
.body(buffer)
|
/** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */
|
||||||
);
|
public static String safeFilename(String name) {
|
||||||
} catch (IllegalArgumentException e) {
|
String base = name == null ? "" : name.replace('\\', '/');
|
||||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())));
|
base = base.substring(base.lastIndexOf('/') + 1).replaceAll("[\\p{Cntrl}\"]", "").trim();
|
||||||
}
|
if (base.isEmpty() || base.equals(".") || base.equals("..")) {
|
||||||
})
|
return "file";
|
||||||
);
|
}
|
||||||
|
return base.length() > 200 ? base.substring(base.length() - 200) : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/attachments/{attachmentId}")
|
@DeleteMapping("/attachments/{attachmentId}")
|
||||||
public Mono<?> deleteAttachment(
|
public Mono<ResponseEntity<?>> deleteAttachment(
|
||||||
@PathVariable Long attachmentId,
|
@PathVariable Long attachmentId,
|
||||||
Mono<Authentication> authenticationMono) {
|
Mono<Authentication> authenticationMono) {
|
||||||
return authenticationMono.flatMap(auth ->
|
return authenticationMono.flatMap(auth ->
|
||||||
@@ -333,7 +306,7 @@ public class MailController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/contacts/{id}")
|
@DeleteMapping("/contacts/{id}")
|
||||||
public Mono<?> deleteContact(
|
public Mono<ResponseEntity<?>> deleteContact(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
Mono<Authentication> authenticationMono) {
|
Mono<Authentication> authenticationMono) {
|
||||||
return authenticationMono.flatMap(auth ->
|
return authenticationMono.flatMap(auth ->
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailAdmins;
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.dto.LoginResponse;
|
||||||
|
import com.mailserver.service.MailService;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import com.mailserver.util.JwtUtils;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@,
|
||||||
|
* support@ — registration stays closed), new password, switch off. A generated password is returned once, in the
|
||||||
|
* answer — it is not logged.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/mailboxes")
|
||||||
|
public class MailboxAdminController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
|
||||||
|
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?");
|
||||||
|
private static final int MIN_PASSWORD = 10;
|
||||||
|
|
||||||
|
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin,
|
||||||
|
long usedBytes, long quotaBytes, Instant createdAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param password empty — the server generates one and returns it once */
|
||||||
|
public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName,
|
||||||
|
@Size(max = 200) String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordRequest(@Size(max = 200) String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ActiveRequest(boolean active) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param password the generated password, or null when the administrator chose it */
|
||||||
|
public record Created(MailboxView mailbox, String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final UserService users;
|
||||||
|
private final MailService mail;
|
||||||
|
private final MailDomains domains;
|
||||||
|
private final MailAdmins admins;
|
||||||
|
private final JwtUtils jwt;
|
||||||
|
|
||||||
|
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt) {
|
||||||
|
this.jwt = jwt;
|
||||||
|
this.users = users;
|
||||||
|
this.mail = mail;
|
||||||
|
this.domains = domains;
|
||||||
|
this.admins = admins;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<MailboxView> list(Mono<Authentication> auth) {
|
||||||
|
return admin(auth).thenMany(users.all())
|
||||||
|
.concatMap(this::view)
|
||||||
|
.sort(Comparator.comparing(MailboxView::email));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
|
||||||
|
return admin(auth).then(body).flatMap(request -> {
|
||||||
|
String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT);
|
||||||
|
int at = email.lastIndexOf('@');
|
||||||
|
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
|
||||||
|
return Mono.error(bad("Not a mailbox address: " + email));
|
||||||
|
}
|
||||||
|
if (!domains.isLocal(email)) {
|
||||||
|
return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList())));
|
||||||
|
}
|
||||||
|
String chosen = request.password() == null ? "" : request.password();
|
||||||
|
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
|
||||||
|
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
|
||||||
|
}
|
||||||
|
String password = chosen.isEmpty() ? randomPassword() : chosen;
|
||||||
|
return users.register(email, password, request.displayName())
|
||||||
|
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists"))
|
||||||
|
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email))
|
||||||
|
.flatMap(this::view)
|
||||||
|
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New password: the given one, or a generated one returned once. */
|
||||||
|
@PostMapping("/{id}/password")
|
||||||
|
public Mono<Created> password(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<PasswordRequest> body) {
|
||||||
|
return admin(auth).then(body.defaultIfEmpty(new PasswordRequest(null))).flatMap(request -> {
|
||||||
|
String chosen = request.password() == null ? "" : request.password();
|
||||||
|
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
|
||||||
|
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
|
||||||
|
}
|
||||||
|
String password = chosen.isEmpty() ? randomPassword() : chosen;
|
||||||
|
return mailbox(id)
|
||||||
|
.flatMap(user -> users.setPassword(user, password))
|
||||||
|
.doOnNext(user -> log.info("Password of {} set by administrator", user.getEmail()))
|
||||||
|
.flatMap(this::view)
|
||||||
|
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator opens a mailbox without its password (Settings → MAILBOXES → Open): a session of that mailbox.
|
||||||
|
* Every opening is logged — who opened which mailbox.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/open")
|
||||||
|
public Mono<LoginResponse> open(Mono<Authentication> auth, @PathVariable Long id) {
|
||||||
|
return admin(auth).flatMap(me -> mailbox(id).map(user -> {
|
||||||
|
log.warn("Administrator {} opened mailbox {}", me, user.getEmail());
|
||||||
|
LoginResponse response = new LoginResponse();
|
||||||
|
response.setToken(jwt.generateToken(user.getEmail()));
|
||||||
|
response.setEmail(user.getEmail());
|
||||||
|
return response;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */
|
||||||
|
@PutMapping("/{id}/active")
|
||||||
|
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
|
||||||
|
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
|
||||||
|
if (!request.active() && user.getEmail().equalsIgnoreCase(me)) {
|
||||||
|
return Mono.error(bad("You can not switch off your own mailbox"));
|
||||||
|
}
|
||||||
|
log.info("Mailbox {} {} by administrator", user.getEmail(), request.active() ? "switched on" : "switched off");
|
||||||
|
return users.setActive(user, request.active());
|
||||||
|
}))).flatMap(this::view);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> admin(Mono<Authentication> auth) {
|
||||||
|
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||||
|
.map(Authentication::getName)
|
||||||
|
.filter(admins::isAdmin)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "Only for mail administrators")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<User> mailbox(Long id) {
|
||||||
|
return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<MailboxView> view(User user) {
|
||||||
|
return mail.usedBytes(user.getId()).defaultIfEmpty(0L)
|
||||||
|
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
|
||||||
|
admins.isAdmin(user.getEmail()), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
|
||||||
|
user.getCreatedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseStatusException bad(String message) {
|
||||||
|
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String randomPassword() {
|
||||||
|
byte[] bytes = new byte[18];
|
||||||
|
new SecureRandom().nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.mailserver.delivery;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends queued messages. Temporary failure (4xx, no connection, DNS) — retry later with growing pauses,
|
||||||
|
* about 22 hours in total; permanent failure (5xx) or retries used up — FAILED and a bounce to the sender's inbox.
|
||||||
|
* Runs on the scheduler thread: blocking here is fine, it is not the network event loop.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DeliveryWorker {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DeliveryWorker.class);
|
||||||
|
|
||||||
|
static final List<Duration> RETRY_PAUSES = List.of(
|
||||||
|
Duration.ofMinutes(1), Duration.ofMinutes(5), Duration.ofMinutes(15), Duration.ofHours(1),
|
||||||
|
Duration.ofHours(3), Duration.ofHours(6), Duration.ofHours(12));
|
||||||
|
private static final int BATCH = 20;
|
||||||
|
|
||||||
|
/** Tells the sender that a message did not reach someone. */
|
||||||
|
public interface BounceSender {
|
||||||
|
void bounce(OutboundMessage message, String reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final OutboundRepository repository;
|
||||||
|
private final SmtpDeliveryClient client;
|
||||||
|
private final BounceSender bounces;
|
||||||
|
|
||||||
|
public DeliveryWorker(OutboundRepository repository, SmtpDeliveryClient client, BounceSender bounces) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.client = client;
|
||||||
|
this.bounces = bounces;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(initialDelayString = "${mailserver.queue.initial-delay-ms:5000}", fixedDelayString = "${mailserver.queue.poll-ms:15000}")
|
||||||
|
public void deliverDue() {
|
||||||
|
List<OutboundMessage> due = repository.findDue(Instant.now(), BATCH).collectList().block();
|
||||||
|
if (due == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (OutboundMessage message : due) {
|
||||||
|
try {
|
||||||
|
attempt(message, Instant.now());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.error("Queue item {} not processed: {}", message.getId(), e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void attempt(OutboundMessage message, Instant now) {
|
||||||
|
SmtpDeliveryClient.Result result = client.deliver(message.getMailFrom(), message.getRcptTo(), message.getMessage());
|
||||||
|
message.setAttempts(message.getAttempts() + 1);
|
||||||
|
message.setLastError(result.outcome() == SmtpDeliveryClient.Outcome.DELIVERED ? null : truncate(result.detail()));
|
||||||
|
switch (result.outcome()) {
|
||||||
|
case DELIVERED -> finish(message, OutboundMessage.DELIVERED, now);
|
||||||
|
case PERMANENT -> fail(message, now, result.detail());
|
||||||
|
case TEMPORARY -> {
|
||||||
|
if (message.getAttempts() > RETRY_PAUSES.size()) {
|
||||||
|
fail(message, now, "gave up after " + message.getAttempts() + " attempts: " + result.detail());
|
||||||
|
} else {
|
||||||
|
message.setNextAttemptAt(now.plus(RETRY_PAUSES.get(message.getAttempts() - 1)));
|
||||||
|
repository.save(message).block();
|
||||||
|
log.info("Queue item {} to {}: retry at {} ({})", message.getId(), message.getRcptTo(),
|
||||||
|
message.getNextAttemptAt(), result.detail());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fail(OutboundMessage message, Instant now, String reason) {
|
||||||
|
finish(message, OutboundMessage.FAILED, now);
|
||||||
|
log.warn("Queue item {} to {} FAILED: {}", message.getId(), message.getRcptTo(), reason);
|
||||||
|
try {
|
||||||
|
bounces.bounce(message, reason);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.error("Bounce for queue item {} not stored: {}", message.getId(), e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finish(OutboundMessage message, String status, Instant now) {
|
||||||
|
message.setStatus(status);
|
||||||
|
message.setFinishedAt(now);
|
||||||
|
repository.save(message).block();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String text) {
|
||||||
|
return text == null || text.length() <= 1000 ? text : text.substring(0, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.mailserver.delivery;
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Table("outbound_queue")
|
||||||
|
public class OutboundMessage {
|
||||||
|
|
||||||
|
public static final String PENDING = "PENDING";
|
||||||
|
public static final String DELIVERED = "DELIVERED";
|
||||||
|
public static final String FAILED = "FAILED";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
@Column("mail_from")
|
||||||
|
private String mailFrom;
|
||||||
|
@Column("rcpt_to")
|
||||||
|
private String rcptTo;
|
||||||
|
@Column("message")
|
||||||
|
private byte[] message;
|
||||||
|
@Column("status")
|
||||||
|
private String status = PENDING;
|
||||||
|
@Column("attempts")
|
||||||
|
private int attempts;
|
||||||
|
@Column("next_attempt_at")
|
||||||
|
private Instant nextAttemptAt;
|
||||||
|
@Column("last_error")
|
||||||
|
private String lastError;
|
||||||
|
@Column("created_at")
|
||||||
|
private Instant createdAt;
|
||||||
|
@Column("finished_at")
|
||||||
|
private Instant finishedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public String getMailFrom() { return mailFrom; }
|
||||||
|
public void setMailFrom(String mailFrom) { this.mailFrom = mailFrom; }
|
||||||
|
public String getRcptTo() { return rcptTo; }
|
||||||
|
public void setRcptTo(String rcptTo) { this.rcptTo = rcptTo; }
|
||||||
|
public byte[] getMessage() { return message; }
|
||||||
|
public void setMessage(byte[] message) { this.message = message; }
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
public int getAttempts() { return attempts; }
|
||||||
|
public void setAttempts(int attempts) { this.attempts = attempts; }
|
||||||
|
public Instant getNextAttemptAt() { return nextAttemptAt; }
|
||||||
|
public void setNextAttemptAt(Instant nextAttemptAt) { this.nextAttemptAt = nextAttemptAt; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
|
public Instant getFinishedAt() { return finishedAt; }
|
||||||
|
public void setFinishedAt(Instant finishedAt) { this.finishedAt = finishedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.mailserver.delivery;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Puts a message for one external recipient into the delivery queue (the worker sends it). */
|
||||||
|
@Service
|
||||||
|
public class OutboundQueue {
|
||||||
|
|
||||||
|
private final OutboundRepository repository;
|
||||||
|
|
||||||
|
public OutboundQueue(OutboundRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<OutboundMessage> enqueue(String mailFrom, String rcptTo, byte[] signedMessage) {
|
||||||
|
OutboundMessage message = new OutboundMessage();
|
||||||
|
message.setMailFrom(mailFrom);
|
||||||
|
message.setRcptTo(rcptTo);
|
||||||
|
message.setMessage(signedMessage);
|
||||||
|
message.setStatus(OutboundMessage.PENDING);
|
||||||
|
message.setCreatedAt(Instant.now());
|
||||||
|
message.setNextAttemptAt(Instant.now());
|
||||||
|
return repository.save(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.mailserver.delivery;
|
||||||
|
|
||||||
|
import org.springframework.data.r2dbc.repository.Query;
|
||||||
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public interface OutboundRepository extends R2dbcRepository<OutboundMessage, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM outbound_queue WHERE status = 'PENDING' AND next_attempt_at <= :now ORDER BY next_attempt_at LIMIT :limit")
|
||||||
|
Flux<OutboundMessage> findDue(Instant now, int limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package com.mailserver.delivery;
|
||||||
|
|
||||||
|
import jakarta.mail.Address;
|
||||||
|
import jakarta.mail.AuthenticationFailedException;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.SendFailedException;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.Transport;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.eclipse.angus.mail.smtp.SMTPAddressFailedException;
|
||||||
|
import org.eclipse.angus.mail.smtp.SMTPSendFailedException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.xbill.DNS.Lookup;
|
||||||
|
import org.xbill.DNS.MXRecord;
|
||||||
|
import org.xbill.DNS.Record;
|
||||||
|
import org.xbill.DNS.TextParseException;
|
||||||
|
import org.xbill.DNS.Type;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivers one message to one recipient over SMTP (Jakarta Mail), blocking.
|
||||||
|
* <ul>
|
||||||
|
* <li><b>mx</b> — straight to the recipient's MX servers on port 25, lowest preference first; STARTTLS when offered,
|
||||||
|
* without certificate checks — opportunistic, like Postfix {@code smtp_tls_security_level=may};</li>
|
||||||
|
* <li><b>smarthost</b> — everything through one provider (Resend, SES, …) with login and verified TLS;</li>
|
||||||
|
* <li><b>off</b> — nothing leaves the server.</li>
|
||||||
|
* </ul>
|
||||||
|
* The message bytes are sent exactly as given (they carry the DKIM signature).
|
||||||
|
*/
|
||||||
|
public class SmtpDeliveryClient {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SmtpDeliveryClient.class);
|
||||||
|
|
||||||
|
public enum Mode { OFF, MX, SMARTHOST }
|
||||||
|
|
||||||
|
public enum Tls { NONE, STARTTLS, SSL }
|
||||||
|
|
||||||
|
/** port — of the smarthost; mxPort — of recipients' MX servers (25; another value only in tests). */
|
||||||
|
public record Settings(Mode mode, String host, int port, String username, String password, Tls tls,
|
||||||
|
String heloName, int timeoutMs, int mxPort) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Outcome { DELIVERED, TEMPORARY, PERMANENT }
|
||||||
|
|
||||||
|
public record Result(Outcome outcome, String detail) {
|
||||||
|
static Result delivered(String detail) { return new Result(Outcome.DELIVERED, detail); }
|
||||||
|
static Result temporary(String detail) { return new Result(Outcome.TEMPORARY, detail); }
|
||||||
|
static Result permanent(String detail) { return new Result(Outcome.PERMANENT, detail); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MX hosts of a domain, best first; empty — the domain has no mail server. */
|
||||||
|
public interface MxResolver {
|
||||||
|
List<String> resolve(String domain) throws TemporaryDnsException;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TemporaryDnsException extends Exception {
|
||||||
|
public TemporaryDnsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
private final MxResolver resolver;
|
||||||
|
|
||||||
|
public SmtpDeliveryClient(Settings settings) {
|
||||||
|
this(settings, SmtpDeliveryClient::dnsMx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SmtpDeliveryClient(Settings settings, MxResolver resolver) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.resolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mode mode() {
|
||||||
|
return settings.mode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result deliver(String from, String recipient, byte[] message) {
|
||||||
|
return switch (settings.mode()) {
|
||||||
|
case OFF -> Result.permanent("outbound delivery is disabled on this server");
|
||||||
|
case SMARTHOST -> send(settings.host(), settings.port(), true, from, recipient, message);
|
||||||
|
case MX -> viaMx(from, recipient, message);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result viaMx(String from, String recipient, byte[] message) {
|
||||||
|
String domain = recipient.substring(recipient.lastIndexOf('@') + 1).toLowerCase(Locale.ROOT);
|
||||||
|
List<String> hosts;
|
||||||
|
try {
|
||||||
|
hosts = resolver.resolve(domain);
|
||||||
|
} catch (TemporaryDnsException e) {
|
||||||
|
return Result.temporary("DNS: " + e.getMessage());
|
||||||
|
}
|
||||||
|
if (hosts.isEmpty()) {
|
||||||
|
return Result.permanent("domain " + domain + " does not accept mail (no MX/A record)");
|
||||||
|
}
|
||||||
|
Result last = null;
|
||||||
|
for (String host : hosts) {
|
||||||
|
last = send(host, settings.mxPort(), false, from, recipient, message);
|
||||||
|
// A server answered with a decision — do not ask the next MX; only a connection problem moves on
|
||||||
|
if (last.outcome() != Outcome.TEMPORARY || last.detail().startsWith("4")) {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result send(String host, int port, boolean smarthost, String from, String recipient, byte[] message) {
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.smtp.host", host);
|
||||||
|
props.put("mail.smtp.port", String.valueOf(port));
|
||||||
|
props.put("mail.smtp.localhost", settings.heloName());
|
||||||
|
props.put("mail.smtp.connectiontimeout", String.valueOf(settings.timeoutMs()));
|
||||||
|
props.put("mail.smtp.timeout", String.valueOf(settings.timeoutMs()));
|
||||||
|
props.put("mail.smtp.writetimeout", String.valueOf(settings.timeoutMs()));
|
||||||
|
props.put("mail.smtp.from", from);
|
||||||
|
props.put("mail.smtp.allow8bitmime", "true");
|
||||||
|
boolean login = false;
|
||||||
|
if (smarthost) {
|
||||||
|
switch (settings.tls()) {
|
||||||
|
case STARTTLS -> {
|
||||||
|
props.put("mail.smtp.starttls.enable", "true");
|
||||||
|
props.put("mail.smtp.starttls.required", "true");
|
||||||
|
}
|
||||||
|
case SSL -> props.put("mail.smtp.ssl.enable", "true");
|
||||||
|
case NONE -> { }
|
||||||
|
}
|
||||||
|
props.put("mail.smtp.ssl.checkserveridentity", "true");
|
||||||
|
login = settings.username() != null && !settings.username().isBlank();
|
||||||
|
props.put("mail.smtp.auth", String.valueOf(login));
|
||||||
|
} else {
|
||||||
|
props.put("mail.smtp.starttls.enable", "true");
|
||||||
|
props.put("mail.smtp.ssl.trust", "*");
|
||||||
|
}
|
||||||
|
Session session = Session.getInstance(props);
|
||||||
|
try (Transport transport = session.getTransport(settings.tls() == Tls.SSL && smarthost ? "smtps" : "smtp")) {
|
||||||
|
if (login) {
|
||||||
|
transport.connect(host, port, settings.username(), settings.password());
|
||||||
|
} else {
|
||||||
|
transport.connect(host, port, null, null);
|
||||||
|
}
|
||||||
|
transport.sendMessage(new RawMessage(session, message), new Address[]{new InternetAddress(recipient)});
|
||||||
|
log.info("Delivered {} -> {} via {}:{}", from, recipient, host, port);
|
||||||
|
return Result.delivered(host);
|
||||||
|
} catch (AuthenticationFailedException e) {
|
||||||
|
return Result.temporary("login to " + host + " failed: " + e.getMessage());
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
int code = replyCode(e);
|
||||||
|
String text = code > 0 ? code + " " + oneLine(e) : "connection to " + host + ": " + oneLine(e);
|
||||||
|
log.warn("Delivery {} -> {} via {}: {}", from, recipient, host, text);
|
||||||
|
return code >= 500 ? Result.permanent(text) : Result.temporary(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMTP reply code from the exception chain, 0 — no reply (connection problem). */
|
||||||
|
static int replyCode(Exception e) {
|
||||||
|
Exception current = e;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof SMTPAddressFailedException f) {
|
||||||
|
return f.getReturnCode();
|
||||||
|
}
|
||||||
|
if (current instanceof SMTPSendFailedException f) {
|
||||||
|
return f.getReturnCode();
|
||||||
|
}
|
||||||
|
current = current instanceof MessagingException m ? m.getNextException()
|
||||||
|
: current.getCause() instanceof Exception c ? c : null;
|
||||||
|
if (current instanceof SendFailedException sf && sf.getInvalidAddresses() != null
|
||||||
|
&& sf.getNextException() == null) {
|
||||||
|
return 550;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String oneLine(Exception e) {
|
||||||
|
String message = String.valueOf(e.getMessage()).replaceAll("\\s+", " ").trim();
|
||||||
|
return message.length() > 500 ? message.substring(0, 500) : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MX hosts by preference (RFC 5321 5.1); no MX — the domain itself (implicit MX); null MX "." — none. */
|
||||||
|
static List<String> dnsMx(String domain) throws TemporaryDnsException {
|
||||||
|
try {
|
||||||
|
Lookup lookup = new Lookup(domain, Type.MX);
|
||||||
|
Record[] records = lookup.run();
|
||||||
|
switch (lookup.getResult()) {
|
||||||
|
case Lookup.SUCCESSFUL -> {
|
||||||
|
List<String> hosts = Arrays.stream(records)
|
||||||
|
.map(MXRecord.class::cast)
|
||||||
|
.sorted(Comparator.comparingInt(MXRecord::getPriority))
|
||||||
|
.map(mx -> mx.getTarget().toString(true))
|
||||||
|
.filter(host -> !host.isEmpty() && !host.equals("."))
|
||||||
|
.toList();
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
case Lookup.TYPE_NOT_FOUND -> {
|
||||||
|
Lookup a = new Lookup(domain, Type.A);
|
||||||
|
a.run();
|
||||||
|
return a.getResult() == Lookup.SUCCESSFUL ? List.of(domain) : List.of();
|
||||||
|
}
|
||||||
|
case Lookup.HOST_NOT_FOUND -> {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
default -> throw new TemporaryDnsException(lookup.getErrorString());
|
||||||
|
}
|
||||||
|
} catch (TextParseException e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message written to the wire byte for byte as received (keeps the DKIM signature valid). */
|
||||||
|
static final class RawMessage extends MimeMessage {
|
||||||
|
private final byte[] raw;
|
||||||
|
|
||||||
|
RawMessage(Session session, byte[] raw) throws MessagingException {
|
||||||
|
super(session, new ByteArrayInputStream(raw));
|
||||||
|
this.raw = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream os, String[] ignoreHeaders) throws IOException {
|
||||||
|
os.write(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream os) throws IOException {
|
||||||
|
os.write(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
return raw.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.mailserver.dev;
|
||||||
|
|
||||||
|
import com.mailserver.service.MailService;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Dev profile only: put a message into a mailbox without SMTP (any sender — never on a real server). */
|
||||||
|
@RestController
|
||||||
|
@Profile("dev")
|
||||||
|
public class DevController {
|
||||||
|
|
||||||
|
private final MailService mailService;
|
||||||
|
|
||||||
|
public DevController(MailService mailService) {
|
||||||
|
this.mailService = mailService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/simulate/receive")
|
||||||
|
public Mono<ResponseEntity<Map<String, String>>> simulateReceive(@RequestBody Map<String, String> body) {
|
||||||
|
return mailService.receiveMessage(body.get("senderEmail"), body.get("senderName"), body.get("recipientEmail"),
|
||||||
|
body.getOrDefault("subject", ""), body.getOrDefault("body", ""), "true".equals(body.get("html")))
|
||||||
|
.thenReturn(ResponseEntity.ok(Map.of("message", "Message received")))
|
||||||
|
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", String.valueOf(e.getMessage())))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ public class Folder {
|
|||||||
@Column("unread_count")
|
@Column("unread_count")
|
||||||
private Integer unreadCount = 0;
|
private Integer unreadCount = 0;
|
||||||
|
|
||||||
|
@Column("is_system")
|
||||||
|
private boolean system;
|
||||||
|
|
||||||
@Column("created_at")
|
@Column("created_at")
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -42,6 +45,9 @@ public class Folder {
|
|||||||
public Integer getUnreadCount() { return unreadCount; }
|
public Integer getUnreadCount() { return unreadCount; }
|
||||||
public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; }
|
public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; }
|
||||||
|
|
||||||
|
public boolean isSystem() { return system; }
|
||||||
|
public void setSystem(boolean system) { this.system = system; }
|
||||||
|
|
||||||
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ public class Message {
|
|||||||
@Column("recipient_email")
|
@Column("recipient_email")
|
||||||
private String recipientEmail;
|
private String recipientEmail;
|
||||||
|
|
||||||
|
@Column("cc_email")
|
||||||
|
private String ccEmail;
|
||||||
|
|
||||||
@Column("subject")
|
@Column("subject")
|
||||||
private String subject;
|
private String subject;
|
||||||
|
|
||||||
@@ -66,6 +69,9 @@ public class Message {
|
|||||||
public String getRecipientEmail() { return recipientEmail; }
|
public String getRecipientEmail() { return recipientEmail; }
|
||||||
public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; }
|
public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; }
|
||||||
|
|
||||||
|
public String getCcEmail() { return ccEmail; }
|
||||||
|
public void setCcEmail(String ccEmail) { this.ccEmail = ccEmail; }
|
||||||
|
|
||||||
public String getSubject() { return subject; }
|
public String getSubject() { return subject; }
|
||||||
public void setSubject(String subject) { this.subject = subject; }
|
public void setSubject(String subject) { this.subject = subject; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.mailserver.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/** A draft: everything optional, addresses are not checked until it is sent. */
|
||||||
|
public record DraftRequest(@Size(max = 5000) String to, @Size(max = 5000) String cc,
|
||||||
|
@Size(max = 1000) String subject, @Size(max = 1_000_000) String body, boolean html) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.mailserver.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record FolderRequest(@NotBlank @Size(max = 50) String name) {
|
||||||
|
}
|
||||||
@@ -1,23 +1,5 @@
|
|||||||
package com.mailserver.dto;
|
package com.mailserver.dto;
|
||||||
|
|
||||||
import java.time.Instant;
|
/** A folder with live counters; system = INBOX, Drafts, Sent, Spam, Trash (cannot be renamed or deleted). */
|
||||||
|
public record FolderResponse(Long id, String name, boolean system, long messageCount, long unreadCount) {
|
||||||
public class FolderResponse {
|
|
||||||
|
|
||||||
private Long id;
|
|
||||||
private String name;
|
|
||||||
private Integer messageCount;
|
|
||||||
private Integer unreadCount;
|
|
||||||
|
|
||||||
public Long getId() { return id; }
|
|
||||||
public void setId(Long id) { this.id = id; }
|
|
||||||
|
|
||||||
public String getName() { return name; }
|
|
||||||
public void setName(String name) { this.name = name; }
|
|
||||||
|
|
||||||
public Integer getMessageCount() { return messageCount; }
|
|
||||||
public void setMessageCount(Integer messageCount) { this.messageCount = messageCount; }
|
|
||||||
|
|
||||||
public Integer getUnreadCount() { return unreadCount; }
|
|
||||||
public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class MessageResponse {
|
|||||||
private String senderEmail;
|
private String senderEmail;
|
||||||
private String senderName;
|
private String senderName;
|
||||||
private String recipientEmail;
|
private String recipientEmail;
|
||||||
|
private String ccEmail;
|
||||||
private String subject;
|
private String subject;
|
||||||
private String body;
|
private String body;
|
||||||
private boolean html;
|
private boolean html;
|
||||||
@@ -32,6 +33,9 @@ public class MessageResponse {
|
|||||||
public String getRecipientEmail() { return recipientEmail; }
|
public String getRecipientEmail() { return recipientEmail; }
|
||||||
public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; }
|
public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; }
|
||||||
|
|
||||||
|
public String getCcEmail() { return ccEmail; }
|
||||||
|
public void setCcEmail(String ccEmail) { this.ccEmail = ccEmail; }
|
||||||
|
|
||||||
public String getSubject() { return subject; }
|
public String getSubject() { return subject; }
|
||||||
public void setSubject(String subject) { this.subject = subject; }
|
public void setSubject(String subject) { this.subject = subject; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.mailserver.dto;
|
||||||
|
|
||||||
|
/** Partial update of a message: only the fields that are set change. */
|
||||||
|
public record MessageUpdateRequest(Boolean read, Boolean flagged, Long folderId) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.mailserver.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record PasswordChangeRequest(@NotBlank String currentPassword, @NotBlank @Size(min = 8, max = 100) String newPassword) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.mailserver.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** @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) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.mailserver.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record ProfileUpdateRequest(@NotBlank @Size(max = 100) String displayName) {
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ public class RegisterRequest {
|
|||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
@Size(min = 6, max = 100)
|
@Size(min = 8, max = 100)
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
@Size(max = 100)
|
@Size(max = 100)
|
||||||
|
|||||||
@@ -1,31 +1,46 @@
|
|||||||
package com.mailserver.dto;
|
package com.mailserver.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Send" from the web client. {@code to} and {@code cc}: one or more addresses separated by commas, semicolons or
|
||||||
|
* spaces. {@code draftId}: the draft being sent — its attachments go with the message, the draft is removed.
|
||||||
|
*/
|
||||||
public class SendRequest {
|
public class SendRequest {
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
@Email
|
@Size(max = 5000)
|
||||||
private String to;
|
private String to;
|
||||||
|
|
||||||
@NotBlank
|
@Size(max = 5000)
|
||||||
|
private String cc;
|
||||||
|
|
||||||
|
@Size(max = 1000)
|
||||||
private String subject;
|
private String subject;
|
||||||
|
|
||||||
@NotBlank
|
@Size(max = 1_000_000)
|
||||||
private String body;
|
private String body;
|
||||||
|
|
||||||
private boolean html = false;
|
private boolean html = false;
|
||||||
|
|
||||||
|
private Long draftId;
|
||||||
|
|
||||||
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; }
|
||||||
|
|
||||||
public String getSubject() { return subject; }
|
public String getCc() { return cc; }
|
||||||
|
public void setCc(String cc) { this.cc = cc; }
|
||||||
|
|
||||||
|
public String getSubject() { return subject == null ? "" : subject; }
|
||||||
public void setSubject(String subject) { this.subject = subject; }
|
public void setSubject(String subject) { this.subject = subject; }
|
||||||
|
|
||||||
public String getBody() { return body; }
|
public String getBody() { return body == null ? "" : body; }
|
||||||
public void setBody(String body) { this.body = body; }
|
public void setBody(String body) { this.body = body; }
|
||||||
|
|
||||||
public boolean isHtml() { return html; }
|
public boolean isHtml() { return html; }
|
||||||
public void setHtml(boolean html) { this.html = html; }
|
public void setHtml(boolean html) { this.html = html; }
|
||||||
|
|
||||||
|
public Long getDraftId() { return draftId; }
|
||||||
|
public void setDraftId(Long draftId) { this.draftId = draftId; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import com.mailserver.domain.Attachment;
|
||||||
|
import com.mailserver.domain.Message;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.repository.AttachmentRepository;
|
||||||
|
import com.mailserver.repository.MessageRepository;
|
||||||
|
import com.mailserver.repository.UserRepository;
|
||||||
|
import com.mailserver.service.LocalDelivery;
|
||||||
|
import com.mailserver.service.MailService;
|
||||||
|
import com.mailserver.service.MessageSources;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.r2dbc.core.DatabaseClient;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** {@link ImapStore} over the mail server's PostgreSQL tables (the same data the web client shows). */
|
||||||
|
public class DbImapStore implements ImapStore {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DbImapStore.class);
|
||||||
|
|
||||||
|
private final DatabaseClient db;
|
||||||
|
private final UserRepository users;
|
||||||
|
private final UserService userService;
|
||||||
|
private final MailService mailService;
|
||||||
|
private final MessageRepository messages;
|
||||||
|
private final AttachmentRepository attachments;
|
||||||
|
private final MessageSources sources;
|
||||||
|
private final String hostname;
|
||||||
|
|
||||||
|
public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
|
||||||
|
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
|
||||||
|
String hostname) {
|
||||||
|
this.db = db;
|
||||||
|
this.users = users;
|
||||||
|
this.userService = userService;
|
||||||
|
this.mailService = mailService;
|
||||||
|
this.messages = messages;
|
||||||
|
this.attachments = attachments;
|
||||||
|
this.sources = sources;
|
||||||
|
this.hostname = hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Account authenticate(String username, String password) {
|
||||||
|
User user = users.findByEmail(username.trim().toLowerCase(Locale.ROOT)).block();
|
||||||
|
if (user == null || !user.isActive() || !userService.checkPassword(user, password)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Account(user.getId(), user.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Mailbox> mailboxes(long userId) {
|
||||||
|
return db.sql("SELECT id, name, is_system, uid_validity, uid_next FROM folders WHERE user_id = :u ORDER BY id")
|
||||||
|
.bind("u", userId)
|
||||||
|
.map(row -> new Mailbox(row.get("id", Long.class), row.get("name", String.class),
|
||||||
|
Boolean.TRUE.equals(row.get("is_system", Boolean.class)),
|
||||||
|
row.get("uid_validity", Long.class), row.get("uid_next", Long.class)))
|
||||||
|
.all().collectList().block();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mailbox create(long userId, String name) {
|
||||||
|
try {
|
||||||
|
var created = mailService.createFolder(userId, name).block();
|
||||||
|
return mailboxes(userId).stream().filter(m -> m.id() == created.id()).findFirst().orElseThrow();
|
||||||
|
} catch (ResponseStatusException e) {
|
||||||
|
throw new Refused(e.getReason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void rename(long userId, Mailbox mailbox, String newName) {
|
||||||
|
try {
|
||||||
|
mailService.renameFolder(mailbox.id(), userId, newName).block();
|
||||||
|
} catch (ResponseStatusException e) {
|
||||||
|
throw new Refused(e.getReason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(long userId, Mailbox mailbox) {
|
||||||
|
try {
|
||||||
|
mailService.deleteFolder(mailbox.id(), userId).block();
|
||||||
|
} catch (ResponseStatusException e) {
|
||||||
|
throw new Refused(e.getReason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Entry> entries(long folderId) {
|
||||||
|
return db.sql("SELECT id, uid, is_read, is_flagged, is_answered, is_deleted, received_at FROM messages "
|
||||||
|
+ "WHERE folder_id = :f ORDER BY uid")
|
||||||
|
.bind("f", folderId)
|
||||||
|
.map(row -> new Entry(row.get("id", Long.class), row.get("uid", Long.class),
|
||||||
|
Boolean.TRUE.equals(row.get("is_read", Boolean.class)),
|
||||||
|
Boolean.TRUE.equals(row.get("is_flagged", Boolean.class)),
|
||||||
|
Boolean.TRUE.equals(row.get("is_answered", Boolean.class)),
|
||||||
|
Boolean.TRUE.equals(row.get("is_deleted", Boolean.class)),
|
||||||
|
instant(row.get("received_at"))))
|
||||||
|
.all().collectList().block();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] source(long messageId) {
|
||||||
|
byte[] raw = sources.get(messageId).block();
|
||||||
|
if (raw != null) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
Message message = messages.findById(messageId).block();
|
||||||
|
if (message == null) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Attachment> files = attachments.findByMessageId(messageId).collectList().block();
|
||||||
|
raw = MimeSource.build(message, files, hostname);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("IMAP: cannot build message {}: {}", messageId, e.toString());
|
||||||
|
return ("Subject: " + message.getSubject() + "\r\n\r\n" + message.getBody()).getBytes();
|
||||||
|
}
|
||||||
|
sources.put(messageId, raw).block();
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) {
|
||||||
|
db.sql("UPDATE messages SET is_read = :s, is_flagged = :f, is_answered = :a, is_deleted = :d WHERE id = :id")
|
||||||
|
.bind("s", seen).bind("f", flagged).bind("a", answered).bind("d", deleted).bind("id", messageId)
|
||||||
|
.then().block();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, boolean answered,
|
||||||
|
boolean deleted, Instant internalDate) {
|
||||||
|
try {
|
||||||
|
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
|
||||||
|
Message message = new Message();
|
||||||
|
message.setFolderId(target.id());
|
||||||
|
String senderEmail = "unknown@" + hostname;
|
||||||
|
String senderName = null;
|
||||||
|
if (mime.getFrom() != null && mime.getFrom().length > 0 && mime.getFrom()[0] instanceof InternetAddress from) {
|
||||||
|
senderEmail = from.getAddress();
|
||||||
|
senderName = from.getPersonal();
|
||||||
|
}
|
||||||
|
message.setSenderEmail(senderEmail);
|
||||||
|
message.setSenderName(senderName);
|
||||||
|
String to = LocalDelivery.addresses(mime, jakarta.mail.Message.RecipientType.TO);
|
||||||
|
message.setRecipientEmail(to == null ? "" : to);
|
||||||
|
message.setCcEmail(LocalDelivery.addresses(mime, jakarta.mail.Message.RecipientType.CC));
|
||||||
|
message.setSubject(mime.getSubject() == null ? "" : mime.getSubject());
|
||||||
|
LocalDelivery.Body body = LocalDelivery.body(mime);
|
||||||
|
message.setBody(body.text());
|
||||||
|
message.setHtml(body.html());
|
||||||
|
message.setRead(seen);
|
||||||
|
message.setFlagged(flagged);
|
||||||
|
message.setSpam(target.system() && "Spam".equals(target.name()));
|
||||||
|
message.setReceivedAt(internalDate == null ? Instant.now() : internalDate);
|
||||||
|
String messageId = mime.getMessageID();
|
||||||
|
message.setMessageId(messageId == null ? UUID.randomUUID().toString() : messageId.replaceAll("^<|>$", ""));
|
||||||
|
message.setMessageSize((long) raw.length);
|
||||||
|
Message stored = messages.save(message).block();
|
||||||
|
for (LocalDelivery.FilePart file : LocalDelivery.attachments(mime)) {
|
||||||
|
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
|
||||||
|
}
|
||||||
|
sources.put(stored.getId(), raw).block();
|
||||||
|
setFlags(stored.getId(), seen, flagged, answered, deleted);
|
||||||
|
return uid(stored.getId());
|
||||||
|
} catch (Refused e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("IMAP APPEND to {} failed: {}", target.name(), e.toString());
|
||||||
|
throw new Refused("Cannot store the message: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> copy(long userId, List<Entry> list, Mailbox target) {
|
||||||
|
boolean spam = target.system() && "Spam".equals(target.name());
|
||||||
|
List<Long> uids = new ArrayList<>();
|
||||||
|
for (Entry entry : list) {
|
||||||
|
Long newId = db.sql("INSERT INTO messages (folder_id, sender_email, sender_name, recipient_email, cc_email, "
|
||||||
|
+ "subject, body, is_html, is_read, is_flagged, is_spam, is_answered, is_deleted, message_size, "
|
||||||
|
+ "received_at, message_id) "
|
||||||
|
+ "SELECT :t, sender_email, sender_name, recipient_email, cc_email, subject, body, is_html, "
|
||||||
|
+ "is_read, is_flagged, :spam, is_answered, is_deleted, message_size, received_at, message_id "
|
||||||
|
+ "FROM messages WHERE id = :id RETURNING id")
|
||||||
|
.bind("t", target.id()).bind("spam", spam).bind("id", entry.id())
|
||||||
|
.map(row -> row.get("id", Long.class)).one().block();
|
||||||
|
if (newId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (Attachment attachment : attachments.findByMessageId(entry.id()).collectList().block()) {
|
||||||
|
copyAttachment(attachment, newId);
|
||||||
|
}
|
||||||
|
db.sql("INSERT INTO message_sources (message_id, raw) SELECT :n, raw FROM message_sources WHERE message_id = :o")
|
||||||
|
.bind("n", newId).bind("o", entry.id()).then().block();
|
||||||
|
uids.add(uid(newId));
|
||||||
|
}
|
||||||
|
return uids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyAttachment(Attachment attachment, long newMessageId) {
|
||||||
|
try {
|
||||||
|
Path from = Paths.get(attachment.getFilePath());
|
||||||
|
Path to = from.resolveSibling(UUID.randomUUID() + "_" + from.getFileName().toString().replaceFirst("^[0-9a-f-]{36}_", ""));
|
||||||
|
Files.copy(from, to);
|
||||||
|
Attachment copy = new Attachment();
|
||||||
|
copy.setMessageId(newMessageId);
|
||||||
|
copy.setFilename(attachment.getFilename());
|
||||||
|
copy.setContentType(attachment.getContentType());
|
||||||
|
copy.setSizeBytes(attachment.getSizeBytes());
|
||||||
|
copy.setFilePath(to.toString());
|
||||||
|
copy.setContentId(attachment.getContentId());
|
||||||
|
attachments.save(copy).block();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("IMAP COPY: attachment {} not copied: {}", attachment.getFilePath(), e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> move(long userId, List<Entry> list, Mailbox target) {
|
||||||
|
boolean spam = target.system() && "Spam".equals(target.name());
|
||||||
|
List<Long> uids = new ArrayList<>();
|
||||||
|
for (Entry entry : list) {
|
||||||
|
Long uid = db.sql("UPDATE messages SET folder_id = :t, is_spam = :spam WHERE id = :id RETURNING uid")
|
||||||
|
.bind("t", target.id()).bind("spam", spam).bind("id", entry.id())
|
||||||
|
.map(row -> row.get("uid", Long.class)).one().block();
|
||||||
|
if (uid != null) {
|
||||||
|
uids.add(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uids;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void expunge(List<Long> messageIds) {
|
||||||
|
for (Long id : messageIds) {
|
||||||
|
for (Attachment attachment : attachments.findByMessageId(id).collectList().block()) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.deleteById(id).block();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long uid(long messageId) {
|
||||||
|
return db.sql("SELECT uid FROM messages WHERE id = :id").bind("id", messageId)
|
||||||
|
.map(row -> row.get("uid", Long.class)).one().block();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant instant(Object value) {
|
||||||
|
if (value instanceof Instant instant) {
|
||||||
|
return instant;
|
||||||
|
}
|
||||||
|
if (value instanceof LocalDateTime local) {
|
||||||
|
return local.toInstant(ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
if (value instanceof java.time.OffsetDateTime offset) {
|
||||||
|
return offset.toInstant();
|
||||||
|
}
|
||||||
|
return Instant.EPOCH;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one IMAP command: the decoder has already collected it whole, with its literals inline
|
||||||
|
* ("{n}\r\n" + n bytes, or "{n+}\r\n" + n bytes).
|
||||||
|
*/
|
||||||
|
final class ImapReader {
|
||||||
|
|
||||||
|
static final class SyntaxError extends RuntimeException {
|
||||||
|
SyntaxError(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final byte[] b;
|
||||||
|
private int pos;
|
||||||
|
|
||||||
|
ImapReader(byte[] command) {
|
||||||
|
this(command, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImapReader(byte[] command, boolean stripLineEnd) {
|
||||||
|
int end = command.length;
|
||||||
|
if (!stripLineEnd) {
|
||||||
|
this.b = command;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (end > 0 && command[end - 1] == '\n') {
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
if (end > 0 && command[end - 1] == '\r') {
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
this.b = java.util.Arrays.copyOf(command, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean atEnd() {
|
||||||
|
return pos >= b.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
int peek() {
|
||||||
|
return pos < b.length ? b[pos] & 0xff : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sp() {
|
||||||
|
if (peek() != ' ') {
|
||||||
|
throw new SyntaxError("Expected a space");
|
||||||
|
}
|
||||||
|
while (peek() == ' ') {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean optionalSp() {
|
||||||
|
if (peek() == ' ') {
|
||||||
|
sp();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void expect(char c) {
|
||||||
|
if (peek() != c) {
|
||||||
|
throw new SyntaxError("Expected '" + c + "'");
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Characters up to a space, parenthesis, or the end (an atom; also tags, sequence sets, flags). */
|
||||||
|
String atom() {
|
||||||
|
int start = pos;
|
||||||
|
while (pos < b.length) {
|
||||||
|
int c = b[pos] & 0xff;
|
||||||
|
if (c == ' ' || c == '(' || c == ')' || c == '{' || c == '"' || c < 0x20 || c == 0x7f) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
if (pos == start) {
|
||||||
|
throw new SyntaxError("Expected an atom");
|
||||||
|
}
|
||||||
|
return new String(b, start, pos - start, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atom, quoted string or literal. */
|
||||||
|
String astring() {
|
||||||
|
int c = peek();
|
||||||
|
if (c == '"' || c == '{') {
|
||||||
|
return new String(stringBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
return atom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quoted string or literal, raw bytes. */
|
||||||
|
byte[] stringBytes() {
|
||||||
|
int c = peek();
|
||||||
|
if (c == '"') {
|
||||||
|
pos++;
|
||||||
|
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||||
|
while (true) {
|
||||||
|
if (pos >= b.length) {
|
||||||
|
throw new SyntaxError("Unterminated quoted string");
|
||||||
|
}
|
||||||
|
byte x = b[pos++];
|
||||||
|
if (x == '"') {
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
if (x == '\\' && pos < b.length) {
|
||||||
|
x = b[pos++];
|
||||||
|
}
|
||||||
|
out.write(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c == '{') {
|
||||||
|
int close = indexOf('}', pos);
|
||||||
|
if (close < 0) {
|
||||||
|
throw new SyntaxError("Bad literal");
|
||||||
|
}
|
||||||
|
String size = new String(b, pos + 1, close - pos - 1, StandardCharsets.US_ASCII);
|
||||||
|
if (size.endsWith("+")) {
|
||||||
|
size = size.substring(0, size.length() - 1);
|
||||||
|
}
|
||||||
|
int length;
|
||||||
|
try {
|
||||||
|
length = Integer.parseInt(size);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new SyntaxError("Bad literal size");
|
||||||
|
}
|
||||||
|
pos = close + 1;
|
||||||
|
if (peek() == '\r') {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
expect('\n');
|
||||||
|
if (pos + length > b.length) {
|
||||||
|
throw new SyntaxError("Literal shorter than announced");
|
||||||
|
}
|
||||||
|
byte[] data = java.util.Arrays.copyOfRange(b, pos, pos + length);
|
||||||
|
pos += length;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
throw new SyntaxError("Expected a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "(a b c)" → [a, b, c] (atoms or strings); an atom without parentheses → [atom]. */
|
||||||
|
List<String> list() {
|
||||||
|
List<String> items = new ArrayList<>();
|
||||||
|
if (peek() != '(') {
|
||||||
|
items.add(astring());
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
while (peek() == ' ') {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
while (peek() != ')') {
|
||||||
|
if (atEnd()) {
|
||||||
|
throw new SyntaxError("Unterminated list");
|
||||||
|
}
|
||||||
|
items.add(astring());
|
||||||
|
while (peek() == ' ') {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything left, as text (used for FETCH attributes and SEARCH keys parsed by their own readers). */
|
||||||
|
String rest() {
|
||||||
|
String text = new String(b, pos, b.length - pos, StandardCharsets.UTF_8);
|
||||||
|
pos = b.length;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reader over the rest, as is (SEARCH may hold literals). */
|
||||||
|
ImapReader restReader() {
|
||||||
|
ImapReader reader = new ImapReader(java.util.Arrays.copyOfRange(b, pos, b.length), false);
|
||||||
|
pos = b.length;
|
||||||
|
return reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int indexOf(char c, int from) {
|
||||||
|
for (int i = from; i < b.length; i++) {
|
||||||
|
if (b[i] == c) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import com.mailserver.security.LoginGuard;
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.channel.ChannelHandlerContext;
|
||||||
|
import io.netty.channel.ChannelPipeline;
|
||||||
|
import io.netty.handler.codec.ByteToMessageDecoder;
|
||||||
|
import io.netty.handler.codec.TooLongFrameException;
|
||||||
|
import io.netty.handler.ssl.SslContext;
|
||||||
|
import io.netty.handler.ssl.SslHandler;
|
||||||
|
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.publisher.Sinks;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import reactor.netty.Connection;
|
||||||
|
import reactor.netty.DisposableServer;
|
||||||
|
import reactor.netty.NettyOutbound;
|
||||||
|
import reactor.netty.tcp.TcpServer;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IMAP server (RFC 3501) on Reactor Netty.
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link CommandDecoder} collects whole commands with their literals ({n} — after "+ Ready", {n+} — at once);</li>
|
||||||
|
* <li>each command runs on a worker thread (database, MIME parsing), never on the event loop; responses are written
|
||||||
|
* in order, one command at a time;</li>
|
||||||
|
* <li>TLS: STARTTLS, or implicit TLS on a separate port (993); LOGIN/AUTHENTICATE only inside TLS unless
|
||||||
|
* {@code authWithoutTls}; plain text sent together with STARTTLS is never executed;</li>
|
||||||
|
* <li>IDLE: changes of the selected mailbox are pushed every {@code idlePoll} until DONE.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class ImapServer {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ImapServer.class);
|
||||||
|
|
||||||
|
static final int MAX_LINE = 64 * 1024;
|
||||||
|
private static final int MAX_QUEUED = 1000;
|
||||||
|
private static final Pattern LITERAL = Pattern.compile("\\{(\\d+)(\\+?)}\r?\n$");
|
||||||
|
private static final Pattern IDLE = Pattern.compile("^(\\S+) IDLE\\s*$", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Duration IDLE_MAX = Duration.ofMinutes(29);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param tls certificate; null — no TLS
|
||||||
|
* @param implicitTls TLS from the first byte (port 993)
|
||||||
|
* @param maxLiteral the largest literal accepted (APPEND of a big message)
|
||||||
|
* @param idlePoll how often IDLE looks for changes
|
||||||
|
*/
|
||||||
|
public record Settings(String host, int port, String hostname, boolean authWithoutTls, SslContext tls,
|
||||||
|
boolean implicitTls, int maxLiteral, Duration idleTimeout, Duration idlePoll) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A whole command and whether it arrived over TLS. */
|
||||||
|
record Command(byte[] bytes, boolean tls) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
private final ImapStore store;
|
||||||
|
private final LoginGuard guard;
|
||||||
|
private DisposableServer server;
|
||||||
|
|
||||||
|
public ImapServer(Settings settings, ImapStore store) {
|
||||||
|
this(settings, store, LoginGuard.disabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImapServer(Settings settings, ImapStore store, LoginGuard guard) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.store = store;
|
||||||
|
this.guard = guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (settings.implicitTls() && settings.tls() == null) {
|
||||||
|
throw new IllegalStateException("Implicit TLS needs a certificate");
|
||||||
|
}
|
||||||
|
TcpServer tcp = TcpServer.create()
|
||||||
|
.host(settings.host())
|
||||||
|
.port(settings.port())
|
||||||
|
.doOnConnection(connection -> connection
|
||||||
|
.addHandlerFirst("imap-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
|
||||||
|
.addHandlerLast("imap-commands", new CommandDecoder(settings.maxLiteral())));
|
||||||
|
if (settings.implicitTls()) {
|
||||||
|
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
|
||||||
|
}
|
||||||
|
server = tcp.handle((inbound, outbound) -> {
|
||||||
|
Connection[] connection = {null};
|
||||||
|
String[] remoteIp = {"unknown"};
|
||||||
|
inbound.withConnection(c -> {
|
||||||
|
connection[0] = c;
|
||||||
|
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
|
||||||
|
remoteIp[0] = address.getAddress().getHostAddress();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ImapSession session = new ImapSession(
|
||||||
|
new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()),
|
||||||
|
store, guard, remoteIp[0], settings.implicitTls());
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Sinks.Empty<Void>[] idleDone = new Sinks.Empty[1];
|
||||||
|
|
||||||
|
Flux<byte[]> responses = inbound.receiveObject().cast(Command.class)
|
||||||
|
// DONE ends IDLE while the IDLE command is still running — handled before the queue
|
||||||
|
.filter(command -> {
|
||||||
|
if (session.idling() && isDone(command.bytes())) {
|
||||||
|
if (idleDone[0] != null) {
|
||||||
|
idleDone[0].tryEmitEmpty();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
// read commands as they come (so DONE is seen during IDLE), run them one by one
|
||||||
|
.onBackpressureBuffer(MAX_QUEUED, dropped -> log.warn("IMAP {}: too many queued commands", session.remoteIp))
|
||||||
|
.concatMap(command -> {
|
||||||
|
if (session.tls && !command.tls()) {
|
||||||
|
// plain text sent together with STARTTLS: never executed, the connection is closed
|
||||||
|
log.warn("IMAP {}: plain-text command after STARTTLS, closing", session.remoteIp);
|
||||||
|
session.closing = true;
|
||||||
|
connection[0].dispose();
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
String text = new String(command.bytes(), StandardCharsets.UTF_8).trim();
|
||||||
|
Matcher idle = IDLE.matcher(text);
|
||||||
|
if (idle.matches()) {
|
||||||
|
idleDone[0] = Sinks.empty();
|
||||||
|
return idle(session, idle.group(1), idleDone[0]);
|
||||||
|
}
|
||||||
|
return blocking(() -> session.handle(command.bytes()));
|
||||||
|
})
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString());
|
||||||
|
return Mono.just(ImapSession.line("* BYE " + e.getMessage()));
|
||||||
|
})
|
||||||
|
// 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)
|
||||||
|
.concatMap(response -> response == ImapSession.STARTTLS_READY
|
||||||
|
? startTls(connection[0], session)
|
||||||
|
: send(outbound, response))
|
||||||
|
.then();
|
||||||
|
})
|
||||||
|
.bindNow(Duration.ofSeconds(10));
|
||||||
|
log.info("IMAP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
|
||||||
|
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Flux<byte[]> idle(ImapSession session, String tag, Sinks.Empty<Void> done) {
|
||||||
|
Flux<byte[]> polls = Flux.interval(settings.idlePoll())
|
||||||
|
.onBackpressureDrop()
|
||||||
|
.concatMap(tick -> blocking(session::idlePoll))
|
||||||
|
.takeUntilOther(done.asMono())
|
||||||
|
.take(IDLE_MAX);
|
||||||
|
return Flux.concat(
|
||||||
|
blocking(() -> session.idleStart(tag)),
|
||||||
|
Flux.defer(() -> session.idling() ? polls : Flux.empty()),
|
||||||
|
blocking(session::idleEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDone(byte[] bytes) {
|
||||||
|
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) {
|
||||||
|
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mono<Void> send(NettyOutbound outbound, byte[] response) {
|
||||||
|
return outbound.send(Mono.just(Unpooled.wrappedBuffer(response))).then();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After the tagged OK of STARTTLS has left in plain text: TLS in front, a fresh decoder (no plain leftovers). */
|
||||||
|
private Mono<Void> startTls(Connection connection, ImapSession session) {
|
||||||
|
ChannelPipeline pipeline = connection.channel().pipeline();
|
||||||
|
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
|
||||||
|
pipeline.replace("imap-commands", "imap-commands", new CommandDecoder(settings.maxLiteral()));
|
||||||
|
pipeline.addFirst("imap-tls", ssl);
|
||||||
|
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
|
||||||
|
if (!future.isSuccess()) {
|
||||||
|
log.info("IMAP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
|
||||||
|
session.closing = true;
|
||||||
|
connection.dispose();
|
||||||
|
}
|
||||||
|
sink.success();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int port() {
|
||||||
|
return server.port();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (server != null) {
|
||||||
|
server.disposeNow(Duration.ofSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects one command: lines plus the literals they announce. A synchronizing literal {n} gets "+ Ready" first;
|
||||||
|
* {n+} (LITERAL+) is read at once. Marks whether the command was read behind TLS.
|
||||||
|
*/
|
||||||
|
static final class CommandDecoder extends ByteToMessageDecoder {
|
||||||
|
private final int maxLiteral;
|
||||||
|
private final ByteArrayOutputStream command = new ByteArrayOutputStream();
|
||||||
|
private int literalLeft;
|
||||||
|
private int lineBytes;
|
||||||
|
|
||||||
|
CommandDecoder(int maxLiteral) {
|
||||||
|
this.maxLiteral = maxLiteral;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
|
||||||
|
while (in.isReadable()) {
|
||||||
|
if (literalLeft > 0) {
|
||||||
|
int n = Math.min(literalLeft, in.readableBytes());
|
||||||
|
byte[] chunk = new byte[n];
|
||||||
|
in.readBytes(chunk);
|
||||||
|
command.write(chunk, 0, n);
|
||||||
|
literalLeft -= n;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int newline = in.indexOf(in.readerIndex(), in.writerIndex(), (byte) '\n');
|
||||||
|
if (newline < 0) {
|
||||||
|
if (lineBytes + in.readableBytes() > MAX_LINE) {
|
||||||
|
throw new TooLongFrameException("Command line too long");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int length = newline - in.readerIndex() + 1;
|
||||||
|
if (lineBytes + length > MAX_LINE) {
|
||||||
|
throw new TooLongFrameException("Command line too long");
|
||||||
|
}
|
||||||
|
byte[] line = new byte[length];
|
||||||
|
in.readBytes(line);
|
||||||
|
command.write(line, 0, line.length);
|
||||||
|
lineBytes += length;
|
||||||
|
Matcher literal = LITERAL.matcher(new String(line, StandardCharsets.ISO_8859_1));
|
||||||
|
if (literal.find()) {
|
||||||
|
long size = Long.parseLong(literal.group(1));
|
||||||
|
if (size > maxLiteral) {
|
||||||
|
ctx.writeAndFlush(Unpooled.copiedBuffer("* BYE Literal too large\r\n", StandardCharsets.US_ASCII));
|
||||||
|
throw new TooLongFrameException("Literal too large: " + size);
|
||||||
|
}
|
||||||
|
literalLeft = (int) size;
|
||||||
|
if (literal.group(2).isEmpty()) {
|
||||||
|
ctx.writeAndFlush(Unpooled.copiedBuffer("+ Ready for literal data\r\n", StandardCharsets.US_ASCII));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.add(new Command(command.toByteArray(), ctx.pipeline().get(SslHandler.class) != null));
|
||||||
|
command.reset();
|
||||||
|
lineBytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailboxes and messages as IMAP sees them. Blocking: the IMAP server calls it from a worker thread,
|
||||||
|
* never from the network event loop.
|
||||||
|
*/
|
||||||
|
public interface ImapStore {
|
||||||
|
|
||||||
|
/** A refusal the client sees as "NO reason". */
|
||||||
|
final class Refused extends RuntimeException {
|
||||||
|
public Refused(String reason) {
|
||||||
|
super(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Account(long userId, String email) {
|
||||||
|
}
|
||||||
|
|
||||||
|
record Mailbox(long id, String name, boolean system, long uidValidity, long uidNext) {
|
||||||
|
|
||||||
|
boolean isInbox() {
|
||||||
|
return system && "INBOX".equalsIgnoreCase(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isDrafts() {
|
||||||
|
return system && "Drafts".equals(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 6154 attribute, or null. */
|
||||||
|
String specialUse() {
|
||||||
|
if (!system) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (name) {
|
||||||
|
case "Drafts" -> "\\Drafts";
|
||||||
|
case "Sent" -> "\\Sent";
|
||||||
|
case "Spam" -> "\\Junk";
|
||||||
|
case "Trash" -> "\\Trash";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean named(String other) {
|
||||||
|
return isInbox() ? "INBOX".equalsIgnoreCase(other) : name.toLowerCase(Locale.ROOT).equals(other.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Entry(long id, long uid, boolean seen, boolean flagged, boolean answered, boolean deleted, Instant internalDate) {
|
||||||
|
|
||||||
|
Entry withFlags(boolean seen, boolean flagged, boolean answered, boolean deleted) {
|
||||||
|
return new Entry(id, uid, seen, flagged, answered, deleted, internalDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null — wrong user or password. */
|
||||||
|
Account authenticate(String username, String password);
|
||||||
|
|
||||||
|
List<Mailbox> mailboxes(long userId);
|
||||||
|
|
||||||
|
Mailbox create(long userId, String name);
|
||||||
|
|
||||||
|
void rename(long userId, Mailbox mailbox, String newName);
|
||||||
|
|
||||||
|
/** An own folder: its messages go to the Trash (as in the web client), then the folder is removed. */
|
||||||
|
void delete(long userId, Mailbox mailbox);
|
||||||
|
|
||||||
|
/** Messages of the folder in UID order. */
|
||||||
|
List<Entry> entries(long folderId);
|
||||||
|
|
||||||
|
/** The full MIME text of the message (the original one, or built once and kept). */
|
||||||
|
byte[] source(long messageId);
|
||||||
|
|
||||||
|
void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted);
|
||||||
|
|
||||||
|
/** Stores the message into the folder; returns its UID. */
|
||||||
|
long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, boolean answered,
|
||||||
|
boolean deleted, Instant internalDate);
|
||||||
|
|
||||||
|
/** Copies; returns the new UIDs in the same order. */
|
||||||
|
List<Long> copy(long userId, List<Entry> messages, Mailbox target);
|
||||||
|
|
||||||
|
/** Moves; returns the new UIDs in the same order. */
|
||||||
|
List<Long> move(long userId, List<Entry> messages, Mailbox target);
|
||||||
|
|
||||||
|
/** Removes the messages for good (with attachment files). */
|
||||||
|
void expunge(List<Long> messageIds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import jakarta.mail.internet.AddressException;
|
||||||
|
import jakarta.mail.internet.ContentDisposition;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeUtility;
|
||||||
|
import jakarta.mail.internet.ParameterList;
|
||||||
|
import jakarta.mail.internet.ParseException;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.UnsupportedEncodingException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** Builds IMAP response bytes: strings, literals, ENVELOPE, BODYSTRUCTURE (RFC 3501 7.4.2). */
|
||||||
|
final class ImapWriter {
|
||||||
|
|
||||||
|
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
ImapWriter text(String text) {
|
||||||
|
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
|
||||||
|
out.write(bytes, 0, bytes.length);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImapWriter sp() {
|
||||||
|
out.write(' ');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImapWriter crlf() {
|
||||||
|
return text("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
ImapWriter literal(byte[] bytes) {
|
||||||
|
text("{" + bytes.length + "}\r\n");
|
||||||
|
out.write(bytes, 0, bytes.length);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quoted when it is plain ASCII without CR/LF, a literal otherwise; null → NIL. */
|
||||||
|
ImapWriter nstring(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return text("NIL");
|
||||||
|
}
|
||||||
|
return string(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImapWriter string(String value) {
|
||||||
|
boolean quotable = value.length() < 1024;
|
||||||
|
for (int i = 0; i < value.length() && quotable; i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c > 0x7e || c < 0x20) {
|
||||||
|
quotable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!quotable) {
|
||||||
|
return literal(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
return text("\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes() {
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ENVELOPE ----------
|
||||||
|
|
||||||
|
ImapWriter envelope(MimeTree message) {
|
||||||
|
text("(");
|
||||||
|
nstring(message.header("Date")).sp();
|
||||||
|
nstring(message.header("Subject")).sp();
|
||||||
|
String from = message.header("From");
|
||||||
|
addresses(from).sp();
|
||||||
|
addresses(firstNonNull(message.header("Sender"), from)).sp();
|
||||||
|
addresses(firstNonNull(message.header("Reply-To"), from)).sp();
|
||||||
|
addresses(message.header("To")).sp();
|
||||||
|
addresses(message.header("Cc")).sp();
|
||||||
|
addresses(message.header("Bcc")).sp();
|
||||||
|
nstring(message.header("In-Reply-To")).sp();
|
||||||
|
nstring(message.header("Message-ID"));
|
||||||
|
return text(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImapWriter addresses(String header) {
|
||||||
|
if (header == null || header.isBlank()) {
|
||||||
|
return text("NIL");
|
||||||
|
}
|
||||||
|
InternetAddress[] list;
|
||||||
|
try {
|
||||||
|
list = InternetAddress.parseHeader(header, false);
|
||||||
|
} catch (AddressException e) {
|
||||||
|
return text("NIL");
|
||||||
|
}
|
||||||
|
if (list.length == 0) {
|
||||||
|
return text("NIL");
|
||||||
|
}
|
||||||
|
text("(");
|
||||||
|
for (InternetAddress address : list) {
|
||||||
|
String email = address.getAddress() == null ? "" : address.getAddress();
|
||||||
|
int at = email.lastIndexOf('@');
|
||||||
|
text("(");
|
||||||
|
nstring(encodedPersonal(address.getPersonal())).sp().text("NIL").sp();
|
||||||
|
nstring(at > 0 ? email.substring(0, at) : email).sp();
|
||||||
|
nstring(at > 0 ? email.substring(at + 1) : null);
|
||||||
|
text(")");
|
||||||
|
}
|
||||||
|
return text(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encodedPersonal(String personal) {
|
||||||
|
if (personal == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MimeUtility.encodeWord(personal, "UTF-8", "B");
|
||||||
|
} catch (UnsupportedEncodingException e) {
|
||||||
|
return personal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- BODYSTRUCTURE / BODY ----------
|
||||||
|
|
||||||
|
ImapWriter bodyStructure(MimeTree part, boolean extensible) {
|
||||||
|
text("(");
|
||||||
|
if (part.isMultipart() && !part.children.isEmpty()) {
|
||||||
|
for (MimeTree child : part.children) {
|
||||||
|
bodyStructure(child, extensible);
|
||||||
|
}
|
||||||
|
sp().string(part.type.getSubType().toUpperCase(Locale.ROOT));
|
||||||
|
if (extensible) {
|
||||||
|
sp().params(part.type.getParameterList()).sp();
|
||||||
|
disposition(part).sp().text("NIL NIL");
|
||||||
|
}
|
||||||
|
return text(")");
|
||||||
|
}
|
||||||
|
String primary = part.isMultipart() ? "TEXT" : part.type.getPrimaryType().toUpperCase(Locale.ROOT);
|
||||||
|
String sub = part.isMultipart() ? "PLAIN" : part.type.getSubType().toUpperCase(Locale.ROOT);
|
||||||
|
string(primary).sp().string(sub).sp();
|
||||||
|
params(part.type.getParameterList()).sp();
|
||||||
|
nstring(part.header("Content-ID")).sp();
|
||||||
|
nstring(part.header("Content-Description")).sp();
|
||||||
|
String encoding = part.header("Content-Transfer-Encoding");
|
||||||
|
string(encoding == null ? "7BIT" : encoding.toUpperCase(Locale.ROOT)).sp();
|
||||||
|
text(String.valueOf(part.bodySize()));
|
||||||
|
if ("TEXT".equals(primary)) {
|
||||||
|
sp().text(String.valueOf(part.bodyLines()));
|
||||||
|
} else if (part.message != null) {
|
||||||
|
sp().envelope(part.message).sp().bodyStructure(part.message, extensible).sp().text(String.valueOf(part.bodyLines()));
|
||||||
|
}
|
||||||
|
if (extensible) {
|
||||||
|
sp().nstring(part.header("Content-MD5")).sp();
|
||||||
|
disposition(part).sp();
|
||||||
|
nstring(part.header("Content-Language")).sp();
|
||||||
|
nstring(part.header("Content-Location"));
|
||||||
|
}
|
||||||
|
return text(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImapWriter params(ParameterList params) {
|
||||||
|
if (params == null || params.size() == 0) {
|
||||||
|
return text("NIL");
|
||||||
|
}
|
||||||
|
text("(");
|
||||||
|
boolean first = true;
|
||||||
|
Enumeration<String> names = params.getNames();
|
||||||
|
while (names.hasMoreElements()) {
|
||||||
|
String name = names.nextElement();
|
||||||
|
if (!first) {
|
||||||
|
sp();
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
string(name.toUpperCase(Locale.ROOT)).sp().string(String.valueOf(params.get(name)));
|
||||||
|
}
|
||||||
|
return text(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImapWriter disposition(MimeTree part) {
|
||||||
|
String header = part.header("Content-Disposition");
|
||||||
|
if (header == null) {
|
||||||
|
return text("NIL");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ContentDisposition disposition = new ContentDisposition(header);
|
||||||
|
text("(").string(disposition.getDisposition().toUpperCase(Locale.ROOT)).sp();
|
||||||
|
params(disposition.getParameterList());
|
||||||
|
return text(")");
|
||||||
|
} catch (ParseException e) {
|
||||||
|
return text("NIL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonNull(String a, String b) {
|
||||||
|
return a != null ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- modified UTF-7 (RFC 3501 5.1.3) for mailbox names ----------
|
||||||
|
|
||||||
|
static String encodeMailbox(String name) {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
StringBuilder pending = new StringBuilder();
|
||||||
|
for (int i = 0; i < name.length(); i++) {
|
||||||
|
char c = name.charAt(i);
|
||||||
|
if (c >= 0x20 && c <= 0x7e) {
|
||||||
|
flushBase64(out, pending);
|
||||||
|
out.append(c == '&' ? "&-" : String.valueOf(c));
|
||||||
|
} else {
|
||||||
|
pending.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBase64(out, pending);
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void flushBase64(StringBuilder out, StringBuilder pending) {
|
||||||
|
if (pending.length() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] utf16 = pending.toString().getBytes(StandardCharsets.UTF_16BE);
|
||||||
|
String base64 = java.util.Base64.getEncoder().withoutPadding().encodeToString(utf16).replace('/', ',');
|
||||||
|
out.append('&').append(base64).append('-');
|
||||||
|
pending.setLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String decodeMailbox(String name) {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
int i = 0;
|
||||||
|
while (i < name.length()) {
|
||||||
|
char c = name.charAt(i);
|
||||||
|
if (c != '&') {
|
||||||
|
out.append(c);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int dash = name.indexOf('-', i);
|
||||||
|
if (dash < 0) {
|
||||||
|
out.append(name.substring(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (dash == i + 1) {
|
||||||
|
out.append('&');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
byte[] utf16 = java.util.Base64.getDecoder().decode(name.substring(i + 1, dash).replace(',', '/'));
|
||||||
|
out.append(new String(utf16, StandardCharsets.UTF_16BE));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
out.append(name, i, dash + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = dash + 1;
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import com.mailserver.domain.Attachment;
|
||||||
|
import com.mailserver.domain.Message;
|
||||||
|
import jakarta.activation.DataHandler;
|
||||||
|
import jakarta.activation.FileDataSource;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeBodyPart;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import jakarta.mail.internet.MimeMultipart;
|
||||||
|
import jakarta.mail.internet.MimeUtility;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME text of a message that has no original (written in the web client): built from the stored fields and
|
||||||
|
* attachment files. The boundary and Message-ID come from the message id, so the result is the same every time.
|
||||||
|
*/
|
||||||
|
public final class MimeSource {
|
||||||
|
|
||||||
|
private MimeSource() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] build(Message message, List<Attachment> attachments, String hostname)
|
||||||
|
throws MessagingException, IOException {
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.mime.splitlongparameters", "true");
|
||||||
|
Session session = Session.getInstance(props);
|
||||||
|
String messageId = message.getMessageId() == null || message.getMessageId().isBlank()
|
||||||
|
? message.getId() + "@" + hostname
|
||||||
|
: message.getMessageId().replaceAll("^<|>$", "");
|
||||||
|
if (!messageId.contains("@")) {
|
||||||
|
messageId = messageId + "@" + hostname;
|
||||||
|
}
|
||||||
|
String finalMessageId = "<" + messageId + ">";
|
||||||
|
MimeMessage mime = new MimeMessage(session) {
|
||||||
|
@Override
|
||||||
|
protected void updateMessageID() throws MessagingException {
|
||||||
|
setHeader("Message-ID", finalMessageId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
String from = message.getSenderEmail() == null ? "unknown@" + hostname : message.getSenderEmail();
|
||||||
|
mime.setFrom(new InternetAddress(from, message.getSenderName(), "UTF-8"));
|
||||||
|
List<InternetAddress> to = addresses(message.getRecipientEmail());
|
||||||
|
if (!to.isEmpty()) {
|
||||||
|
mime.setRecipients(jakarta.mail.Message.RecipientType.TO, to.toArray(new InternetAddress[0]));
|
||||||
|
}
|
||||||
|
List<InternetAddress> cc = addresses(message.getCcEmail());
|
||||||
|
if (!cc.isEmpty()) {
|
||||||
|
mime.setRecipients(jakarta.mail.Message.RecipientType.CC, cc.toArray(new InternetAddress[0]));
|
||||||
|
}
|
||||||
|
mime.setSubject(message.getSubject() == null ? "" : message.getSubject(), "UTF-8");
|
||||||
|
if (message.getReceivedAt() != null) {
|
||||||
|
mime.setSentDate(Date.from(message.getReceivedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
String body = message.getBody() == null ? "" : message.getBody();
|
||||||
|
String subtype = message.isHtml() ? "html" : "plain";
|
||||||
|
if (attachments.isEmpty()) {
|
||||||
|
mime.setText(body, "UTF-8", subtype);
|
||||||
|
} else {
|
||||||
|
MimeMultipart mixed = new FixedBoundaryMultipart("mixed", "=_rms_" + message.getId());
|
||||||
|
MimeBodyPart text = new MimeBodyPart();
|
||||||
|
text.setText(body, "UTF-8", subtype);
|
||||||
|
mixed.addBodyPart(text);
|
||||||
|
for (Attachment attachment : attachments) {
|
||||||
|
Path file = Path.of(attachment.getFilePath());
|
||||||
|
if (!Files.isRegularFile(file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
MimeBodyPart part = new MimeBodyPart();
|
||||||
|
FileDataSource source = new FileDataSource(file.toFile()) {
|
||||||
|
@Override
|
||||||
|
public String getContentType() {
|
||||||
|
return attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
part.setDataHandler(new DataHandler(source));
|
||||||
|
part.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null));
|
||||||
|
part.setDisposition(attachment.getContentId() != null ? MimeBodyPart.INLINE : MimeBodyPart.ATTACHMENT);
|
||||||
|
if (attachment.getContentId() != null) {
|
||||||
|
part.setContentID("<" + attachment.getContentId().replaceAll("^<|>$", "") + ">");
|
||||||
|
}
|
||||||
|
mixed.addBodyPart(part);
|
||||||
|
}
|
||||||
|
mime.setContent(mixed);
|
||||||
|
}
|
||||||
|
mime.saveChanges();
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
mime.writeTo(out);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InternetAddress> addresses(String list) {
|
||||||
|
List<InternetAddress> result = new ArrayList<>();
|
||||||
|
if (list == null || list.isBlank()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (String one : list.split("[,;]")) {
|
||||||
|
String address = one.trim();
|
||||||
|
if (!address.isEmpty()) {
|
||||||
|
try {
|
||||||
|
result.add(new InternetAddress(address));
|
||||||
|
} catch (MessagingException ignored) {
|
||||||
|
// a broken address is left out, the message is still served
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MimeMultipart with a given boundary instead of a random one. */
|
||||||
|
private static final class FixedBoundaryMultipart extends MimeMultipart {
|
||||||
|
FixedBoundaryMultipart(String subtype, String boundary) {
|
||||||
|
super(subtype);
|
||||||
|
this.contentType = "multipart/" + subtype + "; boundary=\"" + boundary + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.ContentType;
|
||||||
|
import jakarta.mail.internet.InternetHeaders;
|
||||||
|
import jakarta.mail.internet.ParseException;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message split into MIME parts by byte offsets — what IMAP needs for BODYSTRUCTURE and BODY[section]:
|
||||||
|
* every part is a range of the original bytes, so sizes and contents match BODY[] exactly.
|
||||||
|
*/
|
||||||
|
final class MimeTree {
|
||||||
|
|
||||||
|
private static final int MAX_DEPTH = 20;
|
||||||
|
|
||||||
|
final byte[] raw;
|
||||||
|
/** Part = [start, end); headers = [start, bodyStart), the blank line included. */
|
||||||
|
final int start;
|
||||||
|
final int bodyStart;
|
||||||
|
final int end;
|
||||||
|
final InternetHeaders headers;
|
||||||
|
final ContentType type;
|
||||||
|
final List<MimeTree> children;
|
||||||
|
/** The encapsulated message of a message/rfc822 part. */
|
||||||
|
final MimeTree message;
|
||||||
|
|
||||||
|
private MimeTree(byte[] raw, int start, int end, int depth) {
|
||||||
|
this.raw = raw;
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
this.bodyStart = headerEnd(raw, start, end);
|
||||||
|
this.headers = parseHeaders(raw, start, bodyStart);
|
||||||
|
this.type = contentType(headers);
|
||||||
|
List<MimeTree> parts = new ArrayList<>();
|
||||||
|
MimeTree nested = null;
|
||||||
|
if (depth < MAX_DEPTH) {
|
||||||
|
if (isMultipart()) {
|
||||||
|
String boundary = type.getParameter("boundary");
|
||||||
|
if (boundary != null && !boundary.isEmpty()) {
|
||||||
|
split(boundary, depth, parts);
|
||||||
|
}
|
||||||
|
} else if (is("message", "rfc822") || is("message", "global")) {
|
||||||
|
nested = new MimeTree(raw, bodyStart, end, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.children = Collections.unmodifiableList(parts);
|
||||||
|
this.message = nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MimeTree parse(byte[] raw) {
|
||||||
|
return new MimeTree(raw, 0, raw.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isMultipart() {
|
||||||
|
return "multipart".equalsIgnoreCase(type.getPrimaryType());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean is(String primary, String sub) {
|
||||||
|
return primary.equalsIgnoreCase(type.getPrimaryType()) && sub.equalsIgnoreCase(type.getSubType());
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] all() {
|
||||||
|
return slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] header() {
|
||||||
|
return slice(start, bodyStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] body() {
|
||||||
|
return slice(bodyStart, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
int bodySize() {
|
||||||
|
return end - bodyStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lines of the body (for text parts in BODYSTRUCTURE). */
|
||||||
|
int bodyLines() {
|
||||||
|
int lines = 0;
|
||||||
|
for (int i = bodyStart; i < end; i++) {
|
||||||
|
if (raw[i] == '\n') {
|
||||||
|
lines++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end > bodyStart && raw[end - 1] != '\n') {
|
||||||
|
lines++;
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header value unfolded, or null. */
|
||||||
|
String header(String name) {
|
||||||
|
String[] values = headers.getHeader(name);
|
||||||
|
if (values == null || values.length == 0 || values[0] == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return values[0].replaceAll("\r?\n(?=[ \t])", "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only these header fields (or all but these), each with its folded lines, plus the blank line. */
|
||||||
|
byte[] headerFields(Set<String> names, boolean not) {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
String text = new String(raw, start, bodyStart - start, StandardCharsets.ISO_8859_1);
|
||||||
|
String[] lines = text.split("\r?\n", -1);
|
||||||
|
boolean keep = false;
|
||||||
|
for (String line : lines) {
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.charAt(0) == ' ' || line.charAt(0) == '\t') {
|
||||||
|
if (keep) {
|
||||||
|
out.append(line).append("\r\n");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int colon = line.indexOf(':');
|
||||||
|
String name = colon > 0 ? line.substring(0, colon).trim().toUpperCase(Locale.ROOT) : "";
|
||||||
|
keep = names.contains(name) != not;
|
||||||
|
if (keep) {
|
||||||
|
out.append(line).append("\r\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.append("\r\n");
|
||||||
|
return out.toString().getBytes(StandardCharsets.ISO_8859_1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] slice(int from, int to) {
|
||||||
|
byte[] copy = new byte[Math.max(0, to - from)];
|
||||||
|
System.arraycopy(raw, from, copy, 0, copy.length);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body parts between "--boundary" lines; the CRLF before a delimiter belongs to the delimiter. */
|
||||||
|
private void split(String boundary, int depth, List<MimeTree> parts) {
|
||||||
|
byte[] delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1);
|
||||||
|
int partStart = -1;
|
||||||
|
int i = bodyStart;
|
||||||
|
while (i < end) {
|
||||||
|
int lineEnd = indexOf(raw, (byte) '\n', i, end);
|
||||||
|
int next = lineEnd < 0 ? end : lineEnd + 1;
|
||||||
|
if (startsWith(raw, i, end, delimiter)) {
|
||||||
|
int after = i + delimiter.length;
|
||||||
|
boolean close = after + 1 < end && raw[after] == '-' && raw[after + 1] == '-';
|
||||||
|
if (partStart >= 0) {
|
||||||
|
int partEnd = i;
|
||||||
|
if (partEnd > partStart && raw[partEnd - 1] == '\n') {
|
||||||
|
partEnd--;
|
||||||
|
if (partEnd > partStart && raw[partEnd - 1] == '\r') {
|
||||||
|
partEnd--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.add(new MimeTree(raw, partStart, Math.max(partStart, partEnd), depth + 1));
|
||||||
|
}
|
||||||
|
if (close) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
partStart = next;
|
||||||
|
}
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
if (partStart >= 0 && partStart < end) { // no closing delimiter — take the rest
|
||||||
|
parts.add(new MimeTree(raw, partStart, end, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int headerEnd(byte[] raw, int start, int end) {
|
||||||
|
if (start < end && raw[start] == '\n') {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
if (start + 1 < end && raw[start] == '\r' && raw[start + 1] == '\n') {
|
||||||
|
return start + 2;
|
||||||
|
}
|
||||||
|
for (int i = start; i < end; i++) {
|
||||||
|
if (raw[i] == '\n') {
|
||||||
|
if (i + 1 < end && raw[i + 1] == '\n') {
|
||||||
|
return i + 2;
|
||||||
|
}
|
||||||
|
if (i + 2 < end && raw[i + 1] == '\r' && raw[i + 2] == '\n') {
|
||||||
|
return i + 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return end; // headers only
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InternetHeaders parseHeaders(byte[] raw, int start, int end) {
|
||||||
|
try {
|
||||||
|
return new InternetHeaders(new ByteArrayInputStream(raw, start, end - start));
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
return new InternetHeaders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContentType contentType(InternetHeaders headers) {
|
||||||
|
String[] values = headers.getHeader("Content-Type");
|
||||||
|
if (values != null && values.length > 0 && values[0] != null) {
|
||||||
|
try {
|
||||||
|
return new ContentType(values[0].replaceAll("\r?\n(?=[ \t])", "").trim());
|
||||||
|
} catch (ParseException ignored) {
|
||||||
|
// broken header — the RFC 2045 default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new ContentType("text/plain; charset=us-ascii");
|
||||||
|
} catch (ParseException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean startsWith(byte[] raw, int at, int end, byte[] prefix) {
|
||||||
|
if (at + prefix.length > end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < prefix.length; i++) {
|
||||||
|
if (raw[at + i] != prefix[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOf(byte[] raw, byte value, int from, int end) {
|
||||||
|
for (int i = from; i < end; i++) {
|
||||||
|
if (raw[i] == value) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header names of the part, for tests and debugging. */
|
||||||
|
List<String> headerNames() {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
Enumeration<jakarta.mail.Header> all = headers.getAllHeaders();
|
||||||
|
while (all.hasMoreElements()) {
|
||||||
|
names.add(all.nextElement().getName());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
package com.mailserver.repository;
|
package com.mailserver.repository;
|
||||||
|
|
||||||
import com.mailserver.domain.Attachment;
|
import com.mailserver.domain.Attachment;
|
||||||
|
import org.springframework.data.r2dbc.repository.Modifying;
|
||||||
|
import org.springframework.data.r2dbc.repository.Query;
|
||||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
public interface AttachmentRepository extends R2dbcRepository<Attachment, Long> {
|
public interface AttachmentRepository extends R2dbcRepository<Attachment, Long> {
|
||||||
Flux<Attachment> findByMessageId(Long messageId);
|
Flux<Attachment> findByMessageId(Long messageId);
|
||||||
|
|
||||||
|
Mono<Long> countByMessageId(Long messageId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}")
|
||||||
|
Mono<Integer> moveAll(Long from, Long to);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(a.size_bytes), 0) 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}")
|
||||||
|
Mono<Long> sumSizeByUserId(Long userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.mailserver.repository;
|
|||||||
|
|
||||||
import com.mailserver.domain.Message;
|
import com.mailserver.domain.Message;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.r2dbc.repository.Modifying;
|
||||||
import org.springframework.data.r2dbc.repository.Query;
|
import org.springframework.data.r2dbc.repository.Query;
|
||||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
@@ -9,19 +10,29 @@ import reactor.core.publisher.Mono;
|
|||||||
|
|
||||||
public interface MessageRepository extends R2dbcRepository<Message, Long> {
|
public interface MessageRepository extends R2dbcRepository<Message, Long> {
|
||||||
|
|
||||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC")
|
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC LIMIT :#{#limit}")
|
||||||
Flux<Message> findByFolderId(Long folderId, Pageable pageable);
|
Flux<Message> findLatest(Long folderId, int limit);
|
||||||
|
|
||||||
|
Flux<Message> findByFolderId(Long folderId);
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId}")
|
||||||
|
Mono<Long> countByFolderId(Long folderId);
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId} AND is_read = false")
|
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId} AND is_read = false")
|
||||||
Mono<Long> countUnreadByFolderId(Long folderId);
|
Mono<Long> countUnreadByFolderId(Long folderId);
|
||||||
|
|
||||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND is_read = false ORDER BY received_at DESC")
|
@Modifying
|
||||||
Flux<Message> findUnreadByFolderId(Long folderId, Pageable pageable);
|
@Query("UPDATE messages SET folder_id = :#{#to} WHERE folder_id = :#{#from}")
|
||||||
|
Mono<Integer> moveAll(Long from, Long to);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(m.message_size), 0) FROM messages m JOIN folders f ON f.id = m.folder_id WHERE f.user_id = :#{#userId}")
|
||||||
|
Mono<Long> sumSizeByUserId(Long userId);
|
||||||
|
|
||||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND " +
|
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND " +
|
||||||
"(LOWER(subject) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
"(LOWER(subject) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
||||||
"LOWER(sender_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
"LOWER(sender_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
||||||
"LOWER(sender_name) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
"LOWER(sender_name) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
||||||
|
"LOWER(recipient_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
||||||
"LOWER(body) LIKE '%' || LOWER(:#{#keyword}) || '%') " +
|
"LOWER(body) LIKE '%' || LOWER(:#{#keyword}) || '%') " +
|
||||||
"ORDER BY received_at DESC")
|
"ORDER BY received_at DESC")
|
||||||
Flux<Message> searchInFolder(Long folderId, String keyword, Pageable pageable);
|
Flux<Message> searchInFolder(Long folderId, String keyword, Pageable pageable);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,458 +1,209 @@
|
|||||||
package com.mailserver.service;
|
package com.mailserver.service;
|
||||||
|
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
|
||||||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.mail.MessagingException;
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
import jakarta.mail.internet.MimeMessage;
|
import jakarta.mail.internet.MimeMessage;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.apache.james.jdkim.DKIMVerifier;
|
||||||
|
import org.apache.james.jdkim.api.PublicKeyRecordRetriever;
|
||||||
|
import org.apache.james.jdkim.api.SignatureRecord;
|
||||||
|
import org.apache.james.jdkim.exceptions.FailException;
|
||||||
|
import org.apache.james.jdkim.impl.DNSPublicKeyRecordRetriever;
|
||||||
|
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
|
||||||
|
import org.bouncycastle.openssl.PEMKeyPair;
|
||||||
|
import org.bouncycastle.openssl.PEMParser;
|
||||||
|
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
|
||||||
|
import org.simplejavamail.utils.mail.dkim.Canonicalization;
|
||||||
|
import org.simplejavamail.utils.mail.dkim.DkimMessage;
|
||||||
|
import org.simplejavamail.utils.mail.dkim.DkimSigner;
|
||||||
|
import org.simplejavamail.utils.mail.dkim.SigningAlgorithm;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.io.StringReader;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.security.*;
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
import java.security.spec.InvalidKeySpecException;
|
import java.security.KeyFactory;
|
||||||
import java.security.cert.CertificateException;
|
import java.security.KeyPairGenerator;
|
||||||
import java.security.spec.PKCS8EncodedKeySpec;
|
import java.security.interfaces.RSAPrivateCrtKey;
|
||||||
import java.security.spec.X509EncodedKeySpec;
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
|
import java.security.spec.RSAPublicKeySpec;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сервис для DKIM (DomainKeys Identified Mail) подписи и верификации писем.
|
* DKIM (RFC 6376): signs our outgoing mail and verifies incoming mail.
|
||||||
*
|
* Signing — simple-java-mail utils-mail-dkim (rsa-sha256, relaxed/relaxed); verification — Apache James jDKIM
|
||||||
* DKIM добавляет криптографическую подпись к каждому исходящему письму,
|
* with the sender's public key from DNS. The key is generated once and kept in {@code keys-dir/dkim-private.pem};
|
||||||
* чтобы получатель мог проверить, что письмо действительно отправлено
|
* the DNS record to publish is printed to the log at start and available from {@link #dnsRecord()}.
|
||||||
* владельцем домена и не было изменено в пути.
|
|
||||||
*
|
|
||||||
* При старте генерирует RSA-ключ (2048 бит) и сохраняет его в файл.
|
|
||||||
* Подписывает заголовки From, Subject, Date, To и тело сообщения.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class DkimService {
|
public class DkimService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(DkimService.class);
|
private static final Logger log = LoggerFactory.getLogger(DkimService.class);
|
||||||
|
|
||||||
private static final String DKIM_SELECTOR = "default";
|
/** Result of checking an incoming message. */
|
||||||
|
public record Verification(boolean signed, boolean passed, String domain) {
|
||||||
|
static final Verification NONE = new Verification(false, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
@Value("${mailserver.dkim.domain:localhost}")
|
private final String domain;
|
||||||
private String domain;
|
private final String selector;
|
||||||
|
private final Path keysDir;
|
||||||
|
private final boolean enabled;
|
||||||
|
private final DKIMVerifier verifier;
|
||||||
|
|
||||||
@Value("${mailserver.dkim.keys-dir:./dkim-keys}")
|
private RSAPrivateKey privateKey;
|
||||||
private String keysDir;
|
|
||||||
|
|
||||||
@Value("${mailserver.dkim.enabled:true}")
|
|
||||||
private boolean enabled;
|
|
||||||
|
|
||||||
private PrivateKey privateKey;
|
|
||||||
private PublicKey publicKey;
|
|
||||||
private String publicKeyBase64;
|
private String publicKeyBase64;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public DkimService(@Value("${mailserver.dkim.domain:localhost}") String domain,
|
||||||
|
@Value("${mailserver.dkim.selector:mail}") String selector,
|
||||||
|
@Value("${mailserver.dkim.keys-dir:./dkim-keys}") String keysDir,
|
||||||
|
@Value("${mailserver.dkim.enabled:true}") boolean enabled) {
|
||||||
|
this(domain, selector, Paths.get(keysDir), enabled, new DNSPublicKeyRecordRetriever());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests pass their own public key lookup instead of DNS
|
||||||
|
DkimService(String domain, String selector, Path keysDir, boolean enabled, PublicKeyRecordRetriever keys) {
|
||||||
|
this.domain = domain.toLowerCase(Locale.ROOT);
|
||||||
|
this.selector = selector;
|
||||||
|
this.keysDir = keysDir;
|
||||||
|
this.enabled = enabled;
|
||||||
|
this.verifier = new DKIMVerifier(keys);
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
Security.addProvider(new BouncyCastleProvider());
|
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
log.info("DKIM подписи отключены");
|
log.info("DKIM signing disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Path keyDir = Paths.get(keysDir);
|
Files.createDirectories(keysDir);
|
||||||
Files.createDirectories(keyDir);
|
Path keyFile = keysDir.resolve("dkim-private.pem");
|
||||||
|
if (Files.exists(keyFile)) {
|
||||||
Path privateKeyFile = keyDir.resolve("dkim-private.pem");
|
privateKey = readPrivateKey(Files.readString(keyFile));
|
||||||
Path publicKeyFile = keyDir.resolve("dkim-public.pem");
|
log.info("DKIM: key loaded for {}", domain);
|
||||||
|
|
||||||
if (Files.exists(privateKeyFile) && Files.exists(publicKeyFile)) {
|
|
||||||
// Загружаем существующие ключи
|
|
||||||
loadKeys(privateKeyFile, publicKeyFile);
|
|
||||||
log.info("DKIM: загружены существующие ключи для домена {}", domain);
|
|
||||||
} else {
|
} else {
|
||||||
// Генерируем новые ключи
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
KeyPair keyPair = generateKeyPair();
|
generator.initialize(2048);
|
||||||
privateKey = keyPair.getPrivate();
|
privateKey = (RSAPrivateKey) generator.generateKeyPair().getPrivate();
|
||||||
publicKey = keyPair.getPublic();
|
String pem = "-----BEGIN PRIVATE KEY-----\n"
|
||||||
saveKeys(privateKeyFile, publicKeyFile, keyPair);
|
+ Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(privateKey.getEncoded())
|
||||||
log.info("DKIM: сгенерированы новые ключи для домена {}", domain);
|
+ "\n-----END PRIVATE KEY-----\n";
|
||||||
|
Files.writeString(keyFile, pem);
|
||||||
|
restrict(keyFile);
|
||||||
|
log.info("DKIM: new key generated for {}", domain);
|
||||||
}
|
}
|
||||||
|
publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyOf(privateKey));
|
||||||
// Формируем публичный ключ в формате DNS TXT-записи
|
log.info("DKIM DNS record — TXT {}._domainkey.{}: {}", selector, domain, dnsRecord());
|
||||||
publicKeyBase64 = encodePublicKeyForDns(publicKey);
|
|
||||||
log.info("DKIM публичный ключ для DNS TXT-записи (_{}._domainkey.{}):", DKIM_SELECTOR, domain);
|
|
||||||
log.info("v=DKIM1; k=rsa; p={}", publicKeyBase64);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Ошибка инициализации DKIM", e);
|
throw new IllegalStateException("DKIM key cannot be loaded from " + keysDir, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** TXT value to publish at {@code <selector>._domainkey.<domain>}. */
|
||||||
* Генерирует RSA-ключ 2048 бит для DKIM.
|
public String dnsRecord() {
|
||||||
*/
|
return publicKeyBase64 == null ? null : "v=DKIM1; k=rsa; p=" + publicKeyBase64;
|
||||||
private KeyPair generateKeyPair() throws NoSuchAlgorithmException {
|
|
||||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
|
||||||
keyPairGenerator.initialize(2048);
|
|
||||||
return keyPairGenerator.generateKeyPair();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохраняет ключи в PEM-формат.
|
* Signs the message when it is from our domain; otherwise returns it unchanged.
|
||||||
|
* The result is the exact byte sequence to send — do not re-serialize it.
|
||||||
*/
|
*/
|
||||||
private void saveKeys(Path privateKeyFile, Path publicKeyFile, KeyPair keyPair)
|
public byte[] sign(byte[] message) throws MessagingException, IOException {
|
||||||
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
|
|
||||||
try (FileWriter fw = new FileWriter(privateKeyFile.toFile());
|
|
||||||
JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) {
|
|
||||||
pemWriter.writeObject(keyPair.getPrivate());
|
|
||||||
pemWriter.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
try (FileWriter fw = new FileWriter(publicKeyFile.toFile());
|
|
||||||
JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) {
|
|
||||||
pemWriter.writeObject(keyPair.getPublic());
|
|
||||||
pemWriter.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Загружает ключи из PEM-файлов.
|
|
||||||
*/
|
|
||||||
private void loadKeys(Path privateKeyFile, Path publicKeyFile)
|
|
||||||
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
|
|
||||||
String pkcs8Private = Files.readString(privateKeyFile);
|
|
||||||
String derEncoded = pkcs8Private
|
|
||||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
|
||||||
.replace("-----END PRIVATE KEY-----", "")
|
|
||||||
.replaceAll("\\s", "");
|
|
||||||
byte[] decoded = Base64.getDecoder().decode(derEncoded);
|
|
||||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
|
|
||||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
|
||||||
privateKey = keyFactory.generatePrivate(spec);
|
|
||||||
|
|
||||||
String pkcs8Public = Files.readString(publicKeyFile);
|
|
||||||
String derEncodedPublic = pkcs8Public
|
|
||||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
|
||||||
.replace("-----END PUBLIC KEY-----", "")
|
|
||||||
.replaceAll("\\s", "");
|
|
||||||
byte[] decodedPublic = Base64.getDecoder().decode(derEncodedPublic);
|
|
||||||
X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(decodedPublic);
|
|
||||||
publicKey = keyFactory.generatePublic(pubSpec);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Кодирует публичный ключ для DNS TXT-записи.
|
|
||||||
*/
|
|
||||||
private String encodePublicKeyForDns(PublicKey key) {
|
|
||||||
byte[] encoded = key.getEncoded();
|
|
||||||
return Base64.getEncoder().withoutPadding().encodeToString(encoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Подписывает MIME-сообщение DKIM-подписью.
|
|
||||||
* Добавляет заголовок DKIM-Signature в письмо.
|
|
||||||
*
|
|
||||||
* @param mimeMessage MIME-сообщение для подписи
|
|
||||||
* @throws MessagingException если не удалось добавить заголовок
|
|
||||||
*/
|
|
||||||
public void signMessage(MimeMessage mimeMessage) throws MessagingException {
|
|
||||||
if (!enabled || privateKey == null) {
|
if (!enabled || privateKey == null) {
|
||||||
return;
|
return message;
|
||||||
}
|
}
|
||||||
|
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(message));
|
||||||
|
if (mime.getFrom() == null || mime.getFrom().length == 0
|
||||||
|
|| !(mime.getFrom()[0] instanceof InternetAddress from)
|
||||||
|
|| !domain.equals(domainOf(from.getAddress()))) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
DkimSigner signer = new DkimSigner(domain, selector, privateKey);
|
||||||
|
signer.setCheckDomainKey(false); // the DNS record may not be published yet (tests, new server)
|
||||||
|
signer.setSigningAlgorithm(SigningAlgorithm.SHA256_WITH_RSA);
|
||||||
|
signer.setHeaderCanonicalization(Canonicalization.RELAXED);
|
||||||
|
signer.setBodyCanonicalization(Canonicalization.RELAXED);
|
||||||
|
signer.setLengthParam(false);
|
||||||
|
signer.setCopyHeaderFields(false);
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(message.length + 1024);
|
||||||
|
DkimMessage signed = new DkimMessage(mime, signer);
|
||||||
|
signed.setAllow8bitMIME(true);
|
||||||
|
signed.writeTo(out);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks DKIM signatures of an incoming message (public key from the signer's DNS). */
|
||||||
|
public Verification verify(byte[] message) {
|
||||||
try {
|
try {
|
||||||
// Извлекаем заголовки для подписи
|
List<SignatureRecord> valid;
|
||||||
String from = extractHeader(mimeMessage, "From");
|
synchronized (verifier) { // DKIMVerifier keeps results between calls
|
||||||
String subject = extractHeader(mimeMessage, "Subject");
|
verifier.resetResults();
|
||||||
String date = extractHeader(mimeMessage, "Date");
|
valid = verifier.verify(new ByteArrayInputStream(message));
|
||||||
String to = extractHeader(mimeMessage, "To");
|
|
||||||
String messageId = extractHeader(mimeMessage, "Message-ID");
|
|
||||||
|
|
||||||
// Извлекаем тело сообщения
|
|
||||||
String body = extractBody(mimeMessage);
|
|
||||||
|
|
||||||
// Формируем канонизированное тело (relaxed канонизация)
|
|
||||||
String canonicalBody = canonicalizeBody(body);
|
|
||||||
|
|
||||||
// Формируем канонизированные заголовки
|
|
||||||
String headerCanonicalization = "r";
|
|
||||||
String bodyCanonicalization = "r";
|
|
||||||
String algorithm = "rsa-sha256";
|
|
||||||
|
|
||||||
// Строим заголовки для подписи
|
|
||||||
StringBuilder headersToSign = new StringBuilder();
|
|
||||||
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
|
|
||||||
String[] headerValues = {from, subject, date, to, messageId};
|
|
||||||
|
|
||||||
for (int i = 0; i < headerNames.length; i++) {
|
|
||||||
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
|
|
||||||
}
|
}
|
||||||
|
if (valid == null) {
|
||||||
// Вычисляем хэш тела
|
return Verification.NONE;
|
||||||
byte[] bodyHash = hashBody(canonicalBody);
|
}
|
||||||
|
if (valid.isEmpty()) {
|
||||||
// Формируем signing input
|
return new Verification(true, false, null);
|
||||||
String headerCanon = headersToSign.toString();
|
}
|
||||||
byte[] signingInput = (headerCanon + "bh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash) + "\rbh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash)).getBytes();
|
return new Verification(true, true, valid.get(0).getDToken().toString().toLowerCase(Locale.ROOT));
|
||||||
|
} catch (FailException e) {
|
||||||
// Подписываем
|
log.info("DKIM: signature failed: {}", e.getMessage());
|
||||||
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
|
return new Verification(true, false, null);
|
||||||
signature.initSign(privateKey);
|
} catch (IOException e) {
|
||||||
signature.update(signingInput);
|
log.warn("DKIM: message cannot be read: {}", e.getMessage());
|
||||||
byte[] signatureBytes = signature.sign();
|
return new Verification(true, false, null);
|
||||||
|
|
||||||
// Формируем DKIM-Signature заголовок
|
|
||||||
String b64Signature = Base64.getEncoder().withoutPadding().encodeToString(signatureBytes);
|
|
||||||
String b64BodyHash = Base64.getEncoder().withoutPadding().encodeToString(bodyHash);
|
|
||||||
|
|
||||||
StringBuilder dkimHeader = new StringBuilder();
|
|
||||||
dkimHeader.append("v=1; ");
|
|
||||||
dkimHeader.append("a=").append(algorithm).append("; ");
|
|
||||||
dkimHeader.append("d=").append(domain).append("; ");
|
|
||||||
dkimHeader.append("s=").append(DKIM_SELECTOR).append("; ");
|
|
||||||
dkimHeader.append("c=").append(headerCanonicalization).append("/").append(bodyCanonicalization).append("; ");
|
|
||||||
dkimHeader.append("h=").append(String.join(":", headerNames)).append("; ");
|
|
||||||
dkimHeader.append("bh=").append(b64BodyHash).append("; ");
|
|
||||||
dkimHeader.append("b=").append(b64Signature);
|
|
||||||
|
|
||||||
mimeMessage.setHeader("DKIM-Signature", dkimHeader.toString());
|
|
||||||
log.debug("DKIM: сообщение подписано для домена {}", domain);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("DKIM: ошибка подписи сообщения", e);
|
|
||||||
// Не бросаем исключение — письмо будет отправлено без подписи
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static String domainOf(String address) {
|
||||||
* Верифицирует DKIM-подпись входящего сообщения.
|
int at = address == null ? -1 : address.lastIndexOf('@');
|
||||||
*
|
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
|
||||||
* @param mimeMessage MIME-сообщение с DKIM-подписью
|
}
|
||||||
* @return true если подпись валидна, false если подпись отсутствует или невалидна
|
|
||||||
*/
|
|
||||||
public boolean verifyMessage(MimeMessage mimeMessage) {
|
|
||||||
if (!enabled) {
|
|
||||||
return true; // Если DKIM отключён, считаем все письма валидными
|
|
||||||
}
|
|
||||||
|
|
||||||
|
private static RSAPrivateKey readPrivateKey(String pem) throws IOException {
|
||||||
|
try (Reader reader = new StringReader(pem); PEMParser parser = new PEMParser(reader)) {
|
||||||
|
Object object = parser.readObject();
|
||||||
|
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
|
||||||
|
// PKCS#8 ("PRIVATE KEY") and the older PKCS#1 ("RSA PRIVATE KEY") written by earlier versions
|
||||||
|
if (object instanceof PEMKeyPair pair) {
|
||||||
|
return (RSAPrivateKey) converter.getKeyPair(pair).getPrivate();
|
||||||
|
}
|
||||||
|
if (object instanceof PrivateKeyInfo info) {
|
||||||
|
return (RSAPrivateKey) converter.getPrivateKey(info);
|
||||||
|
}
|
||||||
|
throw new IOException("Not an RSA private key: " + (object == null ? "empty file" : object.getClass().getSimpleName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] publicKeyOf(RSAPrivateKey key) throws Exception {
|
||||||
|
RSAPrivateCrtKey crt = (RSAPrivateCrtKey) key;
|
||||||
|
return KeyFactory.getInstance("RSA")
|
||||||
|
.generatePublic(new RSAPublicKeySpec(crt.getModulus(), crt.getPublicExponent()))
|
||||||
|
.getEncoded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void restrict(Path file) {
|
||||||
try {
|
try {
|
||||||
String dkimHeader = mimeMessage.getHeader("DKIM-Signature")[0];
|
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
|
||||||
if (dkimHeader == null || dkimHeader.isEmpty()) {
|
} catch (UnsupportedOperationException | IOException ignored) {
|
||||||
log.debug("DKIM: подпись не найдена в сообщении");
|
// not a POSIX file system
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлекаем домен из подписи
|
|
||||||
String domain = extractDkimValue(dkimHeader, "d=");
|
|
||||||
if (domain == null || domain.isEmpty()) {
|
|
||||||
log.warn("DKIM: не удалось извлечь домен из подписи");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// В реальном мире здесь мы бы:
|
|
||||||
// 1. Сделали DNS-запрос для _domainkey.<domain>
|
|
||||||
// 2. Получили публичный ключ
|
|
||||||
// 3. Проверили подпись
|
|
||||||
//
|
|
||||||
// Для локального сервера мы проверяем, что подпись присутствует
|
|
||||||
// и имеет корректный формат
|
|
||||||
String version = extractDkimValue(dkimHeader, "v=");
|
|
||||||
String algorithm = extractDkimValue(dkimHeader, "a=");
|
|
||||||
String selector = extractDkimValue(dkimHeader, "s=");
|
|
||||||
String signature = extractDkimValue(dkimHeader, "b=");
|
|
||||||
|
|
||||||
if (version == null || !version.equals("1")) {
|
|
||||||
log.warn("DKIM: неверная версия подписи");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (algorithm == null || !algorithm.startsWith("rsa-")) {
|
|
||||||
log.warn("DKIM: неподдерживаемый алгоритм: {}", algorithm);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signature == null || signature.isEmpty()) {
|
|
||||||
log.warn("DKIM: подпись пуста");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, что подпись — валидный Base64
|
|
||||||
try {
|
|
||||||
Base64.getDecoder().decode(signature);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("DKIM: подпись не является валидным Base64");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("DKIM: подпись найдена для домена {} (селектор: {})", domain, selector);
|
|
||||||
|
|
||||||
// Если письмо подписано нашим собственным ключом, проверяем полностью
|
|
||||||
if (domain.equals(this.domain) && privateKey != null) {
|
|
||||||
return verifyLocalSignature(mimeMessage, dkimHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Для внешних доменов проверяем только формат
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("DKIM: ошибка верификации", e);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Верифицирует подпись локального домена.
|
|
||||||
*/
|
|
||||||
private boolean verifyLocalSignature(MimeMessage mimeMessage, String dkimHeader) {
|
|
||||||
try {
|
|
||||||
String from = extractHeader(mimeMessage, "From");
|
|
||||||
String subject = extractHeader(mimeMessage, "Subject");
|
|
||||||
String date = extractHeader(mimeMessage, "Date");
|
|
||||||
String to = extractHeader(mimeMessage, "To");
|
|
||||||
String messageId = extractHeader(mimeMessage, "Message-ID");
|
|
||||||
String body = extractBody(mimeMessage);
|
|
||||||
String canonicalBody = canonicalizeBody(body);
|
|
||||||
|
|
||||||
StringBuilder headersToSign = new StringBuilder();
|
|
||||||
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
|
|
||||||
String[] headerValues = {from, subject, date, to, messageId};
|
|
||||||
|
|
||||||
for (int i = 0; i < headerNames.length; i++) {
|
|
||||||
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] bodyHash = hashBody(canonicalBody);
|
|
||||||
String b64BodyHash = extractDkimValue(dkimHeader, "bh=");
|
|
||||||
String b64Signature = extractDkimValue(dkimHeader, "b=");
|
|
||||||
|
|
||||||
// Проверяем хэш тела
|
|
||||||
if (b64BodyHash != null) {
|
|
||||||
byte[] claimedBodyHash = Base64.getDecoder().decode(b64BodyHash);
|
|
||||||
if (!MessageDigest.isEqual(bodyHash, claimedBodyHash)) {
|
|
||||||
log.warn("DKIM: хэш тела не совпадает");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем подпись
|
|
||||||
String headerCanon = headersToSign.toString();
|
|
||||||
byte[] signingInput = (headerCanon + "bh=" + b64BodyHash + "\rbh=" + b64BodyHash).getBytes();
|
|
||||||
|
|
||||||
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
|
|
||||||
signature.initVerify(publicKey);
|
|
||||||
signature.update(signingInput);
|
|
||||||
|
|
||||||
byte[] signatureBytes = Base64.getDecoder().decode(b64Signature);
|
|
||||||
boolean valid = signature.verify(signatureBytes);
|
|
||||||
|
|
||||||
if (valid) {
|
|
||||||
log.info("DKIM: подпись локального домена верифицирована");
|
|
||||||
} else {
|
|
||||||
log.warn("DKIM: подпись локального домена не верифицирована");
|
|
||||||
}
|
|
||||||
|
|
||||||
return valid;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("DKIM: ошибка верификации локальной подписи", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Извлекает значение параметра из DKIM-заголовка.
|
|
||||||
*/
|
|
||||||
private String extractDkimValue(String dkimHeader, String param) {
|
|
||||||
int index = dkimHeader.indexOf(param);
|
|
||||||
if (index == -1) return null;
|
|
||||||
|
|
||||||
int start = index + param.length();
|
|
||||||
int end = dkimHeader.indexOf(";", start);
|
|
||||||
if (end == -1) end = dkimHeader.length();
|
|
||||||
|
|
||||||
return dkimHeader.substring(start, end).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Извлекает заголовок из MIME-сообщения.
|
|
||||||
*/
|
|
||||||
private String extractHeader(MimeMessage mimeMessage, String name) throws MessagingException {
|
|
||||||
String[] headers = mimeMessage.getHeader(name);
|
|
||||||
if (headers == null || headers.length == 0) return "";
|
|
||||||
return headers[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Извлекает тело MIME-сообщения.
|
|
||||||
*/
|
|
||||||
private String extractBody(MimeMessage mimeMessage) throws MessagingException, IOException {
|
|
||||||
Object content = mimeMessage.getContent();
|
|
||||||
if (content instanceof String) {
|
|
||||||
return (String) content;
|
|
||||||
} else if (content instanceof jakarta.mail.internet.MimeMultipart) {
|
|
||||||
jakarta.mail.internet.MimeMultipart multipart = (jakarta.mail.internet.MimeMultipart) content;
|
|
||||||
StringBuilder body = new StringBuilder();
|
|
||||||
for (int i = 0; i < multipart.getCount(); i++) {
|
|
||||||
var part = multipart.getBodyPart(i);
|
|
||||||
String contentType = part.getContentType();
|
|
||||||
if (contentType != null && contentType.toLowerCase().contains("text")) {
|
|
||||||
Object partContent = part.getContent();
|
|
||||||
if (partContent instanceof String) {
|
|
||||||
body.append(partContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return body.toString();
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Канонизация тела сообщения (relaxed канонизация).
|
|
||||||
* Убирает лишние пробелы и переводы строк.
|
|
||||||
*/
|
|
||||||
private String canonicalizeBody(String body) {
|
|
||||||
if (body == null) return "";
|
|
||||||
// Relaxed: нормализуем CRLF в LF, убираем пробелы в конце строк
|
|
||||||
return body.replaceAll("\\r\\n", "\n")
|
|
||||||
.replaceAll("\\r", "\n")
|
|
||||||
.replaceAll(" +\n", "\n")
|
|
||||||
.replaceAll("\n+", "\n")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Вычисляет SHA-256 хэш тела сообщения.
|
|
||||||
*/
|
|
||||||
private byte[] hashBody(String body) throws NoSuchAlgorithmException {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
return digest.digest(body.getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает публичный ключ в формате DNS TXT-записи.
|
|
||||||
* Используется для публикации в DNS.
|
|
||||||
*/
|
|
||||||
public String getPublicKeyForDns() {
|
|
||||||
if (publicKeyBase64 == null) return null;
|
|
||||||
return "v=DKIM1; k=rsa; p=" + publicKeyBase64;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает домен DKIM.
|
|
||||||
*/
|
|
||||||
public String getDomain() {
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает селектор DKIM.
|
|
||||||
*/
|
|
||||||
public String getSelector() {
|
|
||||||
return DKIM_SELECTOR;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import com.mailserver.delivery.DeliveryWorker;
|
||||||
|
import com.mailserver.delivery.OutboundMessage;
|
||||||
|
import jakarta.mail.BodyPart;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.Multipart;
|
||||||
|
import jakarta.mail.Part;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import jakarta.mail.internet.MimeUtility;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a message in one of our mailboxes. Mail from outside is checked first — DKIM (signature),
|
||||||
|
* SPF (the connecting IP against the envelope sender's domain), DMARC (policy of the From domain);
|
||||||
|
* a failure marks it as spam. Mail from our own logged-in users is trusted.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LocalDelivery implements DeliveryWorker.BounceSender {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LocalDelivery.class);
|
||||||
|
|
||||||
|
private final MailService mailService;
|
||||||
|
private final DkimService dkimService;
|
||||||
|
private final SpfService spfService;
|
||||||
|
private final DmarcService dmarcService;
|
||||||
|
private final String hostname;
|
||||||
|
private final MessageSources sources;
|
||||||
|
|
||||||
|
public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService,
|
||||||
|
DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||||
|
MessageSources sources) {
|
||||||
|
this.sources = sources;
|
||||||
|
this.mailService = mailService;
|
||||||
|
this.dkimService = dkimService;
|
||||||
|
this.spfService = spfService;
|
||||||
|
this.dmarcService = dmarcService;
|
||||||
|
this.hostname = hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blocking: call from a worker thread. */
|
||||||
|
public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted)
|
||||||
|
throws MessagingException, IOException {
|
||||||
|
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
|
||||||
|
|
||||||
|
String senderEmail = envelopeFrom;
|
||||||
|
String senderName = null;
|
||||||
|
if (mime.getFrom() != null && mime.getFrom().length > 0 && mime.getFrom()[0] instanceof InternetAddress from) {
|
||||||
|
senderEmail = from.getAddress();
|
||||||
|
senderName = from.getPersonal();
|
||||||
|
}
|
||||||
|
String subject = mime.getSubject() == null ? "" : mime.getSubject();
|
||||||
|
Body body = body(mime);
|
||||||
|
|
||||||
|
boolean spam = false;
|
||||||
|
if (!trusted) {
|
||||||
|
DkimService.Verification dkim = dkimService.verify(raw);
|
||||||
|
String spfSender = envelopeFrom == null || envelopeFrom.isEmpty() ? senderEmail : envelopeFrom;
|
||||||
|
SpfService.SpfResult spf = spfService.checkSpf(spfSender, remoteIp);
|
||||||
|
boolean spfPassed = spf == SpfService.SpfResult.PASS;
|
||||||
|
DmarcService.DmarcResult dmarc = dmarcService.checkDmarc(senderEmail, remoteIp, spfPassed, dkim.passed(), dkim.domain());
|
||||||
|
spam = dmarcService.shouldMarkAsSpam(dmarc) || spf == SpfService.SpfResult.FAIL || (dkim.signed() && !dkim.passed());
|
||||||
|
log.info("Incoming {} -> {} from {}: DKIM {}, SPF {}, DMARC {}{}", senderEmail, mailbox, remoteIp,
|
||||||
|
dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
com.mailserver.domain.Message stored =
|
||||||
|
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam,
|
||||||
|
addresses(mime, jakarta.mail.Message.RecipientType.TO),
|
||||||
|
addresses(mime, jakarta.mail.Message.RecipientType.CC)).block();
|
||||||
|
if (stored != null) {
|
||||||
|
for (FilePart file : attachments(mime)) {
|
||||||
|
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
|
||||||
|
}
|
||||||
|
// IMAP serves the message exactly as it arrived
|
||||||
|
sources.put(stored.getId(), raw).block();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */
|
||||||
|
public static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) {
|
||||||
|
try {
|
||||||
|
jakarta.mail.Address[] list = mime.getRecipients(type);
|
||||||
|
if (list == null || list.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (jakarta.mail.Address address : list) {
|
||||||
|
result.add(address instanceof InternetAddress internet ? internet.getAddress() : address.toString());
|
||||||
|
}
|
||||||
|
String joined = String.join(", ", result);
|
||||||
|
return joined.length() > 5000 ? joined.substring(0, 5000) : joined;
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FilePart(String name, String contentType, byte[] bytes) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parts with a file name (attachments and inline images), nested multiparts walked. */
|
||||||
|
public static List<FilePart> attachments(Part part) throws MessagingException, IOException {
|
||||||
|
List<FilePart> files = new ArrayList<>();
|
||||||
|
collect(part, files);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collect(Part part, List<FilePart> files) throws MessagingException, IOException {
|
||||||
|
if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) {
|
||||||
|
for (int i = 0; i < multipart.getCount(); i++) {
|
||||||
|
collect(multipart.getBodyPart(i), files);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String name = part.getFileName();
|
||||||
|
if (name == null && !Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String decoded = name == null ? "file" : MimeUtility.decodeText(name);
|
||||||
|
String type = part.getContentType() == null ? "application/octet-stream" : part.getContentType().split(";")[0].trim();
|
||||||
|
try (InputStream in = part.getInputStream()) {
|
||||||
|
files.add(new FilePart(decoded, type.toLowerCase(java.util.Locale.ROOT), in.readAllBytes()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-delivery report into the sender's inbox — only when the sender is our mailbox. */
|
||||||
|
@Override
|
||||||
|
public void bounce(OutboundMessage message, String reason) {
|
||||||
|
String text = "Your message to " + message.getRcptTo() + " could not be delivered.\n\n"
|
||||||
|
+ "Reason: " + reason + "\n\nAttempts: " + message.getAttempts() + "\n";
|
||||||
|
try {
|
||||||
|
mailService.receiveMessage("mailer-daemon@" + hostname, "Mail Delivery System", message.getMailFrom(),
|
||||||
|
"Undelivered: message to " + message.getRcptTo(), text, false, false).block();
|
||||||
|
} catch (IllegalArgumentException notOurs) {
|
||||||
|
log.info("Bounce not stored: sender {} is not a local mailbox", message.getMailFrom());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Body(String text, boolean html) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */
|
||||||
|
public static Body body(Part part) throws MessagingException, IOException {
|
||||||
|
String html = find(part, "text/html");
|
||||||
|
if (html != null) {
|
||||||
|
return new Body(html, true);
|
||||||
|
}
|
||||||
|
String plain = find(part, "text/plain");
|
||||||
|
return new Body(plain == null ? "" : plain, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String find(Part part, String type) throws MessagingException, IOException {
|
||||||
|
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (part.isMimeType(type)) {
|
||||||
|
Object content = part.getContent();
|
||||||
|
if (content instanceof String s) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (content instanceof InputStream in) {
|
||||||
|
try (in) {
|
||||||
|
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) {
|
||||||
|
for (int i = 0; i < multipart.getCount(); i++) {
|
||||||
|
BodyPart child = multipart.getBodyPart(i);
|
||||||
|
String found = find(child, type);
|
||||||
|
if (found != null) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import com.mailserver.domain.Folder;
|
|||||||
import com.mailserver.domain.Message;
|
import com.mailserver.domain.Message;
|
||||||
import com.mailserver.domain.User;
|
import com.mailserver.domain.User;
|
||||||
import com.mailserver.dto.AttachmentResponse;
|
import com.mailserver.dto.AttachmentResponse;
|
||||||
|
import com.mailserver.dto.DraftRequest;
|
||||||
|
import com.mailserver.dto.FolderResponse;
|
||||||
|
import com.mailserver.dto.MessageUpdateRequest;
|
||||||
import com.mailserver.dto.MessageResponse;
|
import com.mailserver.dto.MessageResponse;
|
||||||
import com.mailserver.dto.SendRequest;
|
|
||||||
import com.mailserver.repository.AttachmentRepository;
|
import com.mailserver.repository.AttachmentRepository;
|
||||||
import com.mailserver.repository.ContactRepository;
|
import com.mailserver.repository.ContactRepository;
|
||||||
import com.mailserver.repository.FolderRepository;
|
import com.mailserver.repository.FolderRepository;
|
||||||
@@ -15,16 +17,14 @@ import com.mailserver.repository.MessageRepository;
|
|||||||
import com.mailserver.repository.UserRepository;
|
import com.mailserver.repository.UserRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import jakarta.mail.MessagingException;
|
|
||||||
import jakarta.mail.Session;
|
|
||||||
import jakarta.mail.internet.MimeMessage;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.publisher.Sinks;
|
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -33,11 +33,11 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import reactor.netty.Connection;
|
|
||||||
import reactor.netty.tcp.TcpClient;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MailService {
|
public class MailService {
|
||||||
@@ -50,113 +50,251 @@ public class MailService {
|
|||||||
private final AttachmentRepository attachmentRepository;
|
private final AttachmentRepository attachmentRepository;
|
||||||
private final ContactRepository contactRepository;
|
private final ContactRepository contactRepository;
|
||||||
private final SpamFilterService spamFilterService;
|
private final SpamFilterService spamFilterService;
|
||||||
private final DkimService dkimService;
|
|
||||||
|
|
||||||
@Value("${mailserver.attachments.dir:./attachments}")
|
@Value("${mailserver.attachments.dir:./attachments}")
|
||||||
private String attachmentsDir;
|
private String attachmentsDir;
|
||||||
|
|
||||||
@Value("${mailserver.smtp.port:3025}")
|
|
||||||
private int smtpPort;
|
|
||||||
|
|
||||||
@Value("${mailserver.smtp.hostname:localhost}")
|
|
||||||
private String smtpHostname;
|
|
||||||
|
|
||||||
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
|
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
|
||||||
UserRepository userRepository, AttachmentRepository attachmentRepository,
|
UserRepository userRepository, AttachmentRepository attachmentRepository,
|
||||||
ContactRepository contactRepository,
|
ContactRepository contactRepository,
|
||||||
SpamFilterService spamFilterService,
|
SpamFilterService spamFilterService, MessageSources sources) {
|
||||||
DkimService dkimService) {
|
this.sources = sources;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.folderRepository = folderRepository;
|
this.folderRepository = folderRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.attachmentRepository = attachmentRepository;
|
this.attachmentRepository = attachmentRepository;
|
||||||
this.contactRepository = contactRepository;
|
this.contactRepository = contactRepository;
|
||||||
this.spamFilterService = spamFilterService;
|
this.spamFilterService = spamFilterService;
|
||||||
this.dkimService = dkimService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<MessageResponse> sendMessage(String fromEmail, SendRequest request) {
|
/** System folders in the order the web client shows them; the rest — own folders, by name. */
|
||||||
return userRepository.findByEmail(fromEmail)
|
private final MessageSources sources;
|
||||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Sender not found: " + fromEmail)))
|
|
||||||
.flatMap(fromUser ->
|
public static final List<String> SYSTEM_FOLDERS = List.of("INBOX", "Drafts", "Sent", "Spam", "Trash");
|
||||||
folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
|
|
||||||
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
|
/** Messages per folder in one list — the web client sorts and pages them itself. */
|
||||||
.flatMap(sentFolder -> {
|
public static final int LIST_LIMIT = 1000;
|
||||||
Message message = new Message();
|
|
||||||
message.setFolderId(sentFolder.getId());
|
private static final int PREVIEW_CHARS = 200;
|
||||||
message.setSenderEmail(fromEmail);
|
|
||||||
message.setSenderName(fromUser.getDisplayName());
|
// --- Folders ---
|
||||||
message.setRecipientEmail(request.getTo());
|
|
||||||
message.setSubject(request.getSubject());
|
public Flux<FolderResponse> folders(Long userId) {
|
||||||
message.setBody(request.getBody());
|
return folderRepository.findByUserId(userId)
|
||||||
message.setHtml(request.isHtml());
|
.flatMap(folder -> Mono.zip(messageRepository.countByFolderId(folder.getId()),
|
||||||
message.setRead(true);
|
messageRepository.countUnreadByFolderId(folder.getId()))
|
||||||
message.setReceivedAt(Instant.now());
|
.map(counts -> new FolderResponse(folder.getId(), folder.getName(), folder.isSystem(),
|
||||||
message.setMessageId(UUID.randomUUID().toString());
|
counts.getT1(), counts.getT2())))
|
||||||
message.setMessageSize((long) (request.getSubject().length() + request.getBody().length()));
|
.sort(Comparator.comparingInt(MailService::folderOrder)
|
||||||
return messageRepository.save(message);
|
.thenComparing(folder -> folder.name().toLowerCase(Locale.ROOT)));
|
||||||
})
|
|
||||||
.flatMap(message ->
|
|
||||||
sendViaSmtp(fromEmail, request.getTo(), request.getSubject(), request.getBody(),
|
|
||||||
request.isHtml(), message.getMessageId())
|
|
||||||
.doOnSuccess(v -> log.info("Email sent via SMTP: {} -> {}", fromEmail, request.getTo()))
|
|
||||||
.onErrorResume(e -> {
|
|
||||||
log.error("SMTP send failed: {}", e.getMessage(), e);
|
|
||||||
return Mono.empty();
|
|
||||||
})
|
|
||||||
.thenReturn(message)
|
|
||||||
)
|
|
||||||
.map(this::toResponse)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
private static int folderOrder(FolderResponse folder) {
|
||||||
|
int index = SYSTEM_FOLDERS.indexOf(folder.name());
|
||||||
|
return folder.system() && index >= 0 ? index : SYSTEM_FOLDERS.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<FolderResponse> createFolder(Long userId, String rawName) {
|
||||||
|
String name = folderName(rawName);
|
||||||
|
return checkFolderNameFree(userId, name, null)
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
Folder folder = new Folder();
|
||||||
|
folder.setUserId(userId);
|
||||||
|
folder.setName(name);
|
||||||
|
folder.setCreatedAt(Instant.now());
|
||||||
|
return folderRepository.save(folder);
|
||||||
|
}))
|
||||||
|
.map(folder -> new FolderResponse(folder.getId(), folder.getName(), false, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> renameFolder(Long folderId, Long userId, String rawName) {
|
||||||
|
String name = folderName(rawName);
|
||||||
|
return ownFolder(folderId, userId)
|
||||||
|
.flatMap(folder -> checkFolderNameFree(userId, name, folder.getId())
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
folder.setName(name);
|
||||||
|
return folderRepository.save(folder);
|
||||||
|
})))
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An own folder: its messages go to the Trash, then the folder is removed. */
|
||||||
|
public Mono<Void> deleteFolder(Long folderId, Long userId) {
|
||||||
|
return ownFolder(folderId, userId)
|
||||||
|
.flatMap(folder -> systemFolder(userId, "Trash")
|
||||||
|
.flatMap(trash -> messageRepository.moveAll(folder.getId(), trash.getId()))
|
||||||
|
.then(folderRepository.delete(folder)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trash and Spam only: removes every message for good. */
|
||||||
|
public Mono<Integer> emptyFolder(Long folderId, Long userId) {
|
||||||
|
return ownedFolder(folderId, userId)
|
||||||
|
.filter(folder -> folder.isSystem() && ("Trash".equals(folder.getName()) || "Spam".equals(folder.getName())))
|
||||||
|
.switchIfEmpty(Mono.error(badRequest("Only Trash and Spam can be emptied")))
|
||||||
|
.flatMapMany(folder -> messageRepository.findByFolderId(folder.getId()))
|
||||||
|
.concatMap(message -> purge(message).thenReturn(1))
|
||||||
|
.reduce(0, Integer::sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String folderName(String rawName) {
|
||||||
|
String name = rawName == null ? "" : rawName.trim().replaceAll("\\s+", " ");
|
||||||
|
if (name.isEmpty() || name.length() > 50) {
|
||||||
|
throw badRequest("Folder name: 1 to 50 characters");
|
||||||
|
}
|
||||||
|
if (SYSTEM_FOLDERS.stream().anyMatch(name::equalsIgnoreCase)) {
|
||||||
|
throw badRequest("This name belongs to a system folder");
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> checkFolderNameFree(Long userId, String name, Long exceptId) {
|
||||||
|
return folderRepository.findByUserId(userId)
|
||||||
|
.filter(folder -> folder.getName().equalsIgnoreCase(name) && !folder.getId().equals(exceptId))
|
||||||
|
.hasElements()
|
||||||
|
.flatMap(taken -> taken ? Mono.error(badRequest("A folder with this name already exists")) : Mono.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Folder> ownFolder(Long folderId, Long userId) {
|
||||||
|
return ownedFolder(folderId, userId)
|
||||||
|
.filter(folder -> !folder.isSystem())
|
||||||
|
.switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Folder> systemFolder(Long userId, String name) {
|
||||||
|
return folderRepository.findByUserIdAndName(userId, name)
|
||||||
|
.switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Messages ---
|
||||||
|
|
||||||
|
/** Copy of a message we sent, in the sender's "Sent" folder. */
|
||||||
|
public Mono<Message> saveSent(User fromUser, String to, String cc, String subject, String body, boolean html, String messageId) {
|
||||||
|
return systemFolder(fromUser.getId(), "Sent")
|
||||||
|
.flatMap(sentFolder -> {
|
||||||
|
Message message = new Message();
|
||||||
|
message.setFolderId(sentFolder.getId());
|
||||||
|
message.setSenderEmail(fromUser.getEmail());
|
||||||
|
message.setSenderName(fromUser.getDisplayName());
|
||||||
|
message.setRecipientEmail(to);
|
||||||
|
message.setCcEmail(cc == null || cc.isBlank() ? null : cc);
|
||||||
|
message.setSubject(subject);
|
||||||
|
message.setBody(body);
|
||||||
|
message.setHtml(html);
|
||||||
|
message.setRead(true);
|
||||||
|
message.setReceivedAt(Instant.now());
|
||||||
|
message.setMessageId(messageId);
|
||||||
|
message.setMessageSize((long) (subject.length() + body.length()));
|
||||||
|
return messageRepository.save(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates or updates a draft of the user (only a message in their Drafts folder can be updated). */
|
||||||
|
public Mono<MessageResponse> saveDraft(User user, Long draftId, DraftRequest request) {
|
||||||
|
Mono<Message> target = draftId == null
|
||||||
|
? systemFolder(user.getId(), "Drafts").map(drafts -> {
|
||||||
|
Message message = new Message();
|
||||||
|
message.setFolderId(drafts.getId());
|
||||||
|
message.setRead(true);
|
||||||
|
message.setMessageId(UUID.randomUUID().toString());
|
||||||
|
return message;
|
||||||
|
})
|
||||||
|
: ownedDraft(draftId, user.getId());
|
||||||
|
return target.flatMap(message -> {
|
||||||
|
String subject = request.subject() == null ? "" : request.subject();
|
||||||
|
String body = request.body() == null ? "" : request.body();
|
||||||
|
message.setSenderEmail(user.getEmail());
|
||||||
|
message.setSenderName(user.getDisplayName());
|
||||||
|
message.setRecipientEmail(request.to() == null ? "" : request.to().trim());
|
||||||
|
message.setCcEmail(request.cc() == null || request.cc().isBlank() ? null : request.cc().trim());
|
||||||
|
message.setSubject(subject);
|
||||||
|
message.setBody(body);
|
||||||
|
message.setHtml(request.html());
|
||||||
|
message.setReceivedAt(Instant.now());
|
||||||
|
message.setMessageSize((long) (subject.length() + body.length()));
|
||||||
|
return messageRepository.save(message);
|
||||||
|
})
|
||||||
|
// IMAP: the draft changed — its MIME text is built anew
|
||||||
|
.flatMap(saved -> sources.remove(saved.getId()).thenReturn(saved))
|
||||||
|
.flatMap(this::withAttachmentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's draft, or 404. */
|
||||||
|
public Mono<Message> ownedDraft(Long draftId, Long userId) {
|
||||||
|
return ownedMessage(draftId, userId)
|
||||||
|
.filterWhen(message -> folderRepository.findById(message.getFolderId())
|
||||||
|
.map(folder -> folder.isSystem() && "Drafts".equals(folder.getName())))
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Draft not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After sending: the draft's attachments move to the copy in Sent, the draft itself is removed. */
|
||||||
|
public Mono<Void> finishDraft(Long draftId, Long sentMessageId) {
|
||||||
|
return attachmentRepository.moveAll(draftId, sentMessageId)
|
||||||
|
.then(sources.remove(sentMessageId))
|
||||||
|
.then(messageRepository.deleteById(draftId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||||
String subject, String body, boolean isHtml) {
|
String subject, String body, boolean isHtml) {
|
||||||
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false);
|
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> 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) {
|
||||||
return userRepository.findByEmail(recipientEmail)
|
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag, null, null);
|
||||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
|
|
||||||
.flatMap(recipient ->
|
|
||||||
folderRepository.findByUserIdAndName(recipient.getId(), "INBOX")
|
|
||||||
.switchIfEmpty(Mono.error(new IllegalStateException("INBOX not found")))
|
|
||||||
.flatMap(inbox ->
|
|
||||||
spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
|
|
||||||
.flatMap(isBlacklisted -> {
|
|
||||||
boolean isSpam = isBlacklisted || externalSpamFlag;
|
|
||||||
Message message = new Message();
|
|
||||||
message.setFolderId(inbox.getId());
|
|
||||||
message.setSenderEmail(senderEmail);
|
|
||||||
message.setSenderName(senderName);
|
|
||||||
message.setRecipientEmail(recipientEmail);
|
|
||||||
message.setSubject(subject);
|
|
||||||
message.setBody(body);
|
|
||||||
message.setHtml(isHtml);
|
|
||||||
message.setRead(false);
|
|
||||||
message.setSpam(isSpam);
|
|
||||||
message.setReceivedAt(Instant.now());
|
|
||||||
message.setMessageId(UUID.randomUUID().toString());
|
|
||||||
message.setMessageSize((long) (subject.length() + body.length()));
|
|
||||||
return messageRepository.save(message)
|
|
||||||
.then(folderRepository.save(inbox));
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<MessageResponse> getMessages(Long folderId, Long userId, int page, int size) {
|
/**
|
||||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
* Stores an incoming message: into INBOX, or into Spam when it failed the checks or the sender is blacklisted.
|
||||||
return messageRepository.findByFolderId(folderId, pageable)
|
* {@code toHeader}/{@code ccHeader}: addresses from the message headers (shown, used by "reply all");
|
||||||
.map(this::toResponse);
|
* without them — the mailbox itself.
|
||||||
|
*/
|
||||||
|
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||||
|
String subject, String body, boolean isHtml, boolean externalSpamFlag,
|
||||||
|
String toHeader, String ccHeader) {
|
||||||
|
return userRepository.findByEmail(recipientEmail)
|
||||||
|
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
|
||||||
|
.flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
|
||||||
|
.flatMap(blacklisted -> {
|
||||||
|
boolean isSpam = blacklisted || externalSpamFlag;
|
||||||
|
return systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
|
||||||
|
.flatMap(folder -> {
|
||||||
|
Message message = new Message();
|
||||||
|
message.setFolderId(folder.getId());
|
||||||
|
message.setSenderEmail(senderEmail);
|
||||||
|
message.setSenderName(senderName);
|
||||||
|
message.setRecipientEmail(toHeader == null || toHeader.isBlank() ? recipientEmail : toHeader);
|
||||||
|
message.setCcEmail(ccHeader == null || ccHeader.isBlank() ? null : ccHeader);
|
||||||
|
message.setSubject(subject);
|
||||||
|
message.setBody(body);
|
||||||
|
message.setHtml(isHtml);
|
||||||
|
message.setRead(false);
|
||||||
|
message.setSpam(isSpam);
|
||||||
|
message.setReceivedAt(Instant.now());
|
||||||
|
message.setMessageId(UUID.randomUUID().toString());
|
||||||
|
message.setMessageSize((long) (subject.length() + body.length()));
|
||||||
|
return messageRepository.save(message);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest first; bodies cut to a preview — the full message comes from {@link #getMessage}. */
|
||||||
|
public Flux<MessageResponse> listMessages(Long folderId, Long userId, int limit) {
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit, LIST_LIMIT));
|
||||||
|
return ownedFolder(folderId, userId)
|
||||||
|
.flatMapMany(folder -> messageRepository.findLatest(folder.getId(), safeLimit))
|
||||||
|
.concatMap(this::withAttachmentCount)
|
||||||
|
.map(MailService::preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<MessageResponse> searchMessages(Long folderId, Long userId, String keyword, int page, int size) {
|
public Flux<MessageResponse> searchMessages(Long folderId, Long userId, String keyword, int page, int size) {
|
||||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||||
return messageRepository.searchInFolder(folderId, keyword, pageable)
|
return ownedFolder(folderId, userId)
|
||||||
.map(this::toResponse);
|
.flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable))
|
||||||
|
.take(LIST_LIMIT)
|
||||||
|
.concatMap(this::withAttachmentCount)
|
||||||
|
.map(MailService::preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId,
|
public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId,
|
||||||
@@ -164,25 +302,125 @@ public class MailService {
|
|||||||
String sender, String recipient,
|
String sender, String recipient,
|
||||||
int page, int size) {
|
int page, int size) {
|
||||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||||
return messageRepository.searchWithFilters(folderId, subject, body, sender, recipient, pageable)
|
return ownedFolder(folderId, userId)
|
||||||
.map(this::toResponse);
|
.flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable))
|
||||||
|
.take(LIST_LIMIT)
|
||||||
|
.concatMap(this::withAttachmentCount)
|
||||||
|
.map(MailService::preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
|
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
|
||||||
return messageRepository.findById(messageId)
|
return ownedMessage(messageId, userId).flatMap(this::withAttachmentCount);
|
||||||
.map(this::toResponse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> markAsRead(Long messageId, Long userId) {
|
public Mono<Void> markAsRead(Long messageId, Long userId) {
|
||||||
return messageRepository.findById(messageId)
|
return updateMessage(messageId, userId, new MessageUpdateRequest(true, null, null)).then();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read/unread, flag, move to another folder of the user (moving into Spam marks it as spam, out of it — not). */
|
||||||
|
public Mono<MessageResponse> updateMessage(Long messageId, Long userId, MessageUpdateRequest update) {
|
||||||
|
return ownedMessage(messageId, userId)
|
||||||
.flatMap(message -> {
|
.flatMap(message -> {
|
||||||
message.setRead(true);
|
if (update.read() != null) {
|
||||||
return messageRepository.save(message).then();
|
message.setRead(update.read());
|
||||||
|
}
|
||||||
|
if (update.flagged() != null) {
|
||||||
|
message.setFlagged(update.flagged());
|
||||||
|
}
|
||||||
|
if (update.folderId() == null || update.folderId().equals(message.getFolderId())) {
|
||||||
|
return messageRepository.save(message);
|
||||||
|
}
|
||||||
|
return ownedFolder(update.folderId(), userId).flatMap(target -> {
|
||||||
|
message.setFolderId(target.getId());
|
||||||
|
message.setSpam(target.isSystem() && "Spam".equals(target.getName()));
|
||||||
|
return messageRepository.save(message);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.flatMap(this::withAttachmentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Into the Trash; a message already in the Trash is removed for good (with its attachment files). */
|
||||||
|
public Mono<Void> deleteMessage(Long messageId, Long userId) {
|
||||||
|
return ownedMessage(messageId, userId)
|
||||||
|
.flatMap(message -> folderRepository.findById(message.getFolderId())
|
||||||
|
.flatMap(folder -> {
|
||||||
|
if (folder.isSystem() && "Trash".equals(folder.getName())) {
|
||||||
|
return purge(message);
|
||||||
|
}
|
||||||
|
return systemFolder(userId, "Trash").flatMap(trash -> {
|
||||||
|
message.setFolderId(trash.getId());
|
||||||
|
return messageRepository.save(message).then();
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> purge(Message message) {
|
||||||
|
return attachmentRepository.findByMessageId(message.getId())
|
||||||
|
.doOnNext(attachment -> deleteFile(attachment.getFilePath()))
|
||||||
|
.then(messageRepository.delete(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteFile(String path) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(Paths.get(path));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Attachment file {} not deleted: {}", path, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes used by the mailbox: message texts plus attachments. */
|
||||||
|
public Mono<Long> usedBytes(Long userId) {
|
||||||
|
return Mono.zip(messageRepository.sumSizeByUserId(userId), attachmentRepository.sumSizeByUserId(userId))
|
||||||
|
.map(sizes -> sizes.getT1() + sizes.getT2());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder, when it belongs to the user; otherwise 404 — never someone else's mail. */
|
||||||
|
private Mono<Folder> ownedFolder(Long folderId, Long userId) {
|
||||||
|
return folderRepository.findById(folderId)
|
||||||
|
.filter(folder -> folder.getUserId().equals(userId))
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Folder not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Message> ownedMessage(Long messageId, Long userId) {
|
||||||
|
return messageRepository.findById(messageId)
|
||||||
|
.filterWhen(message -> folderRepository.findById(message.getFolderId())
|
||||||
|
.map(folder -> folder.getUserId().equals(userId))
|
||||||
|
.defaultIfEmpty(false))
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Message not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Attachment> ownedAttachment(Long attachmentId, Long userId) {
|
||||||
|
return attachmentRepository.findById(attachmentId)
|
||||||
|
.filterWhen(attachment -> ownedMessage(attachment.getMessageId(), userId).hasElement()
|
||||||
|
.onErrorReturn(false))
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<MessageResponse> withAttachmentCount(Message message) {
|
||||||
|
return attachmentRepository.countByMessageId(message.getId())
|
||||||
|
.defaultIfEmpty(0L)
|
||||||
|
.map(count -> {
|
||||||
|
MessageResponse response = toResponse(message);
|
||||||
|
response.setAttachmentCount(count.intValue());
|
||||||
|
response.setHasAttachments(count > 0);
|
||||||
|
return response;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> deleteMessage(Long messageId, Long userId) {
|
private static MessageResponse preview(MessageResponse response) {
|
||||||
return messageRepository.deleteById(messageId);
|
String body = response.getBody();
|
||||||
|
if (body == null) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
String text = response.isHtml() ? body.replaceAll("(?s)<style.*?</style>|<[^>]+>", " ") : body;
|
||||||
|
text = text.replace(" ", " ").replaceAll("\\s+", " ").trim();
|
||||||
|
response.setBody(text.length() > PREVIEW_CHARS ? text.substring(0, PREVIEW_CHARS) + "…" : text);
|
||||||
|
response.setHtml(false);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ResponseStatusException badRequest(String reason) {
|
||||||
|
return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
private MessageResponse toResponse(Message message) {
|
private MessageResponse toResponse(Message message) {
|
||||||
@@ -191,6 +429,7 @@ public class MailService {
|
|||||||
resp.setSenderEmail(message.getSenderEmail());
|
resp.setSenderEmail(message.getSenderEmail());
|
||||||
resp.setSenderName(message.getSenderName());
|
resp.setSenderName(message.getSenderName());
|
||||||
resp.setRecipientEmail(message.getRecipientEmail());
|
resp.setRecipientEmail(message.getRecipientEmail());
|
||||||
|
resp.setCcEmail(message.getCcEmail());
|
||||||
resp.setSubject(message.getSubject());
|
resp.setSubject(message.getSubject());
|
||||||
resp.setBody(message.getBody());
|
resp.setBody(message.getBody());
|
||||||
resp.setHtml(message.isHtml());
|
resp.setHtml(message.isHtml());
|
||||||
@@ -203,10 +442,30 @@ public class MailService {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stores a file of an incoming message (blocking: called from the SMTP worker thread). */
|
||||||
|
public void storeIncomingAttachment(Long messageId, String filename, String contentType, byte[] bytes) throws IOException {
|
||||||
|
String safeName = com.mailserver.controller.MailController.safeFilename(filename);
|
||||||
|
Path path = Paths.get(attachmentsDir, UUID.randomUUID() + "_" + safeName);
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
Files.write(path, bytes);
|
||||||
|
Attachment attachment = new Attachment();
|
||||||
|
attachment.setMessageId(messageId);
|
||||||
|
attachment.setFilename(safeName);
|
||||||
|
attachment.setContentType(contentType);
|
||||||
|
attachment.setSizeBytes((long) bytes.length);
|
||||||
|
attachment.setFilePath(path.toString());
|
||||||
|
attachmentRepository.save(attachment).block();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attachments of the user's draft, for building the message to send. */
|
||||||
|
public Flux<Attachment> draftAttachments(Long draftId, Long userId) {
|
||||||
|
return ownedDraft(draftId, userId).flatMapMany(draft -> attachmentRepository.findByMessageId(draft.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
|
public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
|
||||||
String originalFilename, String contentType,
|
String originalFilename, String contentType,
|
||||||
long sizeBytes, String filePath) {
|
long sizeBytes, String filePath) {
|
||||||
return messageRepository.findById(messageId)
|
return ownedDraft(messageId, userId)
|
||||||
.flatMap(message -> {
|
.flatMap(message -> {
|
||||||
Attachment attachment = new Attachment();
|
Attachment attachment = new Attachment();
|
||||||
attachment.setMessageId(messageId);
|
attachment.setMessageId(messageId);
|
||||||
@@ -214,7 +473,7 @@ public class MailService {
|
|||||||
attachment.setContentType(contentType);
|
attachment.setContentType(contentType);
|
||||||
attachment.setSizeBytes(sizeBytes);
|
attachment.setSizeBytes(sizeBytes);
|
||||||
attachment.setFilePath(filePath);
|
attachment.setFilePath(filePath);
|
||||||
return attachmentRepository.save(attachment);
|
return sources.remove(messageId).then(attachmentRepository.save(attachment));
|
||||||
})
|
})
|
||||||
.map(this::toAttachmentResponse);
|
.map(this::toAttachmentResponse);
|
||||||
}
|
}
|
||||||
@@ -223,32 +482,22 @@ public class MailService {
|
|||||||
return attachmentsDir;
|
return attachmentsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getAttachmentFilePath(Long attachmentId, Long userId) {
|
/** The attachment record (file path, name, type) — only the owner's. */
|
||||||
return attachmentRepository.findById(attachmentId)
|
public Mono<Attachment> getAttachment(Long attachmentId, Long userId) {
|
||||||
.map(Attachment::getFilePath)
|
return ownedAttachment(attachmentId, userId);
|
||||||
.block();
|
|
||||||
}
|
|
||||||
|
|
||||||
public AttachmentResponse getAttachmentInfo(Long attachmentId, Long userId) {
|
|
||||||
return attachmentRepository.findById(attachmentId)
|
|
||||||
.map(this::toAttachmentResponse)
|
|
||||||
.block();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<AttachmentResponse> getMessageAttachments(Long messageId, Long userId) {
|
public Flux<AttachmentResponse> getMessageAttachments(Long messageId, Long userId) {
|
||||||
return attachmentRepository.findByMessageId(messageId)
|
return ownedMessage(messageId, userId)
|
||||||
|
.flatMapMany(message -> attachmentRepository.findByMessageId(message.getId()))
|
||||||
.map(this::toAttachmentResponse);
|
.map(this::toAttachmentResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
|
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
|
||||||
return attachmentRepository.findById(attachmentId)
|
return ownedAttachment(attachmentId, userId)
|
||||||
.flatMap(attachment -> {
|
.flatMap(attachment -> {
|
||||||
try {
|
deleteFile(attachment.getFilePath());
|
||||||
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
|
return sources.remove(attachment.getMessageId()).then(attachmentRepository.delete(attachment));
|
||||||
} catch (IOException e) {
|
|
||||||
// Игнорируем
|
|
||||||
}
|
|
||||||
return attachmentRepository.delete(attachment);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,132 +510,6 @@ public class MailService {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Void> sendViaSmtp(String from, String to, String subject, String body,
|
|
||||||
boolean isHtml, String messageId) {
|
|
||||||
TcpClient client = TcpClient.create()
|
|
||||||
.host(smtpHostname)
|
|
||||||
.port(smtpPort)
|
|
||||||
.option(io.netty.channel.ChannelOption.SO_TIMEOUT, 30000)
|
|
||||||
.option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
|
|
||||||
|
|
||||||
Properties props = new Properties();
|
|
||||||
Session mailSession = Session.getDefaultInstance(props);
|
|
||||||
MimeMessage mimeMessage = new MimeMessage(mailSession);
|
|
||||||
try {
|
|
||||||
mimeMessage.setFrom(from);
|
|
||||||
mimeMessage.setRecipient(jakarta.mail.Message.RecipientType.TO, new jakarta.mail.internet.InternetAddress(to));
|
|
||||||
mimeMessage.setSubject(subject);
|
|
||||||
mimeMessage.setText(body, isHtml ? "text/html" : "text/plain", "UTF-8");
|
|
||||||
mimeMessage.setHeader("Message-ID", "<" + messageId + "@" + smtpHostname + ">");
|
|
||||||
mimeMessage.setSentDate(new java.util.Date());
|
|
||||||
|
|
||||||
dkimService.signMessage(mimeMessage);
|
|
||||||
|
|
||||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
||||||
mimeMessage.writeTo(baos);
|
|
||||||
String mimeContent = baos.toString(StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
StringBuilder dataBody = new StringBuilder();
|
|
||||||
String[] lines = mimeContent.split("\\r?\\n");
|
|
||||||
for (String mimeLine : lines) {
|
|
||||||
if (mimeLine.startsWith(".")) {
|
|
||||||
dataBody.append(".").append(mimeLine);
|
|
||||||
} else {
|
|
||||||
dataBody.append(mimeLine);
|
|
||||||
}
|
|
||||||
dataBody.append("\r\n");
|
|
||||||
}
|
|
||||||
dataBody.append(".\r\n");
|
|
||||||
|
|
||||||
// SMTP — последовательный протокол: команда -> ответ -> команда -> ответ
|
|
||||||
// connection.inbound().receive() можно подписать только ОДИН раз,
|
|
||||||
// поэтому используем Sinks.Many с directBestEffort для шаринга ответов
|
|
||||||
return client.connect()
|
|
||||||
.flatMap(connection -> {
|
|
||||||
// Sinks.Many — один поток чтения, много подписчиков через multicast
|
|
||||||
Sinks.Many<String> responseSink = Sinks.many().replay().limit(1);
|
|
||||||
|
|
||||||
// Подписываемся на inbound ОДИН раз и пишем все ответы в sink
|
|
||||||
connection.inbound()
|
|
||||||
.receive()
|
|
||||||
.asString(StandardCharsets.UTF_8)
|
|
||||||
.doOnNext(line -> {
|
|
||||||
String trimmed = line.trim();
|
|
||||||
log.debug("SMTP клиент ответ: {}", trimmed);
|
|
||||||
responseSink.tryEmitNext(trimmed);
|
|
||||||
})
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
// Собираем все команды в список для последовательной отправки
|
|
||||||
List<String> commands = new ArrayList<>();
|
|
||||||
commands.add("EHLO " + smtpHostname + "\r\n");
|
|
||||||
commands.add("MAIL FROM:<" + from + ">\r\n");
|
|
||||||
commands.add("RCPT TO:<" + to + ">\r\n");
|
|
||||||
commands.add("DATA\r\n");
|
|
||||||
commands.add(dataBody.toString());
|
|
||||||
commands.add("QUIT\r\n");
|
|
||||||
|
|
||||||
// Ожидаемые коды ответов для каждой команды (включая banner)
|
|
||||||
List<String> expectedCodes = new ArrayList<>();
|
|
||||||
expectedCodes.add("220"); // banner
|
|
||||||
expectedCodes.add("250"); // EHLO
|
|
||||||
expectedCodes.add("250"); // MAIL FROM
|
|
||||||
expectedCodes.add("250"); // RCPT TO
|
|
||||||
expectedCodes.add("354"); // DATA
|
|
||||||
expectedCodes.add("250"); // тело DATA (завершение)
|
|
||||||
expectedCodes.add("221"); // QUIT
|
|
||||||
|
|
||||||
// Последовательно отправляем команды и ждём ответы
|
|
||||||
Mono<Void> send = Mono.empty();
|
|
||||||
for (int i = 0; i < commands.size(); i++) {
|
|
||||||
final String cmd = commands.get(i);
|
|
||||||
final String expectedCode = expectedCodes.get(i);
|
|
||||||
|
|
||||||
send = send.then(sendAndAwait(connection, cmd, responseSink, expectedCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
return send.then(Mono.fromRunnable(() -> connection.dispose()));
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (MessagingException | IOException e) {
|
|
||||||
return Mono.error(new RuntimeException("Failed to prepare SMTP message", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Отправляет SMTP команду и ждёт ответ с указанным кодом.
|
|
||||||
* Для многострочных ответов (EHLO) читает до строки без '-' в конце кода.
|
|
||||||
*/
|
|
||||||
private Mono<Void> sendAndAwait(Connection connection, String command,
|
|
||||||
Sinks.Many<String> responseSink,
|
|
||||||
String expectedCode) {
|
|
||||||
log.debug("SMTP клиент команда: {}", command.trim());
|
|
||||||
|
|
||||||
// Отправляем команду
|
|
||||||
Mono<Void> send = connection.outbound()
|
|
||||||
.sendString(Mono.just(command))
|
|
||||||
.then();
|
|
||||||
|
|
||||||
// Ждём ответ из sink — каждый вызов получает свой Flux подписчика
|
|
||||||
Mono<Void> readResponse = responseSink.asFlux()
|
|
||||||
.takeWhile(line -> {
|
|
||||||
if (expectedCode != null && line.startsWith(expectedCode + "-")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.doOnNext(line -> {
|
|
||||||
if (expectedCode != null && !line.startsWith(expectedCode)) {
|
|
||||||
log.warn("SMTP неожиданный ответ (ожидалось {}): {}", expectedCode, line);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then()
|
|
||||||
.timeout(java.time.Duration.ofSeconds(30))
|
|
||||||
.onErrorResume(e -> Mono.error(new RuntimeException("SMTP response timeout", e)));
|
|
||||||
|
|
||||||
return send.then(readResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Contacts ---
|
// --- Contacts ---
|
||||||
|
|
||||||
public Flux<Contact> getContacts(Long userId) {
|
public Flux<Contact> getContacts(Long userId) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import org.springframework.r2dbc.core.DatabaseClient;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message exactly as received or appended (table message_sources). IMAP serves it as BODY[]; a message without
|
||||||
|
* one gets its MIME text built once ({@link com.mailserver.imap.MimeSource}). A changed draft loses it.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class MessageSources {
|
||||||
|
|
||||||
|
private final DatabaseClient db;
|
||||||
|
|
||||||
|
public MessageSources(DatabaseClient db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<byte[]> get(Long messageId) {
|
||||||
|
return db.sql("SELECT raw FROM message_sources WHERE message_id = :id")
|
||||||
|
.bind("id", messageId)
|
||||||
|
.map(row -> row.get("raw", byte[].class))
|
||||||
|
.one();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> put(Long messageId, byte[] raw) {
|
||||||
|
return db.sql("INSERT INTO message_sources (message_id, raw) VALUES (:id, :raw) "
|
||||||
|
+ "ON CONFLICT (message_id) DO UPDATE SET raw = EXCLUDED.raw")
|
||||||
|
.bind("id", messageId)
|
||||||
|
.bind("raw", raw)
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> remove(Long messageId) {
|
||||||
|
return db.sql("DELETE FROM message_sources WHERE message_id = :id").bind("id", messageId).then();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import com.mailserver.domain.Attachment;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.dto.MessageResponse;
|
||||||
|
import com.mailserver.dto.SendRequest;
|
||||||
|
import com.mailserver.repository.UserRepository;
|
||||||
|
import com.mailserver.smtp.MailRouter;
|
||||||
|
import com.mailserver.smtp.SmtpBackend;
|
||||||
|
import jakarta.mail.Message;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.internet.AddressException;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeBodyPart;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import jakarta.mail.internet.MimeMultipart;
|
||||||
|
import jakarta.mail.internet.MimeUtility;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
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 reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Send" in the web client: builds the MIME message (with the draft's attachments) and routes it like an
|
||||||
|
* authenticated SMTP submission — to every To and Cc address.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SubmissionService {
|
||||||
|
|
||||||
|
/** One message, at most this many addresses (To + Cc). */
|
||||||
|
static final int MAX_RECIPIENTS = 50;
|
||||||
|
|
||||||
|
private final UserRepository users;
|
||||||
|
private final MailService mailService;
|
||||||
|
private final MailRouter router;
|
||||||
|
private final String hostname;
|
||||||
|
|
||||||
|
public SubmissionService(UserRepository users, MailService mailService, MailRouter router,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
||||||
|
this.users = users;
|
||||||
|
this.mailService = mailService;
|
||||||
|
this.router = router;
|
||||||
|
this.hostname = hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
|
||||||
|
List<String> to = addresses(request.getTo());
|
||||||
|
List<String> cc = addresses(request.getCc());
|
||||||
|
cc.removeAll(to);
|
||||||
|
if (to.isEmpty()) {
|
||||||
|
return Mono.error(MailService.badRequest("No recipient"));
|
||||||
|
}
|
||||||
|
if (to.size() + cc.size() > MAX_RECIPIENTS) {
|
||||||
|
return Mono.error(MailService.badRequest("At most " + MAX_RECIPIENTS + " recipients"));
|
||||||
|
}
|
||||||
|
Long draftId = request.getDraftId();
|
||||||
|
return users.findByEmail(fromEmail)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||||
|
.flatMap(user -> (draftId == null ? Flux.<Attachment>empty() : mailService.draftAttachments(draftId, user.getId()))
|
||||||
|
.collectList()
|
||||||
|
.flatMap(attachments -> Mono.fromCallable(() -> submit(user, to, cc, request, attachments))
|
||||||
|
.subscribeOn(Schedulers.boundedElastic()))
|
||||||
|
.flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc),
|
||||||
|
request.getSubject(), request.getBody(), request.isHtml(), messageId))
|
||||||
|
.flatMap(sent -> (draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
|
||||||
|
.then(mailService.getMessage(sent.getId(), user.getId()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String submit(User user, List<String> to, List<String> cc, SendRequest request, List<Attachment> attachments)
|
||||||
|
throws Exception {
|
||||||
|
List<SmtpBackend.Recipient> recipients = new ArrayList<>();
|
||||||
|
for (String address : concat(to, cc)) {
|
||||||
|
SmtpBackend.Route route = router.route(address, user.getEmail());
|
||||||
|
switch (route) {
|
||||||
|
case LOCAL, OUTBOUND -> recipients.add(new SmtpBackend.Recipient(address, route));
|
||||||
|
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 RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String messageId = UUID.randomUUID() + "@" + hostname;
|
||||||
|
MimeMessage mime = mime(user, to, cc, request, attachments);
|
||||||
|
mime.setHeader("Message-ID", "<" + messageId + ">");
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
mime.writeTo(bytes);
|
||||||
|
router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(), recipients),
|
||||||
|
bytes.toByteArray());
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MimeMessage mime(User user, List<String> to, List<String> cc, SendRequest request, List<Attachment> attachments)
|
||||||
|
throws MessagingException, IOException {
|
||||||
|
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
|
||||||
|
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
|
||||||
|
mime.setRecipients(Message.RecipientType.TO, InternetAddress.parse(String.join(",", to)));
|
||||||
|
if (!cc.isEmpty()) {
|
||||||
|
mime.setRecipients(Message.RecipientType.CC, InternetAddress.parse(String.join(",", cc)));
|
||||||
|
}
|
||||||
|
mime.setSubject(request.getSubject(), "UTF-8");
|
||||||
|
String subtype = request.isHtml() ? "html" : "plain";
|
||||||
|
if (attachments.isEmpty()) {
|
||||||
|
mime.setText(request.getBody(), "UTF-8", subtype);
|
||||||
|
} else {
|
||||||
|
MimeMultipart mixed = new MimeMultipart("mixed");
|
||||||
|
MimeBodyPart text = new MimeBodyPart();
|
||||||
|
text.setText(request.getBody(), "UTF-8", subtype);
|
||||||
|
mixed.addBodyPart(text);
|
||||||
|
for (Attachment attachment : attachments) {
|
||||||
|
MimeBodyPart file = new MimeBodyPart();
|
||||||
|
file.attachFile(new File(attachment.getFilePath()),
|
||||||
|
attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType(), null);
|
||||||
|
file.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null));
|
||||||
|
mixed.addBodyPart(file);
|
||||||
|
}
|
||||||
|
mime.setContent(mixed);
|
||||||
|
}
|
||||||
|
mime.setSentDate(new Date());
|
||||||
|
mime.saveChanges();
|
||||||
|
return mime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "a@x, b@y; c@z" → lower-case addresses without repeats; a malformed one → 400. */
|
||||||
|
static List<String> addresses(String raw) {
|
||||||
|
Set<String> result = new LinkedHashSet<>();
|
||||||
|
if (raw == null) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
for (String part : raw.split("[,;\\s]+")) {
|
||||||
|
String address = part.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (address.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new InternetAddress(address, true).validate();
|
||||||
|
} catch (AddressException e) {
|
||||||
|
throw MailService.badRequest("Invalid address: " + address);
|
||||||
|
}
|
||||||
|
if (!address.contains("@")) {
|
||||||
|
throw MailService.badRequest("Invalid address: " + address);
|
||||||
|
}
|
||||||
|
result.add(address);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> concat(List<String> a, List<String> b) {
|
||||||
|
List<String> all = new ArrayList<>(a);
|
||||||
|
all.addAll(b);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package com.mailserver.service;
|
|||||||
|
|
||||||
import com.mailserver.domain.Folder;
|
import com.mailserver.domain.Folder;
|
||||||
import com.mailserver.domain.User;
|
import com.mailserver.domain.User;
|
||||||
import com.mailserver.dto.FolderResponse;
|
|
||||||
import com.mailserver.repository.FolderRepository;
|
import com.mailserver.repository.FolderRepository;
|
||||||
import com.mailserver.repository.UserRepository;
|
import com.mailserver.repository.UserRepository;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
@@ -35,18 +34,18 @@ public class UserService {
|
|||||||
User user = new User();
|
User user = new User();
|
||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
user.setPassword(passwordEncoder.encode(password));
|
user.setPassword(passwordEncoder.encode(password));
|
||||||
user.setDisplayName(displayName);
|
user.setDisplayName(displayName == null || displayName.isBlank() ? email.substring(0, email.indexOf('@')) : displayName.trim());
|
||||||
user.setCreatedAt(Instant.now());
|
user.setCreatedAt(Instant.now());
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
})
|
})
|
||||||
.flatMap(user -> {
|
.flatMap(user -> {
|
||||||
// Создаём стандартные папки
|
// Создаём стандартные папки
|
||||||
String[] defaultFolders = {"INBOX", "Sent", "Drafts", "Trash", "Spam"};
|
return Flux.fromIterable(MailService.SYSTEM_FOLDERS)
|
||||||
return Flux.fromArray(defaultFolders)
|
.concatMap(name -> {
|
||||||
.flatMap(name -> {
|
|
||||||
Folder folder = new Folder();
|
Folder folder = new Folder();
|
||||||
folder.setUserId(user.getId());
|
folder.setUserId(user.getId());
|
||||||
folder.setName(name);
|
folder.setName(name);
|
||||||
|
folder.setSystem(true);
|
||||||
folder.setCreatedAt(Instant.now());
|
folder.setCreatedAt(Instant.now());
|
||||||
return folderRepository.save(folder);
|
return folderRepository.save(folder);
|
||||||
})
|
})
|
||||||
@@ -66,15 +65,36 @@ public class UserService {
|
|||||||
return passwordEncoder.matches(rawPassword, user.getPassword());
|
return passwordEncoder.matches(rawPassword, user.getPassword());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<FolderResponse> getFolders(Long userId) {
|
/** New password, only with the current one. */
|
||||||
return folderRepository.findByUserId(userId)
|
public Mono<Void> changePassword(User user, String currentPassword, String newPassword) {
|
||||||
.map(f -> {
|
if (!checkPassword(user, currentPassword)) {
|
||||||
FolderResponse resp = new FolderResponse();
|
return Mono.error(MailService.badRequest("Current password is wrong"));
|
||||||
resp.setId(f.getId());
|
}
|
||||||
resp.setName(f.getName());
|
user.setPassword(passwordEncoder.encode(newPassword));
|
||||||
resp.setMessageCount(f.getMessageCount());
|
return userRepository.save(user).then();
|
||||||
resp.setUnreadCount(f.getUnreadCount());
|
}
|
||||||
return resp;
|
|
||||||
});
|
public Flux<User> all() {
|
||||||
|
return userRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<User> byId(Long id) {
|
||||||
|
return userRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Administrator sets a new password (Settings → Mailboxes): no current password needed. */
|
||||||
|
public Mono<User> setPassword(User user, String newPassword) {
|
||||||
|
user.setPassword(passwordEncoder.encode(newPassword));
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<User> setActive(User user, boolean active) {
|
||||||
|
user.setActive(active);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<User> updateDisplayName(User user, String displayName) {
|
||||||
|
user.setDisplayName(displayName.trim());
|
||||||
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.mailserver.smtp;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.delivery.OutboundQueue;
|
||||||
|
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.repository.UserRepository;
|
||||||
|
import com.mailserver.service.DkimService;
|
||||||
|
import com.mailserver.service.LocalDelivery;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox →
|
||||||
|
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MailRouter implements SmtpBackend {
|
||||||
|
|
||||||
|
private final MailDomains domains;
|
||||||
|
private final UserRepository users;
|
||||||
|
private final UserService userService;
|
||||||
|
private final LocalDelivery localDelivery;
|
||||||
|
private final OutboundQueue queue;
|
||||||
|
private final DkimService dkim;
|
||||||
|
private final SmtpDeliveryClient client;
|
||||||
|
|
||||||
|
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
|
||||||
|
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client) {
|
||||||
|
this.domains = domains;
|
||||||
|
this.users = users;
|
||||||
|
this.userService = userService;
|
||||||
|
this.localDelivery = localDelivery;
|
||||||
|
this.queue = queue;
|
||||||
|
this.dkim = dkim;
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean authenticate(String username, String password) {
|
||||||
|
User user = users.findByEmail(username.toLowerCase(Locale.ROOT)).block();
|
||||||
|
return user != null && user.isActive() && userService.checkPassword(user, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Route route(String recipient, String authenticatedUser) {
|
||||||
|
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
|
||||||
|
return Route.LOCAL;
|
||||||
|
}
|
||||||
|
if (domains.catchAll() != null) {
|
||||||
|
return Route.LOCAL;
|
||||||
|
}
|
||||||
|
if (domains.isLocal(recipient)) {
|
||||||
|
return Route.UNKNOWN_USER;
|
||||||
|
}
|
||||||
|
if (authenticatedUser == null) {
|
||||||
|
return Route.RELAY_DENIED;
|
||||||
|
}
|
||||||
|
return client.mode() == SmtpDeliveryClient.Mode.OFF ? Route.OUTBOUND_DISABLED : Route.OUTBOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accept(Envelope envelope, byte[] message) throws Exception {
|
||||||
|
boolean trusted = envelope.authenticatedUser() != null;
|
||||||
|
byte[] signed = null;
|
||||||
|
for (Recipient recipient : envelope.recipients()) {
|
||||||
|
switch (recipient.route()) {
|
||||||
|
case LOCAL -> {
|
||||||
|
String mailbox = mailbox(recipient.address());
|
||||||
|
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
|
||||||
|
}
|
||||||
|
case OUTBOUND -> {
|
||||||
|
if (signed == null) {
|
||||||
|
signed = dkim.sign(message);
|
||||||
|
}
|
||||||
|
queue.enqueue(envelope.mailFrom(), recipient.address(), signed).block();
|
||||||
|
}
|
||||||
|
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mailbox that stores mail to this address: its own if it exists here, otherwise the catch-all one. */
|
||||||
|
String mailbox(String address) {
|
||||||
|
if (domains.catchAll() == null || (domains.isLocal(address) && mailboxExists(address))) {
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
return domains.catchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean mailboxExists(String address) {
|
||||||
|
return Boolean.TRUE.equals(users.existsByEmail(address.toLowerCase(Locale.ROOT)).block());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.mailserver.smtp;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the SMTP server asks the rest of the application. All methods may block (database, DNS):
|
||||||
|
* the server calls them on a worker thread, never on the network event loop.
|
||||||
|
*/
|
||||||
|
public interface SmtpBackend {
|
||||||
|
|
||||||
|
/** Where a recipient goes, decided at RCPT TO. */
|
||||||
|
enum Route {
|
||||||
|
/** A mailbox of ours (or the catch-all mailbox) — store it. */
|
||||||
|
LOCAL,
|
||||||
|
/** Someone else's address, sender is logged in — queue for delivery. */
|
||||||
|
OUTBOUND,
|
||||||
|
/** Address of our domain that does not exist. */
|
||||||
|
UNKNOWN_USER,
|
||||||
|
/** Someone else's address and the sender is not logged in: we are not an open relay. */
|
||||||
|
RELAY_DENIED,
|
||||||
|
/** Someone else's address, outbound delivery is switched off on this server. */
|
||||||
|
OUTBOUND_DISABLED
|
||||||
|
}
|
||||||
|
|
||||||
|
record Recipient(String address, Route route) {
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
Route route(String recipient, String authenticatedUser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores or queues the message for every recipient. Returns normally only when the message is safe
|
||||||
|
* (stored or queued) — then the client gets 250. Any exception → 451, the client will retry.
|
||||||
|
*/
|
||||||
|
void accept(Envelope envelope, byte[] message) throws Exception;
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
package com.mailserver.smtp;
|
||||||
|
|
||||||
|
import com.mailserver.security.LoginGuard;
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.buffer.ByteBufUtil;
|
||||||
|
import io.netty.channel.ChannelHandlerContext;
|
||||||
|
import io.netty.channel.ChannelPipeline;
|
||||||
|
import io.netty.handler.codec.LineBasedFrameDecoder;
|
||||||
|
import io.netty.handler.codec.MessageToMessageDecoder;
|
||||||
|
import io.netty.handler.ssl.SslContext;
|
||||||
|
import io.netty.handler.ssl.SslHandler;
|
||||||
|
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import reactor.netty.Connection;
|
||||||
|
import reactor.netty.DisposableServer;
|
||||||
|
import reactor.netty.NettyOutbound;
|
||||||
|
import reactor.netty.tcp.TcpServer;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP server (RFC 5321) on Reactor Netty.
|
||||||
|
* <ul>
|
||||||
|
* <li>input is split into lines by {@link LineBasedFrameDecoder} — a network packet may hold half a line or many
|
||||||
|
* pipelined commands, both work;</li>
|
||||||
|
* <li>AUTH PLAIN / LOGIN against our mailboxes; someone else's address is accepted only from a logged-in sender
|
||||||
|
* (no open relay); a logged-in sender may use only his own address in MAIL FROM;</li>
|
||||||
|
* <li>the message is stored or queued before the client gets 250 — on failure 451 and the client retries;</li>
|
||||||
|
* <li>database and DNS work runs on a worker thread, not on the event loop.</li>
|
||||||
|
* </ul>
|
||||||
|
* TLS (RFC 3207 STARTTLS, or implicit TLS on a separate port, RFC 8314): with a {@code tls} context STARTTLS is
|
||||||
|
* offered; AUTH only over TLS, or without it when {@code authWithoutTls} is on (a closed network: VM, docker network).
|
||||||
|
* After STARTTLS the session starts over (new EHLO), and lines sent in plain text together with STARTTLS are not
|
||||||
|
* executed — the connection is closed (command injection, CVE-2011-0411).
|
||||||
|
*/
|
||||||
|
public class SmtpServer {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SmtpServer.class);
|
||||||
|
|
||||||
|
// RFC 5321 4.5.3.1.6: a text line is at most 1000 octets with CRLF; we allow more for sloppy clients
|
||||||
|
static final int MAX_LINE = 64 * 1024;
|
||||||
|
static final int MAX_RECIPIENTS = 100;
|
||||||
|
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
|
||||||
|
private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param tls certificate for STARTTLS / implicit TLS; null — no TLS
|
||||||
|
* @param implicitTls TLS from the first byte (port 465), no STARTTLS
|
||||||
|
*/
|
||||||
|
public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
|
||||||
|
Duration idleTimeout, SslContext tls, boolean implicitTls) {
|
||||||
|
|
||||||
|
public Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
|
||||||
|
Duration idleTimeout) {
|
||||||
|
this(host, port, hostname, maxMessageBytes, authWithoutTls, idleTimeout, null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line of input and whether it came over TLS. */
|
||||||
|
record Line(byte[] bytes, boolean tls) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks each line: decoded behind an SslHandler (encrypted) or not. */
|
||||||
|
private static final class LineTagger extends MessageToMessageDecoder<ByteBuf> {
|
||||||
|
@Override
|
||||||
|
protected void decode(ChannelHandlerContext ctx, ByteBuf frame, List<Object> out) {
|
||||||
|
out.add(new Line(ByteBufUtil.getBytes(frame), ctx.pipeline().get(SslHandler.class) != null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
private final SmtpBackend backend;
|
||||||
|
private final LoginGuard guard;
|
||||||
|
private DisposableServer server;
|
||||||
|
|
||||||
|
public SmtpServer(Settings settings, SmtpBackend backend) {
|
||||||
|
this(settings, backend, LoginGuard.disabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SmtpServer(Settings settings, SmtpBackend backend, LoginGuard guard) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.backend = backend;
|
||||||
|
this.guard = guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (settings.implicitTls() && settings.tls() == null) {
|
||||||
|
throw new IllegalStateException("Implicit TLS needs a certificate");
|
||||||
|
}
|
||||||
|
TcpServer tcp = TcpServer.create()
|
||||||
|
.host(settings.host())
|
||||||
|
.port(settings.port())
|
||||||
|
.doOnConnection(connection -> connection
|
||||||
|
.addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
|
||||||
|
.addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))
|
||||||
|
.addHandlerLast("smtp-tag", new LineTagger()));
|
||||||
|
if (settings.implicitTls()) {
|
||||||
|
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
|
||||||
|
}
|
||||||
|
server = tcp
|
||||||
|
.handle((inbound, outbound) -> {
|
||||||
|
Connection[] connection = {null};
|
||||||
|
String[] remoteIp = {"unknown"};
|
||||||
|
inbound.withConnection(c -> {
|
||||||
|
connection[0] = c;
|
||||||
|
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
|
||||||
|
remoteIp[0] = address.getAddress().getHostAddress();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Session session = new Session(remoteIp[0], settings.implicitTls());
|
||||||
|
Flux<String> replies = inbound.receiveObject().cast(Line.class)
|
||||||
|
.concatMap(session::onLine)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString());
|
||||||
|
return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n");
|
||||||
|
})
|
||||||
|
.takeUntil(reply -> session.closing);
|
||||||
|
// One reply at a time, each written before the next line is handled: after "220 Ready to start TLS"
|
||||||
|
// the TLS handler goes in only when the reply has left in plain text
|
||||||
|
return Flux.concat(Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies)
|
||||||
|
.concatMap(reply -> send(outbound, reply)
|
||||||
|
.then(Mono.defer(() -> reply == STARTTLS_READY ? startTls(connection[0], session) : Mono.<Void>empty())))
|
||||||
|
.then();
|
||||||
|
})
|
||||||
|
.bindNow(Duration.ofSeconds(10));
|
||||||
|
log.info("SMTP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
|
||||||
|
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mono<Void> send(NettyOutbound outbound, String reply) {
|
||||||
|
return outbound.sendString(Mono.just(reply), StandardCharsets.US_ASCII).then();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TLS handler in front, fresh line decoder (no plain-text leftovers mixed into encrypted input). */
|
||||||
|
private Mono<Void> startTls(Connection connection, Session session) {
|
||||||
|
ChannelPipeline pipeline = connection.channel().pipeline();
|
||||||
|
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
|
||||||
|
pipeline.replace("smtp-lines", "smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false));
|
||||||
|
pipeline.addFirst("smtp-tls", ssl);
|
||||||
|
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
|
||||||
|
if (future.isSuccess()) {
|
||||||
|
log.debug("SMTP {}: TLS {}", session.remoteIp, ssl.engine().getSession().getProtocol());
|
||||||
|
sink.success();
|
||||||
|
} else {
|
||||||
|
log.info("SMTP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
|
||||||
|
session.closing = true;
|
||||||
|
connection.dispose();
|
||||||
|
sink.success();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int port() {
|
||||||
|
return server.port();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (server != null) {
|
||||||
|
server.disposeNow(Duration.ofSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum State { COMMAND, DATA, AUTH_PLAIN, AUTH_LOGIN_USER, AUTH_LOGIN_PASSWORD }
|
||||||
|
|
||||||
|
private final class Session {
|
||||||
|
final String remoteIp;
|
||||||
|
boolean tls;
|
||||||
|
State state = State.COMMAND;
|
||||||
|
String helo;
|
||||||
|
boolean extended;
|
||||||
|
String authenticatedUser;
|
||||||
|
String pendingAuthUser;
|
||||||
|
String mailFrom;
|
||||||
|
final List<SmtpBackend.Recipient> recipients = new ArrayList<>();
|
||||||
|
final ByteArrayOutputStream data = new ByteArrayOutputStream();
|
||||||
|
boolean dataTooBig;
|
||||||
|
int badCommands;
|
||||||
|
int authFailures;
|
||||||
|
volatile boolean closing;
|
||||||
|
|
||||||
|
Session(String remoteIp, boolean tls) {
|
||||||
|
this.remoteIp = remoteIp;
|
||||||
|
this.tls = tls;
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<String> onLine(Line input) {
|
||||||
|
if (tls && !input.tls()) {
|
||||||
|
// Sent in plain text after STARTTLS, before the handshake: never execute it
|
||||||
|
return Mono.error(new IllegalStateException("plain-text input after STARTTLS"));
|
||||||
|
}
|
||||||
|
byte[] line = stripCr(input.bytes());
|
||||||
|
return switch (state) {
|
||||||
|
case DATA -> onDataLine(line);
|
||||||
|
case AUTH_PLAIN -> authPlain(ascii(line));
|
||||||
|
case AUTH_LOGIN_USER -> authLoginUser(ascii(line));
|
||||||
|
case AUTH_LOGIN_PASSWORD -> authLoginPassword(ascii(line));
|
||||||
|
case COMMAND -> onCommand(ascii(line));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> onCommand(String line) {
|
||||||
|
String[] parts = line.trim().split("\\s+", 2);
|
||||||
|
String verb = parts[0].toUpperCase(Locale.ROOT);
|
||||||
|
String arg = parts.length > 1 ? parts[1].trim() : "";
|
||||||
|
return switch (verb) {
|
||||||
|
case "EHLO", "HELO" -> hello(verb.equals("EHLO"), arg);
|
||||||
|
case "AUTH" -> auth(arg);
|
||||||
|
case "MAIL" -> mail(arg);
|
||||||
|
case "RCPT" -> rcpt(arg);
|
||||||
|
case "DATA" -> dataStart();
|
||||||
|
case "RSET" -> {
|
||||||
|
resetTransaction();
|
||||||
|
yield reply("250 2.0.0 OK");
|
||||||
|
}
|
||||||
|
case "NOOP" -> reply("250 2.0.0 OK");
|
||||||
|
case "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message");
|
||||||
|
case "STARTTLS" -> startTls(arg);
|
||||||
|
case "QUIT" -> {
|
||||||
|
closing = true;
|
||||||
|
yield reply("221 2.0.0 " + settings.hostname() + " Bye");
|
||||||
|
}
|
||||||
|
case "" -> reply("500 5.5.2 Empty command");
|
||||||
|
default -> bad("500 5.5.1 Command not recognized");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> hello(boolean ehlo, String arg) {
|
||||||
|
if (arg.isEmpty()) {
|
||||||
|
return reply("501 5.5.4 Hostname required");
|
||||||
|
}
|
||||||
|
helo = arg;
|
||||||
|
extended = ehlo;
|
||||||
|
resetTransaction();
|
||||||
|
if (!ehlo) {
|
||||||
|
return reply("250 " + settings.hostname());
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("250-").append(settings.hostname()).append(" Hello ").append(arg).append("\r\n");
|
||||||
|
sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n");
|
||||||
|
sb.append("250-8BITMIME\r\n");
|
||||||
|
sb.append("250-PIPELINING\r\n");
|
||||||
|
if (settings.tls() != null && !tls) {
|
||||||
|
sb.append("250-STARTTLS\r\n");
|
||||||
|
}
|
||||||
|
if (authAllowed()) {
|
||||||
|
sb.append("250-AUTH PLAIN LOGIN\r\n");
|
||||||
|
}
|
||||||
|
sb.append("250 ENHANCEDSTATUSCODES\r\n");
|
||||||
|
return Mono.just(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean authAllowed() {
|
||||||
|
return tls || settings.authWithoutTls();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> startTls(String arg) {
|
||||||
|
if (settings.tls() == null) {
|
||||||
|
return reply("454 4.7.0 TLS not available");
|
||||||
|
}
|
||||||
|
if (tls) {
|
||||||
|
return bad("503 5.5.1 TLS already active");
|
||||||
|
}
|
||||||
|
if (!arg.isEmpty()) {
|
||||||
|
return reply("501 5.5.4 Syntax: STARTTLS");
|
||||||
|
}
|
||||||
|
// RFC 3207 4.2: everything known about the client is forgotten, a new EHLO is expected
|
||||||
|
tls = true;
|
||||||
|
helo = null;
|
||||||
|
extended = false;
|
||||||
|
authenticatedUser = null;
|
||||||
|
resetTransaction();
|
||||||
|
return Mono.just(STARTTLS_READY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> auth(String arg) {
|
||||||
|
if (!authAllowed()) {
|
||||||
|
return reply("538 5.7.11 Encryption required for requested authentication mechanism");
|
||||||
|
}
|
||||||
|
if (helo == null || !extended) {
|
||||||
|
return reply("503 5.5.1 EHLO first");
|
||||||
|
}
|
||||||
|
if (authenticatedUser != null) {
|
||||||
|
return reply("503 5.5.1 Already authenticated");
|
||||||
|
}
|
||||||
|
if (mailFrom != null) {
|
||||||
|
return reply("503 5.5.1 AUTH not allowed during a mail transaction");
|
||||||
|
}
|
||||||
|
String[] parts = arg.split("\\s+", 2);
|
||||||
|
String mechanism = parts[0].toUpperCase(Locale.ROOT);
|
||||||
|
String initial = parts.length > 1 ? parts[1] : null;
|
||||||
|
if (mechanism.equals("PLAIN")) {
|
||||||
|
if (initial == null) {
|
||||||
|
state = State.AUTH_PLAIN;
|
||||||
|
return reply("334 ");
|
||||||
|
}
|
||||||
|
return authPlain(initial);
|
||||||
|
}
|
||||||
|
if (mechanism.equals("LOGIN")) {
|
||||||
|
if (initial != null) {
|
||||||
|
return authLoginUser(initial);
|
||||||
|
}
|
||||||
|
state = State.AUTH_LOGIN_USER;
|
||||||
|
return reply("334 " + b64("Username:"));
|
||||||
|
}
|
||||||
|
return reply("504 5.5.4 Unrecognized authentication type");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> authPlain(String response) {
|
||||||
|
state = State.COMMAND;
|
||||||
|
if (response.equals("*")) {
|
||||||
|
return reply("501 5.0.0 Authentication cancelled");
|
||||||
|
}
|
||||||
|
String decoded = unb64(response);
|
||||||
|
// authzid \0 authcid \0 password
|
||||||
|
String[] fields = decoded == null ? new String[0] : decoded.split("\0", -1);
|
||||||
|
if (fields.length != 3) {
|
||||||
|
return reply("501 5.5.2 Cannot decode AUTH PLAIN response");
|
||||||
|
}
|
||||||
|
return checkPassword(fields[1], fields[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> authLoginUser(String response) {
|
||||||
|
if (response.equals("*")) {
|
||||||
|
state = State.COMMAND;
|
||||||
|
return reply("501 5.0.0 Authentication cancelled");
|
||||||
|
}
|
||||||
|
pendingAuthUser = unb64(response);
|
||||||
|
if (pendingAuthUser == null) {
|
||||||
|
state = State.COMMAND;
|
||||||
|
return reply("501 5.5.2 Cannot decode username");
|
||||||
|
}
|
||||||
|
state = State.AUTH_LOGIN_PASSWORD;
|
||||||
|
return reply("334 " + b64("Password:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> authLoginPassword(String response) {
|
||||||
|
state = State.COMMAND;
|
||||||
|
String user = pendingAuthUser;
|
||||||
|
pendingAuthUser = null;
|
||||||
|
if (response.equals("*")) {
|
||||||
|
return reply("501 5.0.0 Authentication cancelled");
|
||||||
|
}
|
||||||
|
String password = unb64(response);
|
||||||
|
if (password == null) {
|
||||||
|
return reply("501 5.5.2 Cannot decode password");
|
||||||
|
}
|
||||||
|
return checkPassword(user, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> checkPassword(String user, String password) {
|
||||||
|
String username = user.trim().toLowerCase(Locale.ROOT);
|
||||||
|
Duration wait = guard.retryAfter(remoteIp, username);
|
||||||
|
if (!wait.isZero()) {
|
||||||
|
// Blocked: the password is not even checked, the connection is closed
|
||||||
|
log.warn("SMTP {}: login as {} refused, blocked for {}", remoteIp, username, LoginGuard.human(wait));
|
||||||
|
closing = true;
|
||||||
|
return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait));
|
||||||
|
}
|
||||||
|
return blocking(() -> backend.authenticate(username, password))
|
||||||
|
.flatMap(ok -> {
|
||||||
|
if (ok) {
|
||||||
|
guard.success(remoteIp, username);
|
||||||
|
authenticatedUser = username;
|
||||||
|
log.info("SMTP {}: authenticated as {}", remoteIp, username);
|
||||||
|
return Mono.just("235 2.7.0 Authentication successful\r\n");
|
||||||
|
}
|
||||||
|
guard.failure(remoteIp, username);
|
||||||
|
log.warn("SMTP {}: authentication failed for {}", remoteIp, username);
|
||||||
|
badCommands++;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> mail(String arg) {
|
||||||
|
if (helo == null) {
|
||||||
|
return reply("503 5.5.1 EHLO/HELO first");
|
||||||
|
}
|
||||||
|
if (mailFrom != null) {
|
||||||
|
return reply("503 5.5.1 Sender already specified");
|
||||||
|
}
|
||||||
|
if (!arg.regionMatches(true, 0, "FROM:", 0, 5)) {
|
||||||
|
return reply("501 5.5.4 Syntax: MAIL FROM:<address>");
|
||||||
|
}
|
||||||
|
String rest = arg.substring(5).trim();
|
||||||
|
String address = path(rest);
|
||||||
|
if (address == null) {
|
||||||
|
return reply("501 5.1.7 Bad sender address syntax");
|
||||||
|
}
|
||||||
|
Long size = sizeParam(rest);
|
||||||
|
if (size != null && size > settings.maxMessageBytes()) {
|
||||||
|
return reply("552 5.3.4 Message size exceeds fixed limit");
|
||||||
|
}
|
||||||
|
if (authenticatedUser != null && !address.isEmpty() && !address.equals(authenticatedUser)) {
|
||||||
|
return reply("553 5.7.1 Sender address not owned by authenticated user");
|
||||||
|
}
|
||||||
|
mailFrom = address;
|
||||||
|
return reply("250 2.1.0 Sender OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> rcpt(String arg) {
|
||||||
|
if (mailFrom == null) {
|
||||||
|
return reply("503 5.5.1 MAIL first");
|
||||||
|
}
|
||||||
|
if (!arg.regionMatches(true, 0, "TO:", 0, 3)) {
|
||||||
|
return reply("501 5.5.4 Syntax: RCPT TO:<address>");
|
||||||
|
}
|
||||||
|
String address = path(arg.substring(3).trim());
|
||||||
|
if (address == null || address.isEmpty() || address.indexOf('@') < 1) {
|
||||||
|
return reply("501 5.1.3 Bad recipient address syntax");
|
||||||
|
}
|
||||||
|
if (recipients.size() >= MAX_RECIPIENTS) {
|
||||||
|
return reply("452 4.5.3 Too many recipients");
|
||||||
|
}
|
||||||
|
String user = authenticatedUser;
|
||||||
|
return blocking(() -> backend.route(address, user))
|
||||||
|
.map(route -> switch (route) {
|
||||||
|
case LOCAL, OUTBOUND -> {
|
||||||
|
recipients.add(new SmtpBackend.Recipient(address, route));
|
||||||
|
yield "250 2.1.5 Recipient OK\r\n";
|
||||||
|
}
|
||||||
|
case UNKNOWN_USER -> "550 5.1.1 No such user here\r\n";
|
||||||
|
case RELAY_DENIED -> {
|
||||||
|
log.warn("SMTP {}: relay denied {} -> {}", remoteIp, mailFrom, address);
|
||||||
|
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";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> dataStart() {
|
||||||
|
if (mailFrom == null) {
|
||||||
|
return reply("503 5.5.1 MAIL first");
|
||||||
|
}
|
||||||
|
if (recipients.isEmpty()) {
|
||||||
|
return reply("554 5.5.1 No valid recipients");
|
||||||
|
}
|
||||||
|
state = State.DATA;
|
||||||
|
data.reset();
|
||||||
|
dataTooBig = false;
|
||||||
|
return reply("354 End data with <CR><LF>.<CR><LF>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> onDataLine(byte[] line) {
|
||||||
|
if (line.length == 1 && line[0] == '.') {
|
||||||
|
state = State.COMMAND;
|
||||||
|
if (dataTooBig) {
|
||||||
|
resetTransaction();
|
||||||
|
return reply("552 5.3.4 Message size exceeds fixed limit");
|
||||||
|
}
|
||||||
|
SmtpBackend.Envelope envelope = new SmtpBackend.Envelope(
|
||||||
|
remoteIp, helo, mailFrom, authenticatedUser, List.copyOf(recipients));
|
||||||
|
byte[] message = withReceivedHeader(data.toByteArray());
|
||||||
|
resetTransaction();
|
||||||
|
return blocking(() -> {
|
||||||
|
backend.accept(envelope, message);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(ok -> "250 2.0.0 Message accepted\r\n")
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.error("SMTP {}: message from {} not stored: {}", remoteIp, envelope.mailFrom(), e.toString());
|
||||||
|
return Mono.just("451 4.3.0 Message not stored, try again later\r\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!dataTooBig) {
|
||||||
|
int offset = line.length > 0 && line[0] == '.' ? 1 : 0; // dot-unstuffing (RFC 5321 4.5.2)
|
||||||
|
data.write(line, offset, line.length - offset);
|
||||||
|
data.write('\r');
|
||||||
|
data.write('\n');
|
||||||
|
if (data.size() > settings.maxMessageBytes()) {
|
||||||
|
dataTooBig = true;
|
||||||
|
data.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] withReceivedHeader(byte[] message) {
|
||||||
|
String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n"
|
||||||
|
+ "\tby " + settings.hostname() + " with " + (tls ? "ESMTPS" : "ESMTP") + (authenticatedUser != null ? "A" : "") + ";\r\n"
|
||||||
|
+ "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n";
|
||||||
|
byte[] header = received.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] all = new byte[header.length + message.length];
|
||||||
|
System.arraycopy(header, 0, all, 0, header.length);
|
||||||
|
System.arraycopy(message, 0, all, header.length, message.length);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetTransaction() {
|
||||||
|
mailFrom = null;
|
||||||
|
recipients.clear();
|
||||||
|
data.reset();
|
||||||
|
dataTooBig = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> bad(String text) {
|
||||||
|
if (++badCommands > MAX_BAD_COMMANDS) {
|
||||||
|
closing = true;
|
||||||
|
return reply("421 4.7.0 Too many errors, closing connection");
|
||||||
|
}
|
||||||
|
return reply(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mono<String> reply(String line) {
|
||||||
|
return Mono.just(line + "\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> Mono<T> blocking(java.util.concurrent.Callable<T> work) {
|
||||||
|
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "<a@b> SIZE=1" → "a@b"; "<>" → "" (null sender of bounces); malformed → null. */
|
||||||
|
static String path(String text) {
|
||||||
|
int start = text.indexOf('<');
|
||||||
|
int end = text.indexOf('>', start + 1);
|
||||||
|
if (start != 0 || end < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String address = text.substring(1, end).trim();
|
||||||
|
int colon = address.indexOf(':'); // source route "@a,@b:user@host" (RFC 5321 4.1.2) — ignored
|
||||||
|
if (address.startsWith("@") && colon > 0) {
|
||||||
|
address = address.substring(colon + 1);
|
||||||
|
}
|
||||||
|
if (address.contains(" ") || address.contains("<")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return address.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long sizeParam(String text) {
|
||||||
|
for (String param : text.substring(text.indexOf('>') + 1).trim().split("\\s+")) {
|
||||||
|
if (param.regionMatches(true, 0, "SIZE=", 0, 5)) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(param.substring(5));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] stripCr(byte[] line) {
|
||||||
|
if (line.length > 0 && line[line.length - 1] == '\r') {
|
||||||
|
byte[] copy = new byte[line.length - 1];
|
||||||
|
System.arraycopy(line, 0, copy, 0, copy.length);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ascii(byte[] line) {
|
||||||
|
return new String(line, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String b64(String text) {
|
||||||
|
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String unb64(String text) {
|
||||||
|
try {
|
||||||
|
return new String(Base64.getDecoder().decode(text.trim()), StandardCharsets.UTF_8);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.mailserver.smtp;
|
||||||
|
|
||||||
|
import io.netty.handler.ssl.SslContext;
|
||||||
|
import io.netty.handler.ssl.SslContextBuilder;
|
||||||
|
import org.bouncycastle.asn1.x500.X500Name;
|
||||||
|
import org.bouncycastle.asn1.x509.Extension;
|
||||||
|
import org.bouncycastle.asn1.x509.GeneralName;
|
||||||
|
import org.bouncycastle.asn1.x509.GeneralNames;
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||||
|
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate for SMTP TLS (STARTTLS and implicit TLS):
|
||||||
|
* <ul>
|
||||||
|
* <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 —
|
||||||
|
* enough for encryption between our own services; other servers deliver over it too (MX delivery does not
|
||||||
|
* check certificates, RFC 7435 opportunistic TLS).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class SmtpTls {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SmtpTls.class);
|
||||||
|
static final String CERT_FILE = "smtp-cert.pem";
|
||||||
|
static final String KEY_FILE = "smtp-key.pem";
|
||||||
|
static final Duration RELOAD_CHECK = Duration.ofMinutes(1);
|
||||||
|
|
||||||
|
private SmtpTls() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SslContext serverContext(String certFile, String keyFile, Path dir, String hostname) throws Exception {
|
||||||
|
boolean givenCert = certFile != null && !certFile.isBlank();
|
||||||
|
boolean givenKey = keyFile != null && !keyFile.isBlank();
|
||||||
|
if (givenCert != givenKey) {
|
||||||
|
throw new IllegalStateException("TLS: set both the certificate and the key file, or neither");
|
||||||
|
}
|
||||||
|
Path cert;
|
||||||
|
Path key;
|
||||||
|
if (givenCert) {
|
||||||
|
cert = Path.of(certFile);
|
||||||
|
key = Path.of(keyFile);
|
||||||
|
log.info("SMTP TLS: certificate {}", cert);
|
||||||
|
} else {
|
||||||
|
cert = dir.resolve(CERT_FILE);
|
||||||
|
key = dir.resolve(KEY_FILE);
|
||||||
|
if (!Files.exists(cert) || !Files.exists(key)) {
|
||||||
|
selfSigned(hostname, cert, key);
|
||||||
|
log.warn("SMTP TLS: created a self-signed certificate for {} in {}", hostname, dir);
|
||||||
|
} else {
|
||||||
|
log.info("SMTP TLS: self-signed certificate {}", cert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void selfSigned(String hostname, Path certFile, Path keyFile) throws Exception {
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048, new SecureRandom());
|
||||||
|
KeyPair pair = generator.generateKeyPair();
|
||||||
|
X500Name name = new X500Name("CN=" + hostname);
|
||||||
|
Instant now = Instant.now();
|
||||||
|
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name,
|
||||||
|
new BigInteger(64, new SecureRandom()), Date.from(now.minus(Duration.ofDays(1))),
|
||||||
|
Date.from(now.plus(Duration.ofDays(3650))), name, pair.getPublic());
|
||||||
|
builder.addExtension(Extension.subjectAlternativeName, false,
|
||||||
|
new GeneralNames(new GeneralName(GeneralName.dNSName, hostname)));
|
||||||
|
X509Certificate certificate = new JcaX509CertificateConverter()
|
||||||
|
.getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())));
|
||||||
|
|
||||||
|
Files.createDirectories(keyFile.toAbsolutePath().getParent());
|
||||||
|
write(keyFile, pem("PRIVATE KEY", pair.getPrivate().getEncoded()), true);
|
||||||
|
write(certFile, pem("CERTIFICATE", certificate.getEncoded()), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pem(String type, byte[] der) {
|
||||||
|
return "-----BEGIN " + type + "-----\n"
|
||||||
|
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der)
|
||||||
|
+ "\n-----END " + type + "-----\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void write(Path file, String text, boolean secret) throws IOException {
|
||||||
|
Files.writeString(file, text, StandardCharsets.US_ASCII);
|
||||||
|
if (secret) {
|
||||||
|
try {
|
||||||
|
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
|
||||||
|
} catch (UnsupportedOperationException ignored) {
|
||||||
|
// not a POSIX file system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Local development only (./gradlew bootRun --args='--spring.profiles.active=dev'): Postgres from docker-compose.yml
|
||||||
|
DB_PORT=5433
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
JWT_SECRET=dev-only-jwt-secret-not-for-any-real-server-0123456789
|
||||||
|
mailserver.domains=mailserver.local
|
||||||
|
mailserver.admin.email=admin@mailserver.local
|
||||||
|
mailserver.admin.password=admin123
|
||||||
|
mailserver.registration.enabled=true
|
||||||
|
mailserver.cors.origins=http://localhost:4200
|
||||||
|
mailserver.smtp.auth-without-tls=true
|
||||||
|
mailserver.dkim.domain=mailserver.local
|
||||||
|
mailserver.spf.enabled=false
|
||||||
|
mailserver.dmarc.enabled=false
|
||||||
|
logging.level.com.mailserver=DEBUG
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
spring.application.name=mail-server
|
|
||||||
|
|
||||||
server.port=8080
|
|
||||||
|
|
||||||
# R2DBC — реактивный доступ к PostgreSQL
|
|
||||||
spring.r2dbc.url=r2dbc:postgresql://localhost:5432/mailserver
|
|
||||||
spring.r2dbc.username=mail
|
|
||||||
spring.r2dbc.password=secret
|
|
||||||
|
|
||||||
# Flyway — миграции базы данных
|
|
||||||
spring.flyway.enabled=true
|
|
||||||
spring.flyway.url=jdbc:postgresql://localhost:5432/mailserver
|
|
||||||
spring.flyway.user=mail
|
|
||||||
spring.flyway.password=secret
|
|
||||||
spring.flyway.locations=classpath:db/migration
|
|
||||||
spring.flyway.baseline-on-migrate=true
|
|
||||||
|
|
||||||
# Вложения
|
|
||||||
mailserver.attachments.dir=./attachments
|
|
||||||
spring.webflux.multipart.max-file-size=10MB
|
|
||||||
spring.webflux.multipart.max-request-size=10MB
|
|
||||||
|
|
||||||
# SMTP-сервер для приёма входящих писем
|
|
||||||
mailserver.smtp.port=3025
|
|
||||||
mailserver.smtp.hostname=your-domain.com
|
|
||||||
|
|
||||||
# IMAP-сервер (отключён)
|
|
||||||
mailserver.imap.enabled=false
|
|
||||||
mailserver.imap.port=1143
|
|
||||||
mailserver.imap.hostname=localhost
|
|
||||||
|
|
||||||
# DKIM — криптографическая подпись писем
|
|
||||||
mailserver.dkim.enabled=false
|
|
||||||
mailserver.dkim.domain=your-domain.com
|
|
||||||
mailserver.dkim.keys-dir=./dkim-keys
|
|
||||||
|
|
||||||
# SPF — проверка отправителей через DNS
|
|
||||||
mailserver.spf.enabled=false
|
|
||||||
mailserver.spf.local-bypass=true
|
|
||||||
|
|
||||||
# DMARC — политика обработки писем, не прошедших SPF/DKIM
|
|
||||||
mailserver.dmarc.enabled=false
|
|
||||||
mailserver.dmarc.local-bypass=true
|
|
||||||
|
|
||||||
# SMTP релей — доставка писем на внешнюю почту
|
|
||||||
# Порт 25 — стандартный SMTP relay между серверами (VPS)
|
|
||||||
mailserver.relay.enabled=true
|
|
||||||
mailserver.relay.port=25
|
|
||||||
mailserver.relay.starttls=false
|
|
||||||
mailserver.relay.auth-enabled=false
|
|
||||||
mailserver.relay.username=
|
|
||||||
mailserver.relay.password=
|
|
||||||
|
|
||||||
logging.level.com.mailserver=INFO
|
|
||||||
@@ -1,54 +1,101 @@
|
|||||||
spring.application.name=mail-server
|
spring.application.name=mail-server
|
||||||
|
|
||||||
server.port=8080
|
server.port=8080
|
||||||
|
# behind a proxy (Jambotron Caddy): take scheme/host from X-Forwarded-*, otherwise the browser's own
|
||||||
|
# domain looks like another origin to CORS and every API call with an Origin header gets 403
|
||||||
|
server.forward-headers-strategy=framework
|
||||||
|
|
||||||
# R2DBC — реактивный доступ к PostgreSQL
|
# Secrets: environment variables, or files in a config tree (file name = variable name, e.g. /run/mail-secrets/DB_PASSWORD).
|
||||||
spring.r2dbc.url=r2dbc:postgresql://localhost:5432/mailserver
|
# No defaults — the server does not start without them (dev profile: application-dev.properties).
|
||||||
spring.r2dbc.username=mail
|
spring.config.import=optional:configtree:${MAILSERVER_SECRETS_DIR:/run/mail-secrets}/
|
||||||
spring.r2dbc.password=secret
|
|
||||||
|
|
||||||
# Flyway — миграции базы данных
|
# PostgreSQL: R2DBC for the app, JDBC for Flyway migrations
|
||||||
|
spring.r2dbc.url=r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mailserver}
|
||||||
|
spring.r2dbc.username=${DB_USER:mail}
|
||||||
|
spring.r2dbc.password=${DB_PASSWORD}
|
||||||
spring.flyway.enabled=true
|
spring.flyway.enabled=true
|
||||||
spring.flyway.url=jdbc:postgresql://localhost:5432/mailserver
|
spring.flyway.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mailserver}
|
||||||
spring.flyway.user=mail
|
spring.flyway.user=${DB_USER:mail}
|
||||||
spring.flyway.password=secret
|
spring.flyway.password=${DB_PASSWORD}
|
||||||
spring.flyway.locations=classpath:db/migration
|
spring.flyway.locations=classpath:db/migration
|
||||||
spring.flyway.baseline-on-migrate=true
|
spring.flyway.baseline-on-migrate=true
|
||||||
|
|
||||||
# Вложения
|
jwt.secret=${JWT_SECRET}
|
||||||
mailserver.attachments.dir=./attachments
|
|
||||||
|
# Mailboxes of these domains live here; catch-all: mail to any address without its own mailbox here goes into this one mailbox (mail trap), nothing goes outside
|
||||||
|
mailserver.domains=${MAIL_DOMAINS:localhost}
|
||||||
|
mailserver.catch-all=${MAIL_CATCH_ALL:}
|
||||||
|
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
|
||||||
|
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
|
||||||
|
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
|
||||||
|
mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
|
||||||
|
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
||||||
|
|
||||||
|
# Attachments
|
||||||
|
mailserver.attachments.dir=${MAIL_ATTACHMENTS_DIR:./attachments}
|
||||||
spring.webflux.multipart.max-file-size=10MB
|
spring.webflux.multipart.max-file-size=10MB
|
||||||
spring.webflux.multipart.max-request-size=10MB
|
spring.webflux.multipart.max-request-size=10MB
|
||||||
|
|
||||||
# SMTP-сервер для приёма входящих писем
|
# SMTP server (incoming mail and submission). AUTH without TLS only in a closed network (docker network, VM)
|
||||||
mailserver.smtp.port=3025
|
mailserver.smtp.port=${SMTP_PORT:3025}
|
||||||
mailserver.smtp.hostname=localhost
|
mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost}
|
||||||
|
mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false}
|
||||||
|
mailserver.smtp.max-message-bytes=26214400
|
||||||
|
# TLS (75.1): STARTTLS on the SMTP port; implicit TLS on SMTPS_PORT (465) if set. Certificate: MAIL_TLS_CERT + MAIL_TLS_KEY
|
||||||
|
# (PEM, PKCS#8 key), otherwise a self-signed one created once in MAIL_TLS_DIR. MAIL_TLS=false — no TLS
|
||||||
|
mailserver.tls.enabled=${MAIL_TLS:true}
|
||||||
|
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
|
||||||
|
mailserver.tls.key-file=${MAIL_TLS_KEY:}
|
||||||
|
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}
|
||||||
|
|
||||||
# IMAP-сервер для доступа к письмам
|
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
|
||||||
mailserver.imap.enabled=false
|
mailserver.outbound.mode=${OUTBOUND_MODE:off}
|
||||||
mailserver.imap.port=1143
|
mailserver.outbound.host=${OUTBOUND_HOST:}
|
||||||
mailserver.imap.hostname=localhost
|
mailserver.outbound.port=${OUTBOUND_PORT:587}
|
||||||
|
mailserver.outbound.username=${OUTBOUND_USERNAME:}
|
||||||
|
mailserver.outbound.password=${OUTBOUND_PASSWORD:}
|
||||||
|
mailserver.outbound.tls=${OUTBOUND_TLS:starttls}
|
||||||
|
|
||||||
# DKIM — криптографическая подпись писем
|
# IMAP (75.2): STARTTLS on IMAP_PORT, implicit TLS on IMAPS_PORT (993) if set; same certificate as SMTP.
|
||||||
mailserver.dkim.enabled=false
|
# LOGIN only inside TLS unless IMAP_AUTH_WITHOUT_TLS=true (a closed network only)
|
||||||
mailserver.dkim.domain=localhost
|
mailserver.imap.enabled=${IMAP_ENABLED:true}
|
||||||
mailserver.dkim.keys-dir=./dkim-keys
|
mailserver.imap.port=${IMAP_PORT:3143}
|
||||||
|
mailserver.imaps.port=${IMAPS_PORT:0}
|
||||||
|
mailserver.imap.auth-without-tls=${IMAP_AUTH_WITHOUT_TLS:false}
|
||||||
|
|
||||||
# SPF — проверка отправителей через DNS
|
# DKIM — signing of our mail; DNS record printed to the log at start
|
||||||
mailserver.spf.enabled=false
|
mailserver.dkim.enabled=${DKIM_ENABLED:true}
|
||||||
|
mailserver.dkim.domain=${DKIM_DOMAIN:localhost}
|
||||||
|
mailserver.dkim.selector=${DKIM_SELECTOR:mail}
|
||||||
|
mailserver.dkim.keys-dir=${DKIM_KEYS_DIR:./dkim-keys}
|
||||||
|
|
||||||
|
# SPF / DMARC for incoming mail
|
||||||
|
mailserver.spf.enabled=${SPF_ENABLED:true}
|
||||||
mailserver.spf.local-bypass=true
|
mailserver.spf.local-bypass=true
|
||||||
|
mailserver.dmarc.enabled=${DMARC_ENABLED:true}
|
||||||
# DMARC — политика обработки писем, не прошедших SPF/DKIM
|
|
||||||
mailserver.dmarc.enabled=false
|
|
||||||
mailserver.dmarc.local-bypass=true
|
mailserver.dmarc.local-bypass=true
|
||||||
|
|
||||||
# SMTP релей — доставка писем на внешнюю почту
|
# error responses carry the reason ("No such mailbox", "Sending outside is disabled") for the web client
|
||||||
# Порт 587 — submission с STARTTLS (для отправки через SMTP-сервер)
|
server.error.include-message=always
|
||||||
mailserver.relay.enabled=true
|
|
||||||
mailserver.relay.port=587
|
|
||||||
mailserver.relay.starttls=true
|
|
||||||
mailserver.relay.auth-enabled=false
|
|
||||||
mailserver.relay.username=
|
|
||||||
mailserver.relay.password=
|
|
||||||
|
|
||||||
logging.level.com.mailserver=DEBUG
|
management.endpoints.web.exposure.include=health
|
||||||
|
management.endpoint.health.show-details=never
|
||||||
|
|
||||||
|
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,15 @@
|
|||||||
|
-- Очередь отправки наружу: письмо хранится как готовые байты (уже с DKIM-подписью) до доставки
|
||||||
|
CREATE TABLE outbound_queue (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
mail_from VARCHAR(255) NOT NULL,
|
||||||
|
rcpt_to VARCHAR(255) NOT NULL,
|
||||||
|
message BYTEA NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_error VARCHAR(1000),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
finished_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_outbound_queue_due ON outbound_queue(next_attempt_at) WHERE status = 'PENDING';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Mail cabinet: several recipients and copy, system vs own folders
|
||||||
|
ALTER TABLE messages ALTER COLUMN recipient_email TYPE TEXT;
|
||||||
|
ALTER TABLE messages ADD COLUMN cc_email TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE folders ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
UPDATE folders SET is_system = TRUE WHERE name IN ('INBOX', 'Sent', 'Drafts', 'Trash', 'Spam');
|
||||||
|
CREATE UNIQUE INDEX idx_folders_user_name ON folders(user_id, LOWER(name));
|
||||||
|
|
||||||
|
-- the attachments entity always had content_id (inline images, cid:) — the column was missing
|
||||||
|
ALTER TABLE attachments ADD COLUMN content_id VARCHAR(255);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- IMAP (75.2): UIDs per folder, IMAP flags, the original message text
|
||||||
|
|
||||||
|
-- UIDVALIDITY: never changes for a folder; UIDNEXT: the next UID to give out
|
||||||
|
ALTER TABLE folders ADD COLUMN uid_validity BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM now())::BIGINT);
|
||||||
|
ALTER TABLE folders ADD COLUMN uid_next BIGINT NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- UID inside the folder; strictly increasing — a message moved into a folder gets a new, larger UID
|
||||||
|
ALTER TABLE messages ADD COLUMN uid BIGINT;
|
||||||
|
ALTER TABLE messages ADD COLUMN is_answered BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE messages ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
UPDATE messages m SET uid = numbered.n
|
||||||
|
FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY folder_id ORDER BY received_at, id) AS n FROM messages) numbered
|
||||||
|
WHERE m.id = numbered.id;
|
||||||
|
|
||||||
|
UPDATE folders f SET uid_next = COALESCE((SELECT MAX(uid) FROM messages WHERE folder_id = f.id), 0) + 1;
|
||||||
|
|
||||||
|
ALTER TABLE messages ALTER COLUMN uid SET NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX idx_messages_folder_uid ON messages(folder_id, uid);
|
||||||
|
|
||||||
|
-- The UID is given out by the database, so every path that stores or moves a message (SMTP, web client, IMAP)
|
||||||
|
-- gets it right without knowing about IMAP
|
||||||
|
CREATE FUNCTION assign_message_uid() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE folders SET uid_next = uid_next + 1 WHERE id = NEW.folder_id RETURNING uid_next - 1 INTO NEW.uid;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER messages_uid_on_insert BEFORE INSERT ON messages
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION assign_message_uid();
|
||||||
|
|
||||||
|
-- The web client saves the whole row (folder_id included) — only a real move gets a new UID
|
||||||
|
CREATE TRIGGER messages_uid_on_move BEFORE UPDATE OF folder_id ON messages
|
||||||
|
FOR EACH ROW WHEN (NEW.folder_id IS DISTINCT FROM OLD.folder_id) EXECUTE FUNCTION assign_message_uid();
|
||||||
|
|
||||||
|
-- The message exactly as received (SMTP) or appended (IMAP APPEND); for messages without it the server builds
|
||||||
|
-- the MIME text once from the stored fields and keeps it here, so sizes and bytes never change between fetches.
|
||||||
|
-- Removed when a draft changes.
|
||||||
|
CREATE TABLE message_sources (
|
||||||
|
message_id BIGINT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
raw BYTEA NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DataInitializerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatedPasswordsAreAppendedToAFileOnlyTheOwnerReads(@TempDir Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("data/initial-passwords.txt");
|
||||||
|
|
||||||
|
DataInitializer.appendPassword(file, "admin@mail.test", "secret-1");
|
||||||
|
DataInitializer.appendPassword(file, "trap@mail.test", "secret-2");
|
||||||
|
|
||||||
|
assertThat(Files.readAllLines(file)).hasSize(2);
|
||||||
|
assertThat(Files.readAllLines(file).get(0)).endsWith(" admin@mail.test secret-1");
|
||||||
|
assertThat(Files.readAllLines(file).get(1)).endsWith(" trap@mail.test secret-2");
|
||||||
|
assertThat(PosixFilePermissions.toString(Files.getPosixFilePermissions(file))).isEqualTo("rw-------");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import com.mailserver.smtp.SmtpTls;
|
||||||
|
import io.netty.handler.ssl.SslContextBuilder;
|
||||||
|
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.server.reactive.HttpHandler;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** https://mail.jambocheck.com: the web client over HTTPS with the mail certificate. */
|
||||||
|
class HttpsConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void servesTheWebAppOverTls(@TempDir Path dir) throws Exception {
|
||||||
|
MailServerConfig.SmtpTlsContext tls = new MailServerConfig.SmtpTlsContext(SmtpTls.serverContext("", "", dir, "localhost"));
|
||||||
|
HttpHandler handler = (request, response) -> {
|
||||||
|
response.setStatusCode(HttpStatus.OK);
|
||||||
|
return response.writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance
|
||||||
|
.wrap(("hello " + request.getURI().getScheme()).getBytes(StandardCharsets.UTF_8))));
|
||||||
|
};
|
||||||
|
io.netty.handler.ssl.SslContext trustAll = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build();
|
||||||
|
HttpsConfig.HttpsServer server = new HttpsConfig.HttpsServer(handler, tls, "127.0.0.1", 0);
|
||||||
|
server.start();
|
||||||
|
try {
|
||||||
|
assertThat(server.isRunning()).isTrue();
|
||||||
|
String body = HttpClient.create()
|
||||||
|
.secure(spec -> spec.sslContext(trustAll))
|
||||||
|
.get().uri("https://127.0.0.1:" + server.port() + "/")
|
||||||
|
.responseContent().aggregate().asString().block();
|
||||||
|
assertThat(body).isEqualTo("hello https");
|
||||||
|
} finally {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
assertThat(server.isRunning()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LoginGuardConfigTest {
|
||||||
|
|
||||||
|
private final LoginGuardConfig.TrustedProxyHeaderTransformer transformer = new LoginGuardConfig.TrustedProxyHeaderTransformer();
|
||||||
|
|
||||||
|
private ServerHttpRequest from(String peer) {
|
||||||
|
return MockServerHttpRequest.post("/api/login")
|
||||||
|
.remoteAddress(new InetSocketAddress(peer, 40000))
|
||||||
|
.header("X-Forwarded-For", "198.51.100.9")
|
||||||
|
.header("X-Forwarded-Proto", "https")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardedForIsBelievedFromAPrivateProxy() {
|
||||||
|
ServerHttpRequest request = transformer.apply(from("172.18.0.5"));
|
||||||
|
assertThat(request.getRemoteAddress().getHostString()).isEqualTo("198.51.100.9");
|
||||||
|
assertThat(request.getURI().getScheme()).isEqualTo("https");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardedForFromTheInternetIsDropped() {
|
||||||
|
ServerHttpRequest request = transformer.apply(from("203.0.113.7"));
|
||||||
|
assertThat(request.getRemoteAddress().getAddress().getHostAddress()).isEqualTo("203.0.113.7");
|
||||||
|
assertThat(request.getHeaders().containsKey("X-Forwarded-For")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void privateRanges() {
|
||||||
|
for (String ip : new String[]{"127.0.0.1", "10.30.0.13", "172.16.0.1", "192.168.1.1", "fd00::1", "::1", "fe80::1"}) {
|
||||||
|
assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isTrue();
|
||||||
|
}
|
||||||
|
for (String ip : new String[]{"103.109.235.154", "172.32.0.1", "2001:db8::1", "8.8.8.8"}) {
|
||||||
|
assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.dto.LoginRequest;
|
||||||
|
import com.mailserver.security.LoginGuard;
|
||||||
|
import com.mailserver.service.MailService;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import com.mailserver.util.JwtUtils;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class AuthenticationControllerTest {
|
||||||
|
|
||||||
|
private final UserService users = mock(UserService.class);
|
||||||
|
private final JwtUtils jwt = mock(JwtUtils.class);
|
||||||
|
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
|
||||||
|
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
|
||||||
|
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
|
||||||
|
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class));
|
||||||
|
|
||||||
|
private final User alice = new User();
|
||||||
|
|
||||||
|
AuthenticationControllerTest() {
|
||||||
|
alice.setEmail("alice@x.test");
|
||||||
|
alice.setPassword("hash");
|
||||||
|
when(users.findByEmail("alice@x.test")).thenReturn(Mono.just(alice));
|
||||||
|
when(users.findByEmail("ghost@x.test")).thenReturn(Mono.empty());
|
||||||
|
when(users.checkPassword(eq(alice), anyString())).thenAnswer(i -> "secret".equals(i.getArgument(1)));
|
||||||
|
when(jwt.generateToken(any())).thenReturn("token");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<?> login(String ip, String email, String password) {
|
||||||
|
LoginRequest request = new LoginRequest();
|
||||||
|
request.setEmail(email);
|
||||||
|
request.setPassword(password);
|
||||||
|
MockServerHttpRequest http = MockServerHttpRequest.post("/api/login")
|
||||||
|
.remoteAddress(new InetSocketAddress(ip, 40000)).build();
|
||||||
|
return controller.login(Mono.just(request), http).block();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() {
|
||||||
|
assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
|
||||||
|
assertThat(login("1.2.3.4", "alice@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
|
||||||
|
login("1.2.3.4", "alice@x.test", "y");
|
||||||
|
|
||||||
|
ResponseEntity<?> blocked = login("1.2.3.4", "alice@x.test", "secret");
|
||||||
|
assertThat(blocked.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
assertThat(blocked.getHeaders().getFirst("Retry-After")).isEqualTo("900");
|
||||||
|
assertThat(blocked.getBody()).isEqualTo(Map.of("error", "Too many failed logins, try again in 15 min"));
|
||||||
|
|
||||||
|
assertThat(login("5.6.7.8", "alice@x.test", "secret").getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blockedAttemptDoesNotCheckThePassword() {
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
login("1.2.3.4", "ghost@x.test", "x");
|
||||||
|
}
|
||||||
|
login("1.2.3.4", "alice@x.test", "secret");
|
||||||
|
verify(users, never()).checkPassword(any(), eq("secret"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MailControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attachmentNameCannotLeaveTheFolder() {
|
||||||
|
assertThat(MailController.safeFilename("../../etc/passwd")).isEqualTo("passwd");
|
||||||
|
assertThat(MailController.safeFilename("..\\..\\boot.ini")).isEqualTo("boot.ini");
|
||||||
|
assertThat(MailController.safeFilename("..")).isEqualTo("file");
|
||||||
|
assertThat(MailController.safeFilename("a\"b\r\n.txt")).isEqualTo("ab.txt");
|
||||||
|
assertThat(MailController.safeFilename(null)).isEqualTo("file");
|
||||||
|
assertThat(MailController.safeFilename("report 2026.pdf")).isEqualTo("report 2026.pdf");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailAdmins;
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.service.MailService;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/** Settings → Mailboxes: only administrators; mailboxes only in our domains; a generated password comes back once. */
|
||||||
|
class MailboxAdminControllerTest {
|
||||||
|
|
||||||
|
private final UserService users = mock(UserService.class);
|
||||||
|
private final MailService mail = mock(MailService.class);
|
||||||
|
private final MailDomains domains = new MailDomains("jambocheck.com", "");
|
||||||
|
private final MailAdmins admins = new MailAdmins("", "", domains);
|
||||||
|
private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class);
|
||||||
|
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt);
|
||||||
|
|
||||||
|
private final User admin = user(1L, "admin@jambocheck.com");
|
||||||
|
private final User alice = user(2L, "alice@jambocheck.com");
|
||||||
|
|
||||||
|
MailboxAdminControllerTest() {
|
||||||
|
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
|
||||||
|
when(users.all()).thenReturn(Flux.just(alice, admin));
|
||||||
|
when(users.byId(1L)).thenReturn(Mono.just(admin));
|
||||||
|
when(users.byId(2L)).thenReturn(Mono.just(alice));
|
||||||
|
when(users.byId(99L)).thenReturn(Mono.empty());
|
||||||
|
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0))));
|
||||||
|
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
|
||||||
|
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
|
||||||
|
User u = i.getArgument(0);
|
||||||
|
u.setActive(i.getArgument(1));
|
||||||
|
return Mono.just(u);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administratorIsTheFirstMailboxByDefault() {
|
||||||
|
assertThat(admins.isAdmin("ADMIN@jambocheck.com")).isTrue();
|
||||||
|
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
|
||||||
|
assertThat(new MailAdmins("ops@jambocheck.com, root@jambocheck.com", "admin@jambocheck.com", domains).isAdmin("admin@jambocheck.com")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsMailboxesSortedForAnAdministratorOnly() {
|
||||||
|
List<MailboxAdminController.MailboxView> list = controller.list(as("admin@jambocheck.com")).collectList().block();
|
||||||
|
assertThat(list).extracting(MailboxAdminController.MailboxView::email).containsExactly("admin@jambocheck.com", "alice@jambocheck.com");
|
||||||
|
assertThat(list.get(0).admin()).isTrue();
|
||||||
|
|
||||||
|
assertThat(status(() -> controller.list(as("alice@jambocheck.com")).collectList().block())).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsReservedNamesWithAGeneratedPasswordShownOnce() {
|
||||||
|
var created = controller.create(as("admin@jambocheck.com"),
|
||||||
|
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", ""))).block();
|
||||||
|
|
||||||
|
assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
|
||||||
|
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
|
||||||
|
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
|
||||||
|
|
||||||
|
var chosen = controller.create(as("admin@jambocheck.com"),
|
||||||
|
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1"))).block();
|
||||||
|
assertThat(chosen.password()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesOtherDomainsBadAddressesShortPasswordsAndNonAdmins() {
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "x@gmail.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "bad address@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "x@jambocheck.com", "short"))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(status(() -> create("alice@jambocheck.com", "x@jambocheck.com", ""))).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
verify(users, never()).register(anyString(), anyString(), any());
|
||||||
|
|
||||||
|
when(users.register(eq("alice@jambocheck.com"), anyString(), any())).thenReturn(Mono.error(new IllegalArgumentException("exists")));
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "alice@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newPasswordAndSwitchOffButNotYourself() {
|
||||||
|
var reset = controller.password(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.PasswordRequest(null))).block();
|
||||||
|
assertThat(reset.password()).isNotBlank();
|
||||||
|
verify(users).setPassword(alice, reset.password());
|
||||||
|
|
||||||
|
assertThat(controller.active(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block().active()).isFalse();
|
||||||
|
assertThat(status(() -> controller.active(as("admin@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block()))
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administratorOpensAnyMailboxOthersCanNot() {
|
||||||
|
when(jwt.generateToken("alice@jambocheck.com")).thenReturn("alice-token");
|
||||||
|
|
||||||
|
var session = controller.open(as("admin@jambocheck.com"), 2L).block();
|
||||||
|
assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com");
|
||||||
|
assertThat(session.getToken()).isEqualTo("alice-token");
|
||||||
|
|
||||||
|
assertThat(status(() -> controller.open(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
assertThat(status(() -> controller.open(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MailboxAdminController.Created create(String me, String email, String password) {
|
||||||
|
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mono<Authentication> as(String email) {
|
||||||
|
return Mono.just(new UsernamePasswordAuthenticationToken(email, null, List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpStatus status(Runnable call) {
|
||||||
|
Throwable error = org.assertj.core.api.Assertions.catchThrowable(call::run);
|
||||||
|
assertThat(error).isInstanceOf(ResponseStatusException.class);
|
||||||
|
return HttpStatus.valueOf(((ResponseStatusException) error).getStatusCode().value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static User user(Long id, String email) {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(id);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setPassword("hash");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.mailserver.delivery;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class DeliveryWorkerTest {
|
||||||
|
|
||||||
|
private OutboundRepository repository;
|
||||||
|
private final List<String> bounces = new ArrayList<>();
|
||||||
|
private SmtpDeliveryClient.Result next;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository = mock(OutboundRepository.class);
|
||||||
|
when(repository.save(any())).thenAnswer(call -> Mono.just(call.getArgument(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeliveryWorker worker() {
|
||||||
|
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX,
|
||||||
|
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx", 1000, 25), domain -> List.of("mx")) {
|
||||||
|
@Override
|
||||||
|
public Result deliver(String from, String recipient, byte[] message) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return new DeliveryWorker(repository, client, (message, reason) -> bounces.add(message.getRcptTo() + ": " + reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OutboundMessage queued() {
|
||||||
|
OutboundMessage message = new OutboundMessage();
|
||||||
|
message.setId(1L);
|
||||||
|
message.setMailFrom("alice@ours.test");
|
||||||
|
message.setRcptTo("bob@theirs.test");
|
||||||
|
message.setMessage(new byte[]{1});
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deliveredIsDone() {
|
||||||
|
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.DELIVERED, "mx");
|
||||||
|
OutboundMessage message = queued();
|
||||||
|
worker().attempt(message, Instant.EPOCH);
|
||||||
|
assertThat(message.getStatus()).isEqualTo(OutboundMessage.DELIVERED);
|
||||||
|
assertThat(message.getLastError()).isNull();
|
||||||
|
assertThat(bounces).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void temporaryFailureIsRetriedWithGrowingPausesThenBounced() {
|
||||||
|
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.TEMPORARY, "451 greylisted");
|
||||||
|
OutboundMessage message = queued();
|
||||||
|
DeliveryWorker worker = worker();
|
||||||
|
Instant now = Instant.EPOCH;
|
||||||
|
List<Duration> pauses = new ArrayList<>();
|
||||||
|
for (int i = 0; i < DeliveryWorker.RETRY_PAUSES.size(); i++) {
|
||||||
|
worker.attempt(message, now);
|
||||||
|
assertThat(message.getStatus()).isEqualTo(OutboundMessage.PENDING);
|
||||||
|
pauses.add(Duration.between(now, message.getNextAttemptAt()));
|
||||||
|
now = message.getNextAttemptAt();
|
||||||
|
}
|
||||||
|
assertThat(pauses).isEqualTo(DeliveryWorker.RETRY_PAUSES);
|
||||||
|
|
||||||
|
worker.attempt(message, now);
|
||||||
|
assertThat(message.getStatus()).isEqualTo(OutboundMessage.FAILED);
|
||||||
|
assertThat(bounces).singleElement().asString().contains("gave up after 8 attempts").contains("451 greylisted");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void permanentFailureBouncesAtOnce() {
|
||||||
|
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.PERMANENT, "550 no such user");
|
||||||
|
OutboundMessage message = queued();
|
||||||
|
worker().attempt(message, Instant.EPOCH);
|
||||||
|
assertThat(message.getStatus()).isEqualTo(OutboundMessage.FAILED);
|
||||||
|
assertThat(message.getLastError()).isEqualTo("550 no such user");
|
||||||
|
assertThat(bounces).containsExactly("bob@theirs.test: 550 no such user");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.mailserver.delivery;
|
||||||
|
|
||||||
|
import com.icegreen.greenmail.util.GreenMail;
|
||||||
|
import com.icegreen.greenmail.util.ServerSetup;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SmtpDeliveryClientTest {
|
||||||
|
|
||||||
|
private static final String MESSAGE = "From: alice@ours.test\r\nTo: bob@theirs.test\r\nSubject: Hi\r\n"
|
||||||
|
+ "DKIM-Signature: v=1; a=rsa-sha256; d=ours.test; s=mail; h=from; bh=x; b=y\r\n\r\nbody\r\n.dot line\r\n";
|
||||||
|
|
||||||
|
private GreenMail greenMail;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void start() {
|
||||||
|
greenMail = new GreenMail(new ServerSetup(0, "127.0.0.1", ServerSetup.PROTOCOL_SMTP));
|
||||||
|
greenMail.setUser("relay-user", "relay-pass");
|
||||||
|
greenMail.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
greenMail.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int port() {
|
||||||
|
return greenMail.getSmtp().getPort();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SmtpDeliveryClient mxClient(int mxPort, List<String> hosts) {
|
||||||
|
return new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX, "", 0, "", "",
|
||||||
|
SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, mxPort), domain -> hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deliversToTheMxAndKeepsTheBytesUnchanged() throws Exception {
|
||||||
|
SmtpDeliveryClient.Result result = mxClient(port(), List.of("127.0.0.1"))
|
||||||
|
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
assertThat(result.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
|
||||||
|
MimeMessage[] received = greenMail.getReceivedMessages();
|
||||||
|
assertThat(received).hasSize(1);
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
received[0].writeTo(out);
|
||||||
|
// GreenMail adds its Received/Return-Path; everything we sent is there byte for byte (DKIM stays valid)
|
||||||
|
assertThat(out.toString(StandardCharsets.UTF_8)).contains(MESSAGE.stripTrailing());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void triesTheNextMxWhenTheFirstDoesNotAnswer() throws Exception {
|
||||||
|
int closed;
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
closed = socket.getLocalPort();
|
||||||
|
}
|
||||||
|
// first "host" refuses the connection, second is GreenMail — both on 127.0.0.1, so use two clients' ports:
|
||||||
|
SmtpDeliveryClient.Result refused = mxClient(closed, List.of("127.0.0.1"))
|
||||||
|
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(refused.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.TEMPORARY);
|
||||||
|
|
||||||
|
SmtpDeliveryClient.Result fallback = mxClient(port(), List.of("unreachable.invalid", "127.0.0.1"))
|
||||||
|
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(fallback.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void domainWithoutMailServerIsAPermanentFailure() {
|
||||||
|
SmtpDeliveryClient.Result result = mxClient(port(), List.of())
|
||||||
|
.deliver("alice@ours.test", "bob@nowhere.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(result.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.PERMANENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dnsTroubleIsTemporary() {
|
||||||
|
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX,
|
||||||
|
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, port()),
|
||||||
|
domain -> { throw new SmtpDeliveryClient.TemporaryDnsException("SERVFAIL"); });
|
||||||
|
assertThat(client.deliver("a@ours.test", "b@theirs.test", MESSAGE.getBytes()).outcome())
|
||||||
|
.isEqualTo(SmtpDeliveryClient.Outcome.TEMPORARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendsThroughASmarthostWithLogin() {
|
||||||
|
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.SMARTHOST,
|
||||||
|
"127.0.0.1", port(), "relay-user", "relay-pass", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, 25));
|
||||||
|
|
||||||
|
assertThat(client.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes()).outcome())
|
||||||
|
.isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
|
||||||
|
assertThat(greenMail.getReceivedMessages()).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void offModeSendsNothing() {
|
||||||
|
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.OFF,
|
||||||
|
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, 25));
|
||||||
|
assertThat(client.deliver("a@ours.test", "b@theirs.test", MESSAGE.getBytes()).outcome())
|
||||||
|
.isEqualTo(SmtpDeliveryClient.Outcome.PERMANENT);
|
||||||
|
assertThat(greenMail.getReceivedMessages()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import com.mailserver.smtp.SmtpTls;
|
||||||
|
import jakarta.mail.BodyPart;
|
||||||
|
import jakarta.mail.Flags;
|
||||||
|
import jakarta.mail.Folder;
|
||||||
|
import jakarta.mail.Message;
|
||||||
|
import jakarta.mail.Multipart;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.Store;
|
||||||
|
import jakarta.mail.UIDFolder;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import jakarta.mail.search.SubjectTerm;
|
||||||
|
import org.eclipse.angus.mail.imap.AppendUID;
|
||||||
|
import org.eclipse.angus.mail.imap.IMAPFolder;
|
||||||
|
import com.mailserver.security.LoginGuard;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ImapServerTest {
|
||||||
|
|
||||||
|
static final String PLAIN = """
|
||||||
|
From: Bob <bob@elsewhere.test>
|
||||||
|
To: alice@example.test
|
||||||
|
Subject: Hello Alice
|
||||||
|
Date: Thu, 05 Nov 2026 10:00:00 +0000
|
||||||
|
Message-ID: <hello@elsewhere.test>
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Hi there, line one.
|
||||||
|
Line two.
|
||||||
|
""";
|
||||||
|
|
||||||
|
static final String WITH_FILE = """
|
||||||
|
From: =?UTF-8?B?0JDQvdC90LA=?= <anna@elsewhere.test>
|
||||||
|
To: alice@example.test
|
||||||
|
Subject: =?UTF-8?B?0J7RgtGH0ZHRgg==?=
|
||||||
|
Date: Thu, 05 Nov 2026 11:00:00 +0000
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/mixed; boundary="b1"
|
||||||
|
|
||||||
|
preamble
|
||||||
|
--b1
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
See the report.
|
||||||
|
--b1
|
||||||
|
Content-Type: application/pdf; name="report.pdf"
|
||||||
|
Content-Disposition: attachment; filename="report.pdf"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
JVBERi0xLjQgdGVzdA==
|
||||||
|
--b1--
|
||||||
|
""";
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tlsDir;
|
||||||
|
|
||||||
|
private MemoryImapStore store;
|
||||||
|
private ImapServer server;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void start() throws Exception {
|
||||||
|
store = new MemoryImapStore();
|
||||||
|
store.deliver("INBOX", PLAIN);
|
||||||
|
store.deliver("INBOX", WITH_FILE);
|
||||||
|
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);
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Store connect() throws Exception {
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.imap.host", "127.0.0.1");
|
||||||
|
props.put("mail.imap.port", String.valueOf(server.port()));
|
||||||
|
props.put("mail.imap.starttls.enable", "true");
|
||||||
|
props.put("mail.imap.starttls.required", "true");
|
||||||
|
props.put("mail.imap.ssl.trust", "*");
|
||||||
|
props.put("mail.imap.ssl.checkserveridentity", "false");
|
||||||
|
Store imap = Session.getInstance(props).getStore("imap");
|
||||||
|
imap.connect("alice@example.test", "secret");
|
||||||
|
return imap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void realClientReadsFoldersMessagesAndAttachments() throws Exception {
|
||||||
|
try (Store imap = connect()) {
|
||||||
|
List<String> names = Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName).toList();
|
||||||
|
assertThat(names).contains("INBOX", "Drafts", "Sent", "Spam", "Trash");
|
||||||
|
|
||||||
|
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
|
||||||
|
inbox.open(Folder.READ_WRITE);
|
||||||
|
assertThat(inbox.getMessageCount()).isEqualTo(2);
|
||||||
|
assertThat(inbox.getUnreadMessageCount()).isEqualTo(2);
|
||||||
|
|
||||||
|
Message first = inbox.getMessage(1);
|
||||||
|
assertThat(first.getSubject()).isEqualTo("Hello Alice");
|
||||||
|
assertThat(((InternetAddress) first.getFrom()[0]).getPersonal()).isEqualTo("Bob");
|
||||||
|
assertThat(first.getContent().toString()).contains("Line two.");
|
||||||
|
assertThat(first.isSet(Flags.Flag.SEEN)).isTrue(); // BODY[] without PEEK
|
||||||
|
|
||||||
|
Message second = inbox.getMessage(2);
|
||||||
|
assertThat(second.getSubject()).isEqualTo("Отчёт");
|
||||||
|
assertThat(((InternetAddress) second.getFrom()[0]).getPersonal()).isEqualTo("Анна");
|
||||||
|
Multipart parts = (Multipart) second.getContent(); // BODYSTRUCTURE, then BODY[1] and BODY[2]
|
||||||
|
assertThat(parts.getCount()).isEqualTo(2);
|
||||||
|
assertThat(parts.getBodyPart(0).getContent().toString()).contains("See the report.");
|
||||||
|
BodyPart file = parts.getBodyPart(1);
|
||||||
|
assertThat(file.getFileName()).isEqualTo("report.pdf");
|
||||||
|
assertThat(new String(file.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)).isEqualTo("%PDF-1.4 test");
|
||||||
|
|
||||||
|
assertThat(inbox.search(new SubjectTerm("hello"))).hasSize(1);
|
||||||
|
assertThat(inbox.getUID(first)).isEqualTo(1);
|
||||||
|
assertThat(inbox.getMessageByUID(2).getSubject()).isEqualTo("Отчёт");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appendCopyMoveAndExpunge() throws Exception {
|
||||||
|
try (Store imap = connect()) {
|
||||||
|
IMAPFolder sent = (IMAPFolder) imap.getFolder("Sent");
|
||||||
|
MimeMessage outgoing = new MimeMessage(Session.getInstance(new Properties()));
|
||||||
|
outgoing.setFrom(new InternetAddress("alice@example.test"));
|
||||||
|
outgoing.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@elsewhere.test"));
|
||||||
|
outgoing.setSubject("Sent from Thunderbird");
|
||||||
|
outgoing.setText("body");
|
||||||
|
AppendUID[] appended = sent.appendUIDMessages(new Message[]{outgoing});
|
||||||
|
assertThat(appended[0].uid).isEqualTo(1);
|
||||||
|
assertThat(appended[0].uidvalidity).isEqualTo(store.box("Sent").uidValidity());
|
||||||
|
|
||||||
|
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
|
||||||
|
inbox.open(Folder.READ_WRITE);
|
||||||
|
inbox.copyMessages(new Message[]{inbox.getMessage(1)}, imap.getFolder("Trash"));
|
||||||
|
inbox.moveMessages(new Message[]{inbox.getMessage(2)}, imap.getFolder("Spam"));
|
||||||
|
inbox.close(false); // JavaMail counts EXPUNGEd messages until the folder is opened again
|
||||||
|
inbox.open(Folder.READ_WRITE);
|
||||||
|
assertThat(inbox.getMessageCount()).isEqualTo(1);
|
||||||
|
|
||||||
|
inbox.getMessage(1).setFlag(Flags.Flag.DELETED, true);
|
||||||
|
inbox.expunge();
|
||||||
|
assertThat(inbox.getMessageCount()).isZero();
|
||||||
|
}
|
||||||
|
assertThat(store.entries(store.box("Trash").id())).hasSize(1);
|
||||||
|
assertThat(store.entries(store.box("Spam").id())).hasSize(1);
|
||||||
|
assertThat(store.entries(store.box("Sent").id())).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRenameAndDeleteFolders() throws Exception {
|
||||||
|
try (Store imap = connect()) {
|
||||||
|
Folder projects = imap.getFolder("Проекты");
|
||||||
|
assertThat(projects.create(Folder.HOLDS_MESSAGES)).isTrue();
|
||||||
|
assertThat(Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName)).contains("Проекты");
|
||||||
|
assertThat(projects.renameTo(imap.getFolder("Archive"))).isTrue();
|
||||||
|
assertThat(imap.getFolder("Archive").delete(false)).isTrue();
|
||||||
|
assertThat(imap.getFolder("Archive").exists()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
String greeting = client.read();
|
||||||
|
assertThat(greeting).contains("STARTTLS").contains("LOGINDISABLED").doesNotContain("AUTH=PLAIN");
|
||||||
|
assertThat(client.send("a LOGIN alice@example.test secret")).startsWith("a NO [PRIVACYREQUIRED]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void literalsAndLiteralPlus() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.tls("s");
|
||||||
|
client.write("a LOGIN {18}\r\n");
|
||||||
|
assertThat(client.read()).startsWith("+ ");
|
||||||
|
client.write("alice@example.test {6+}\r\nsecret\r\n");
|
||||||
|
assertThat(client.read()).startsWith("a OK");
|
||||||
|
String message = "Subject: via literal+\r\n\r\nhi\r\n";
|
||||||
|
client.write("b APPEND Drafts (\\Seen) {" + message.length() + "+}\r\n" + message + "\r\n");
|
||||||
|
assertThat(client.read()).startsWith("b OK [APPENDUID");
|
||||||
|
}
|
||||||
|
assertThat(store.entries(store.box("Drafts").id())).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idleReportsNewMailUntilDone() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.tls("s");
|
||||||
|
client.send("a LOGIN alice@example.test secret");
|
||||||
|
client.untilTagged("b", "b SELECT INBOX");
|
||||||
|
client.write("c IDLE\r\n");
|
||||||
|
assertThat(client.read()).startsWith("+ idling");
|
||||||
|
store.deliver("INBOX", PLAIN);
|
||||||
|
assertThat(client.read()).isEqualTo("* 3 EXISTS");
|
||||||
|
client.write("DONE\r\n");
|
||||||
|
assertThat(client.read()).startsWith("c OK");
|
||||||
|
assertThat(client.send("d NOOP")).startsWith("d OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CVE-2011-0411 class: commands sent in plain text right after STARTTLS must not run inside TLS
|
||||||
|
@Test
|
||||||
|
void plainTextAfterStartTlsIsNotExecuted() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.write("s STARTTLS\r\nx LOGIN alice@example.test secret\r\n");
|
||||||
|
assertThat(client.read()).startsWith("s OK");
|
||||||
|
String reply;
|
||||||
|
try {
|
||||||
|
client.startTls();
|
||||||
|
reply = client.read();
|
||||||
|
} catch (java.io.IOException closed) {
|
||||||
|
reply = null; // the server closed the connection — the expected outcome
|
||||||
|
}
|
||||||
|
assertThat(reply).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sectionsOfANestedMessage() {
|
||||||
|
String raw = ("Subject: outer\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n"
|
||||||
|
+ "--x\r\nContent-Type: text/plain\r\n\r\nouter text\r\n"
|
||||||
|
+ "--x\r\nContent-Type: message/rfc822\r\n\r\nSubject: inner\r\nContent-Type: text/plain\r\n\r\ninner text\r\n"
|
||||||
|
+ "--x--\r\n");
|
||||||
|
MimeTree tree = MimeTree.parse(raw.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
assertThat(text(ImapSession.section(tree, "1"))).isEqualTo("outer text");
|
||||||
|
assertThat(text(ImapSession.section(tree, "2.HEADER"))).isEqualTo("Subject: inner\r\nContent-Type: text/plain\r\n\r\n");
|
||||||
|
assertThat(text(ImapSession.section(tree, "2.TEXT"))).isEqualTo("inner text");
|
||||||
|
assertThat(text(ImapSession.section(tree, "2.1"))).isEqualTo("inner text");
|
||||||
|
assertThat(text(ImapSession.section(tree, "1.MIME"))).isEqualTo("Content-Type: text/plain\r\n\r\n");
|
||||||
|
assertThat(text(ImapSession.section(tree, "HEADER.FIELDS (SUBJECT)"))).isEqualTo("Subject: outer\r\n\r\n");
|
||||||
|
assertThat(text(ImapSession.section(tree, ""))).isEqualTo(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mailboxNamesUseModifiedUtf7() {
|
||||||
|
// the example of RFC 3501 5.1.3
|
||||||
|
assertThat(ImapWriter.encodeMailbox("~peter/mail/台北/日本語")).isEqualTo("~peter/mail/&U,BTFw-/&ZeVnLIqe-");
|
||||||
|
assertThat(ImapWriter.decodeMailbox("~peter/mail/&U,BTFw-/&ZeVnLIqe-")).isEqualTo("~peter/mail/台北/日本語");
|
||||||
|
assertThat(ImapWriter.decodeMailbox(ImapWriter.encodeMailbox("Проекты & план"))).isEqualTo("Проекты & план");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(byte[] bytes) {
|
||||||
|
return new String(bytes, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line-oriented IMAP test client. */
|
||||||
|
static final class Client implements AutoCloseable {
|
||||||
|
private Socket socket;
|
||||||
|
private BufferedReader in;
|
||||||
|
private OutputStream out;
|
||||||
|
|
||||||
|
Client(int port) throws Exception {
|
||||||
|
socket = new Socket("127.0.0.1", port);
|
||||||
|
socket.setSoTimeout(5000);
|
||||||
|
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
out = socket.getOutputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
void write(String text) throws Exception {
|
||||||
|
out.write(text.getBytes(StandardCharsets.UTF_8));
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
String read() throws Exception {
|
||||||
|
return in.readLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
String send(String line) throws Exception {
|
||||||
|
write(line + "\r\n");
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a command, returns all lines up to its tagged response. */
|
||||||
|
List<String> untilTagged(String tag, String line) throws Exception {
|
||||||
|
write(line + "\r\n");
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
String next;
|
||||||
|
do {
|
||||||
|
next = read();
|
||||||
|
lines.add(next);
|
||||||
|
} while (next != null && !next.startsWith(tag + " "));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tls(String tag) throws Exception {
|
||||||
|
assertThat(send(tag + " STARTTLS")).startsWith(tag + " OK");
|
||||||
|
startTls();
|
||||||
|
}
|
||||||
|
|
||||||
|
void startTls() throws Exception {
|
||||||
|
SSLContext context = SSLContext.getInstance("TLS");
|
||||||
|
context.init(null, new TrustManager[]{new X509TrustManager() {
|
||||||
|
public void checkClientTrusted(X509Certificate[] chain, String authType) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public void checkServerTrusted(X509Certificate[] chain, String authType) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public X509Certificate[] getAcceptedIssuers() {
|
||||||
|
return new X509Certificate[0];
|
||||||
|
}
|
||||||
|
}}, null);
|
||||||
|
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
|
||||||
|
ssl.setUseClientMode(true);
|
||||||
|
ssl.startHandshake();
|
||||||
|
socket = ssl;
|
||||||
|
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
out = ssl.getOutputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.mailserver.imap;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/** In-memory {@link ImapStore}: one user alice@example.test / secret with the system folders. */
|
||||||
|
class MemoryImapStore implements ImapStore {
|
||||||
|
|
||||||
|
static final class Stored {
|
||||||
|
final long id;
|
||||||
|
volatile long folderId;
|
||||||
|
volatile long uid;
|
||||||
|
volatile boolean seen;
|
||||||
|
volatile boolean flagged;
|
||||||
|
volatile boolean answered;
|
||||||
|
volatile boolean deleted;
|
||||||
|
final Instant date;
|
||||||
|
final byte[] raw;
|
||||||
|
|
||||||
|
Stored(long id, long folderId, long uid, byte[] raw, Instant date) {
|
||||||
|
this.id = id;
|
||||||
|
this.folderId = folderId;
|
||||||
|
this.uid = uid;
|
||||||
|
this.raw = raw;
|
||||||
|
this.date = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
Entry entry() {
|
||||||
|
return new Entry(id, uid, seen, flagged, answered, deleted, date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final AtomicLong ids = new AtomicLong(100);
|
||||||
|
final List<Mailbox> boxes = new CopyOnWriteArrayList<>();
|
||||||
|
final Map<Long, Long> uidNext = new ConcurrentHashMap<>();
|
||||||
|
final List<Stored> messages = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
MemoryImapStore() {
|
||||||
|
for (String name : List.of("INBOX", "Drafts", "Sent", "Spam", "Trash")) {
|
||||||
|
addBox(name, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mailbox addBox(String name, boolean system) {
|
||||||
|
long id = ids.incrementAndGet();
|
||||||
|
uidNext.put(id, 1L);
|
||||||
|
Mailbox box = new Mailbox(id, name, system, 1700000000L + id, 1);
|
||||||
|
boxes.add(box);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
Mailbox box(String name) {
|
||||||
|
return boxes.stream().filter(b -> b.name().equals(name)).findFirst().orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delivers a message into a folder, as SMTP would. */
|
||||||
|
synchronized Stored deliver(String folder, String raw) {
|
||||||
|
Mailbox box = box(folder);
|
||||||
|
Stored stored = new Stored(ids.incrementAndGet(), box.id(), nextUid(box.id()),
|
||||||
|
raw.replace("\n", "\r\n").replace("\r\r\n", "\r\n").getBytes(), Instant.parse("2026-11-05T10:00:00Z"));
|
||||||
|
messages.add(stored);
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized long nextUid(long folderId) {
|
||||||
|
long uid = uidNext.get(folderId);
|
||||||
|
uidNext.put(folderId, uid + 1);
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Account authenticate(String username, String password) {
|
||||||
|
return "alice@example.test".equalsIgnoreCase(username) && "secret".equals(password)
|
||||||
|
? new Account(1, "alice@example.test") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Mailbox> mailboxes(long userId) {
|
||||||
|
return boxes.stream().map(b -> new Mailbox(b.id(), b.name(), b.system(), b.uidValidity(), uidNext.get(b.id()))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized Mailbox create(long userId, String name) {
|
||||||
|
return addBox(name, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void rename(long userId, Mailbox mailbox, String newName) {
|
||||||
|
boxes.removeIf(b -> b.id() == mailbox.id());
|
||||||
|
boxes.add(new Mailbox(mailbox.id(), newName, false, mailbox.uidValidity(), mailbox.uidNext()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void delete(long userId, Mailbox mailbox) {
|
||||||
|
Mailbox trash = box("Trash");
|
||||||
|
move(userId, entries(mailbox.id()), trash);
|
||||||
|
boxes.removeIf(b -> b.id() == mailbox.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Entry> entries(long folderId) {
|
||||||
|
return messages.stream().filter(m -> m.folderId == folderId)
|
||||||
|
.sorted((a, b) -> Long.compare(a.uid, b.uid)).map(Stored::entry).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] source(long messageId) {
|
||||||
|
return find(messageId).raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Stored find(long id) {
|
||||||
|
return messages.stream().filter(m -> m.id == id).findFirst().orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) {
|
||||||
|
Stored m = find(messageId);
|
||||||
|
m.seen = seen;
|
||||||
|
m.flagged = flagged;
|
||||||
|
m.answered = answered;
|
||||||
|
m.deleted = deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged,
|
||||||
|
boolean answered, boolean deleted, Instant internalDate) {
|
||||||
|
Stored stored = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), raw,
|
||||||
|
internalDate == null ? Instant.now() : internalDate);
|
||||||
|
stored.seen = seen;
|
||||||
|
stored.flagged = flagged;
|
||||||
|
stored.answered = answered;
|
||||||
|
stored.deleted = deleted;
|
||||||
|
messages.add(stored);
|
||||||
|
return stored.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<Long> copy(long userId, List<Entry> list, Mailbox target) {
|
||||||
|
List<Long> uids = new ArrayList<>();
|
||||||
|
for (Entry entry : list) {
|
||||||
|
Stored from = find(entry.id());
|
||||||
|
Stored copy = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), from.raw, from.date);
|
||||||
|
copy.seen = from.seen;
|
||||||
|
copy.flagged = from.flagged;
|
||||||
|
messages.add(copy);
|
||||||
|
uids.add(copy.uid);
|
||||||
|
}
|
||||||
|
return uids;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<Long> move(long userId, List<Entry> list, Mailbox target) {
|
||||||
|
List<Long> uids = new ArrayList<>();
|
||||||
|
for (Entry entry : list) {
|
||||||
|
Stored m = find(entry.id());
|
||||||
|
m.folderId = target.id();
|
||||||
|
m.uid = nextUid(target.id());
|
||||||
|
uids.add(m.uid);
|
||||||
|
}
|
||||||
|
return uids;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void expunge(List<Long> messageIds) {
|
||||||
|
messages.removeIf(m -> messageIds.contains(m.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,94 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DkimServiceTest {
|
||||||
|
|
||||||
|
private static final String MESSAGE = "From: Alice <alice@ours.test>\r\nTo: bob@theirs.test\r\nSubject: Hello\r\n"
|
||||||
|
+ "Date: Mon, 2 Nov 2026 10:00:00 +0000\r\nMessage-ID: <1@ours.test>\r\nMIME-Version: 1.0\r\n"
|
||||||
|
+ "Content-Type: text/plain; charset=UTF-8\r\n\r\nHello Bob,\r\nsee you.\r\n";
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path keys;
|
||||||
|
|
||||||
|
/** Signer and a verifier that finds the signer's DNS record — like the receiving side would. */
|
||||||
|
private DkimService[] pair() {
|
||||||
|
DkimService signer = new DkimService("ours.test", "mail", keys, true, (method, selector, token) -> List.of());
|
||||||
|
signer.init();
|
||||||
|
DkimService verifier = new DkimService("other.test", "mail", keys.resolve("verifier"), false,
|
||||||
|
(method, selector, token) -> "mail".contentEquals(selector) && "ours.test".contentEquals(token)
|
||||||
|
? List.of(signer.dnsRecord()) : List.of());
|
||||||
|
return new DkimService[]{signer, verifier};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signatureFromOurDomainVerifies() throws Exception {
|
||||||
|
DkimService[] p = pair();
|
||||||
|
byte[] signed = p[0].sign(MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
String text = new String(signed, StandardCharsets.UTF_8);
|
||||||
|
assertThat(text).startsWith("DKIM-Signature:").contains("c=relaxed/relaxed").contains("a=rsa-sha256")
|
||||||
|
.contains("d=ours.test").contains("s=mail");
|
||||||
|
DkimService.Verification result = p[1].verify(signed);
|
||||||
|
assertThat(result.signed()).isTrue();
|
||||||
|
assertThat(result.passed()).isTrue();
|
||||||
|
assertThat(result.domain()).isEqualTo("ours.test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changedBodyFailsVerification() throws Exception {
|
||||||
|
DkimService[] p = pair();
|
||||||
|
String signed = new String(p[0].sign(MESSAGE.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
|
||||||
|
byte[] tampered = signed.replace("see you.", "send money.").getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
DkimService.Verification result = p[1].verify(tampered);
|
||||||
|
assertThat(result.signed()).isTrue();
|
||||||
|
assertThat(result.passed()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unsignedMessageIsReportedAsNone() {
|
||||||
|
DkimService.Verification result = pair()[1].verify(MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(result.signed()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mailFromAnotherDomainIsNotSigned() throws Exception {
|
||||||
|
byte[] foreign = MESSAGE.replace("alice@ours.test", "eve@elsewhere.test").getBytes(StandardCharsets.UTF_8);
|
||||||
|
assertThat(pair()[0].sign(foreign)).isEqualTo(foreign);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keyIsKeptAcrossRestartsAndOldPkcs1FilesLoad() throws Exception {
|
||||||
|
DkimService first = new DkimService("ours.test", "mail", keys, true, (m, s, t) -> List.of());
|
||||||
|
first.init();
|
||||||
|
DkimService second = new DkimService("ours.test", "mail", keys, true, (m, s, t) -> List.of());
|
||||||
|
second.init();
|
||||||
|
assertThat(second.dnsRecord()).isEqualTo(first.dnsRecord()).startsWith("v=DKIM1; k=rsa; p=");
|
||||||
|
|
||||||
|
// the previous version wrote "BEGIN RSA PRIVATE KEY" (PKCS#1) and could not read it back
|
||||||
|
Path legacy = keys.resolve("legacy");
|
||||||
|
Files.createDirectories(legacy);
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
RSAPrivateKey key = (RSAPrivateKey) generator.generateKeyPair().getPrivate();
|
||||||
|
try (var writer = Files.newBufferedWriter(legacy.resolve("dkim-private.pem"));
|
||||||
|
var pem = new org.bouncycastle.openssl.jcajce.JcaPEMWriter(writer)) {
|
||||||
|
pem.writeObject(key);
|
||||||
|
}
|
||||||
|
assertThat(Files.readString(legacy.resolve("dkim-private.pem"))).contains("BEGIN RSA PRIVATE KEY");
|
||||||
|
DkimService loaded = new DkimService("ours.test", "mail", legacy, true, (m, s, t) -> List.of());
|
||||||
|
loaded.init();
|
||||||
|
assertThat(loaded.dnsRecord()).isNotNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LocalDeliveryTest {
|
||||||
|
|
||||||
|
private static MimeMessage parse(String text) throws Exception {
|
||||||
|
return new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prefersHtmlInNestedAlternativeAndSkipsAttachments() throws Exception {
|
||||||
|
MimeMessage message = parse("""
|
||||||
|
Content-Type: multipart/mixed; boundary="m"\r
|
||||||
|
\r
|
||||||
|
--m\r
|
||||||
|
Content-Type: multipart/alternative; boundary="a"\r
|
||||||
|
\r
|
||||||
|
--a\r
|
||||||
|
Content-Type: text/plain\r
|
||||||
|
\r
|
||||||
|
plain\r
|
||||||
|
--a\r
|
||||||
|
Content-Type: text/html\r
|
||||||
|
\r
|
||||||
|
<p>html</p>\r
|
||||||
|
--a--\r
|
||||||
|
--m\r
|
||||||
|
Content-Type: text/html\r
|
||||||
|
Content-Disposition: attachment; filename="x.html"\r
|
||||||
|
\r
|
||||||
|
<p>attached</p>\r
|
||||||
|
--m--\r
|
||||||
|
""");
|
||||||
|
|
||||||
|
LocalDelivery.Body body = LocalDelivery.body(message);
|
||||||
|
assertThat(body.html()).isTrue();
|
||||||
|
assertThat(body.text()).contains("<p>html</p>").doesNotContain("attached");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plainTextMessage() throws Exception {
|
||||||
|
LocalDelivery.Body body = LocalDelivery.body(parse("Subject: x\r\n\r\njust text\r\n"));
|
||||||
|
assertThat(body.html()).isFalse();
|
||||||
|
assertThat(body.text()).isEqualTo("just text\r\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import com.mailserver.domain.Attachment;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.dto.SendRequest;
|
||||||
|
import jakarta.mail.Message;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class SubmissionServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addressesAreSplitLowercasedAndDeduplicated() {
|
||||||
|
assertThat(SubmissionService.addresses(" Bob@Mail.test, alice@mail.test;bob@mail.test carol@x.org "))
|
||||||
|
.containsExactly("bob@mail.test", "alice@mail.test", "carol@x.org");
|
||||||
|
assertThat(SubmissionService.addresses(null)).isEmpty();
|
||||||
|
assertThat(SubmissionService.addresses(" ")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedAddressIsRejected() {
|
||||||
|
assertThatThrownBy(() -> SubmissionService.addresses("bob@mail.test, not-an-address"))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("not-an-address");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attachmentsGoIntoTheMessageAndComeBackOutOnDelivery(@TempDir Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("report.pdf");
|
||||||
|
Files.writeString(file, "%PDF-1.4 test");
|
||||||
|
Attachment attachment = new Attachment();
|
||||||
|
attachment.setFilename("Отчёт 2026.pdf");
|
||||||
|
attachment.setContentType("application/pdf");
|
||||||
|
attachment.setFilePath(file.toString());
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("admin@mail.test");
|
||||||
|
user.setDisplayName("Admin");
|
||||||
|
SendRequest request = new SendRequest();
|
||||||
|
request.setSubject("Report");
|
||||||
|
request.setBody("See the file");
|
||||||
|
|
||||||
|
MimeMessage mime = SubmissionService.mime(user, List.of("bob@mail.test"), List.of("carol@mail.test"), request,
|
||||||
|
List.of(attachment));
|
||||||
|
ByteArrayOutputStream raw = new ByteArrayOutputStream();
|
||||||
|
mime.writeTo(raw);
|
||||||
|
|
||||||
|
MimeMessage parsed = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw.toByteArray()));
|
||||||
|
assertThat(parsed.getRecipients(Message.RecipientType.CC)[0].toString()).isEqualTo("carol@mail.test");
|
||||||
|
assertThat(LocalDelivery.addresses(parsed, Message.RecipientType.TO)).isEqualTo("bob@mail.test");
|
||||||
|
assertThat(LocalDelivery.addresses(parsed, Message.RecipientType.CC)).isEqualTo("carol@mail.test");
|
||||||
|
assertThat(LocalDelivery.body(parsed).text()).isEqualTo("See the file");
|
||||||
|
List<LocalDelivery.FilePart> files = LocalDelivery.attachments(parsed);
|
||||||
|
assertThat(files).hasSize(1);
|
||||||
|
assertThat(files.get(0).name()).isEqualTo("Отчёт 2026.pdf");
|
||||||
|
assertThat(files.get(0).contentType()).isEqualTo("application/pdf");
|
||||||
|
assertThat(new String(files.get(0).bytes(), StandardCharsets.UTF_8)).isEqualTo("%PDF-1.4 test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plainMessageHasNoAttachments() throws Exception {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("admin@mail.test");
|
||||||
|
SendRequest request = new SendRequest();
|
||||||
|
request.setBody("Hi");
|
||||||
|
MimeMessage mime = SubmissionService.mime(user, List.of("bob@mail.test"), List.of(), request, List.of());
|
||||||
|
assertThat(LocalDelivery.attachments(mime)).isEmpty();
|
||||||
|
assertThat(LocalDelivery.body(mime).text()).isEqualTo("Hi");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.mailserver.smtp;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.delivery.OutboundQueue;
|
||||||
|
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||||
|
import com.mailserver.repository.UserRepository;
|
||||||
|
import com.mailserver.service.DkimService;
|
||||||
|
import com.mailserver.service.LocalDelivery;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Mail trap (75.4): existing mailboxes keep their mail, everything else goes into the catch-all mailbox. */
|
||||||
|
class MailRouterTest {
|
||||||
|
|
||||||
|
private final UserRepository users = mock(UserRepository.class);
|
||||||
|
private final LocalDelivery localDelivery = mock(LocalDelivery.class);
|
||||||
|
private final OutboundQueue queue = mock(OutboundQueue.class);
|
||||||
|
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
|
||||||
|
|
||||||
|
private MailRouter router(String catchAll) {
|
||||||
|
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
|
||||||
|
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
|
||||||
|
when(client.mode()).thenReturn(SmtpDeliveryClient.Mode.OFF);
|
||||||
|
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
|
||||||
|
queue, mock(DkimService.class), client);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutCatchAllUnknownAndOutsideAddressesAreRefused() {
|
||||||
|
MailRouter router = router("");
|
||||||
|
|
||||||
|
assertThat(router.route("bob@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||||
|
assertThat(router.route("nobody@mail.test", null)).isEqualTo(SmtpBackend.Route.UNKNOWN_USER);
|
||||||
|
assertThat(router.route("user@gmail.com", null)).isEqualTo(SmtpBackend.Route.RELAY_DENIED);
|
||||||
|
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND_DISABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void catchAllTakesEverythingWithoutItsOwnMailboxButExistingMailboxesKeepTheirMail() throws Exception {
|
||||||
|
MailRouter router = router("trap@mail.test");
|
||||||
|
|
||||||
|
assertThat(router.route("nobody@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||||
|
assertThat(router.route("user@gmail.com", null)).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||||
|
assertThat(router.mailbox("bob@mail.test")).isEqualTo("bob@mail.test");
|
||||||
|
assertThat(router.mailbox("user@gmail.com")).isEqualTo("trap@mail.test");
|
||||||
|
|
||||||
|
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "no-reply@jambotron.com", null, List.of(
|
||||||
|
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());
|
||||||
|
|
||||||
|
verify(localDelivery).deliver(any(), eq("trap@mail.test"), 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(queue, never()).enqueue(any(), any(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package com.mailserver.smtp;
|
||||||
|
|
||||||
|
import jakarta.mail.Message;
|
||||||
|
import jakarta.mail.Session;
|
||||||
|
import jakarta.mail.Transport;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import com.mailserver.security.LoginGuard;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SmtpServerTest {
|
||||||
|
|
||||||
|
/** Mailboxes of example.test; alice can log in. */
|
||||||
|
static class FakeBackend implements SmtpBackend {
|
||||||
|
final Map<String, String> passwords = Map.of("alice@example.test", "secret");
|
||||||
|
final List<String> mailboxes = List.of("alice@example.test", "bob@example.test");
|
||||||
|
final List<Envelope> envelopes = new CopyOnWriteArrayList<>();
|
||||||
|
final List<String> messages = new CopyOnWriteArrayList<>();
|
||||||
|
volatile boolean failStore;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean authenticate(String username, String password) {
|
||||||
|
return password.equals(passwords.get(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Route route(String recipient, String authenticatedUser) {
|
||||||
|
if (recipient.endsWith("@example.test")) {
|
||||||
|
return mailboxes.contains(recipient) ? Route.LOCAL : Route.UNKNOWN_USER;
|
||||||
|
}
|
||||||
|
return authenticatedUser == null ? Route.RELAY_DENIED : Route.OUTBOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accept(Envelope envelope, byte[] message) {
|
||||||
|
if (failStore) {
|
||||||
|
throw new IllegalStateException("database down");
|
||||||
|
}
|
||||||
|
envelopes.add(envelope);
|
||||||
|
messages.add(new String(message, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private FakeBackend backend;
|
||||||
|
private SmtpServer server;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void start() {
|
||||||
|
backend = new FakeBackend();
|
||||||
|
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, true,
|
||||||
|
Duration.ofSeconds(30)), backend);
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storesMailFromARealClient() throws Exception {
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.smtp.host", "127.0.0.1");
|
||||||
|
props.put("mail.smtp.port", String.valueOf(server.port()));
|
||||||
|
Session session = Session.getInstance(props);
|
||||||
|
MimeMessage message = new MimeMessage(session);
|
||||||
|
message.setFrom(new InternetAddress("someone@elsewhere.test"));
|
||||||
|
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
|
||||||
|
message.setSubject("Hello");
|
||||||
|
message.setText("first line\n.starts with a dot\nlast line");
|
||||||
|
|
||||||
|
Transport.send(message);
|
||||||
|
|
||||||
|
assertThat(backend.envelopes).hasSize(1);
|
||||||
|
SmtpBackend.Envelope envelope = backend.envelopes.get(0);
|
||||||
|
assertThat(envelope.mailFrom()).isEqualTo("someone@elsewhere.test");
|
||||||
|
assertThat(envelope.authenticatedUser()).isNull();
|
||||||
|
assertThat(envelope.recipients()).containsExactly(new SmtpBackend.Recipient("bob@example.test", SmtpBackend.Route.LOCAL));
|
||||||
|
assertThat(backend.messages.get(0))
|
||||||
|
.startsWith("Received: from ")
|
||||||
|
.contains("by mx.example.test with ESMTP;")
|
||||||
|
.contains("Subject: Hello")
|
||||||
|
.contains("\r\n.starts with a dot\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handlesPipelinedCommandsInOnePacketAndLinesSplitAcrossPackets() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.write("EHLO c.test\r\nMAIL FROM:<x@elsewhere.test>\r\nRCPT TO:<bob@example.test>\r\nDATA\r\n");
|
||||||
|
assertThat(client.readReply()).startsWith("250-mx.example.test");
|
||||||
|
assertThat(client.read()).startsWith("250 2.1.0");
|
||||||
|
assertThat(client.read()).startsWith("250 2.1.5");
|
||||||
|
assertThat(client.read()).startsWith("354");
|
||||||
|
client.write("Subject: split\r\n\r\nhal");
|
||||||
|
client.write("f a line\r\n..dot\r\n.");
|
||||||
|
client.write("\r\nQUIT\r\n");
|
||||||
|
assertThat(client.read()).startsWith("250 2.0.0");
|
||||||
|
assertThat(client.read()).startsWith("221");
|
||||||
|
}
|
||||||
|
assertThat(backend.messages.get(0)).endsWith("Subject: split\r\n\r\nhalf a line\r\n.dot\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesToRelayForStrangers() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.send("EHLO spammer.test");
|
||||||
|
client.readReply();
|
||||||
|
client.send("MAIL FROM:<spam@spammer.test>");
|
||||||
|
assertThat(client.send("RCPT TO:<victim@gmail.com>")).startsWith("550 5.7.1 Relaying denied");
|
||||||
|
assertThat(client.send("RCPT TO:<nobody@example.test>")).startsWith("550 5.1.1");
|
||||||
|
assertThat(client.send("DATA")).startsWith("554");
|
||||||
|
}
|
||||||
|
assertThat(backend.envelopes).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void relaysForALoggedInUserOnlyFromHisOwnAddress() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.send("EHLO laptop");
|
||||||
|
assertThat(client.readReplyLines()).contains("250-AUTH PLAIN LOGIN");
|
||||||
|
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0wrong"))).startsWith("535");
|
||||||
|
assertThat(client.send("AUTH PLAIN " + b64("\0Alice@Example.test\0secret"))).startsWith("235");
|
||||||
|
assertThat(client.send("MAIL FROM:<bob@example.test>")).startsWith("553");
|
||||||
|
assertThat(client.send("MAIL FROM:<alice@example.test>")).startsWith("250");
|
||||||
|
assertThat(client.send("RCPT TO:<friend@gmail.com>")).startsWith("250");
|
||||||
|
assertThat(client.send("DATA")).startsWith("354");
|
||||||
|
assertThat(client.send("Subject: out\r\n\r\nhi\r\n.")).startsWith("250");
|
||||||
|
}
|
||||||
|
SmtpBackend.Envelope envelope = backend.envelopes.get(0);
|
||||||
|
assertThat(envelope.authenticatedUser()).isEqualTo("alice@example.test");
|
||||||
|
assertThat(envelope.recipients()).containsExactly(new SmtpBackend.Recipient("friend@gmail.com", SmtpBackend.Route.OUTBOUND));
|
||||||
|
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
|
||||||
|
void authLoginWorksToo() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.send("EHLO laptop");
|
||||||
|
client.readReply();
|
||||||
|
assertThat(client.send("AUTH LOGIN")).isEqualTo("334 " + b64("Username:"));
|
||||||
|
assertThat(client.send(b64("alice@example.test"))).isEqualTo("334 " + b64("Password:"));
|
||||||
|
assertThat(client.send(b64("secret"))).startsWith("235");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTooBigMessagesAndKeepsTheSession() throws Exception {
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.send("HELO c");
|
||||||
|
client.send("MAIL FROM:<x@elsewhere.test>");
|
||||||
|
client.send("RCPT TO:<bob@example.test>");
|
||||||
|
client.send("DATA");
|
||||||
|
assertThat(client.send("x".repeat(3000) + "\r\n.")).startsWith("552");
|
||||||
|
assertThat(client.send("MAIL FROM:<x@elsewhere.test> SIZE=5000")).startsWith("552");
|
||||||
|
assertThat(client.send("NOOP")).startsWith("250");
|
||||||
|
}
|
||||||
|
assertThat(backend.envelopes).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void answers451WhenTheMessageCannotBeStored() throws Exception {
|
||||||
|
backend.failStore = true;
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.send("HELO c");
|
||||||
|
client.send("MAIL FROM:<x@elsewhere.test>");
|
||||||
|
client.send("RCPT TO:<bob@example.test>");
|
||||||
|
client.send("DATA");
|
||||||
|
assertThat(client.send("Subject: x\r\n\r\nbody\r\n.")).startsWith("451");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutTlsAuthIsOffWhenNotAllowed() throws Exception {
|
||||||
|
server.stop();
|
||||||
|
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false,
|
||||||
|
Duration.ofSeconds(30)), backend);
|
||||||
|
server.start();
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.send("EHLO laptop");
|
||||||
|
assertThat(client.readReplyLines()).noneMatch(line -> line.contains("AUTH"));
|
||||||
|
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesPaths() {
|
||||||
|
assertThat(SmtpServer.path("<A@B.test> SIZE=10")).isEqualTo("a@b.test");
|
||||||
|
assertThat(SmtpServer.path("<>")).isEmpty();
|
||||||
|
assertThat(SmtpServer.path("<@relay.test:u@b.test>")).isEqualTo("u@b.test");
|
||||||
|
assertThat(SmtpServer.path("u@b.test")).isNull();
|
||||||
|
assertThat(SmtpServer.path("<a b@c>")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- TLS (75.1) ----------
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tlsDir;
|
||||||
|
|
||||||
|
private SmtpServer tlsServer(boolean implicit) throws Exception {
|
||||||
|
server.stop();
|
||||||
|
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false,
|
||||||
|
Duration.ofSeconds(30), SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), implicit), backend);
|
||||||
|
server.start();
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startTlsThenAuthAndTheSessionStartsOver() throws Exception {
|
||||||
|
tlsServer(false);
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.send("EHLO laptop");
|
||||||
|
List<String> plain = client.readReplyLines();
|
||||||
|
assertThat(plain).contains("250-STARTTLS").noneMatch(line -> line.contains("AUTH"));
|
||||||
|
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538");
|
||||||
|
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("250");
|
||||||
|
|
||||||
|
assertThat(client.send("STARTTLS")).isEqualTo("220 2.0.0 Ready to start TLS");
|
||||||
|
client.startTls();
|
||||||
|
|
||||||
|
// RFC 3207: MAIL FROM before STARTTLS is forgotten, EHLO again
|
||||||
|
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("503");
|
||||||
|
client.send("EHLO laptop");
|
||||||
|
List<String> secure = client.readReplyLines();
|
||||||
|
assertThat(secure).contains("250-AUTH PLAIN LOGIN").noneMatch(line -> line.contains("STARTTLS"));
|
||||||
|
assertThat(client.send("STARTTLS")).startsWith("503");
|
||||||
|
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("235");
|
||||||
|
client.send("MAIL FROM:<alice@example.test>");
|
||||||
|
client.send("RCPT TO:<friend@gmail.com>");
|
||||||
|
client.send("DATA");
|
||||||
|
assertThat(client.send("Subject: tls\r\n\r\nhi\r\n.")).startsWith("250");
|
||||||
|
}
|
||||||
|
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CVE-2011-0411: commands sent in plain text right after STARTTLS must not run inside the TLS session
|
||||||
|
@Test
|
||||||
|
void plainTextInjectedAfterStartTlsIsNotExecuted() throws Exception {
|
||||||
|
tlsServer(false);
|
||||||
|
try (Client client = new Client(server.port())) {
|
||||||
|
client.read();
|
||||||
|
client.send("EHLO laptop");
|
||||||
|
client.readReplyLines();
|
||||||
|
client.write("STARTTLS\r\nMAIL FROM:<x@elsewhere.test>\r\nRCPT TO:<bob@example.test>\r\n");
|
||||||
|
assertThat(client.read()).isEqualTo("220 2.0.0 Ready to start TLS");
|
||||||
|
client.startTls();
|
||||||
|
String reply = client.read();
|
||||||
|
assertThat(reply == null || reply.startsWith("421")).as(reply).isTrue();
|
||||||
|
}
|
||||||
|
assertThat(backend.envelopes).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void realClientRequiringStartTlsDelivers() throws Exception {
|
||||||
|
tlsServer(false);
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.smtp.host", "127.0.0.1");
|
||||||
|
props.put("mail.smtp.port", String.valueOf(server.port()));
|
||||||
|
props.put("mail.smtp.starttls.enable", "true");
|
||||||
|
props.put("mail.smtp.starttls.required", "true");
|
||||||
|
props.put("mail.smtp.ssl.trust", "*");
|
||||||
|
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
|
||||||
|
props.put("mail.smtp.auth", "true");
|
||||||
|
Session session = Session.getInstance(props);
|
||||||
|
MimeMessage message = new MimeMessage(session);
|
||||||
|
message.setFrom(new InternetAddress("alice@example.test"));
|
||||||
|
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
|
||||||
|
message.setSubject("Over STARTTLS");
|
||||||
|
message.setText("hello");
|
||||||
|
|
||||||
|
Transport.send(message, "alice@example.test", "secret");
|
||||||
|
|
||||||
|
assertThat(backend.envelopes).hasSize(1);
|
||||||
|
assertThat(backend.envelopes.get(0).authenticatedUser()).isEqualTo("alice@example.test");
|
||||||
|
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void implicitTlsPortWorksWithoutStartTls() throws Exception {
|
||||||
|
tlsServer(true);
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.smtp.host", "127.0.0.1");
|
||||||
|
props.put("mail.smtp.port", String.valueOf(server.port()));
|
||||||
|
props.put("mail.smtp.ssl.enable", "true");
|
||||||
|
props.put("mail.smtp.ssl.trust", "*");
|
||||||
|
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
|
||||||
|
props.put("mail.smtp.auth", "true");
|
||||||
|
Session session = Session.getInstance(props);
|
||||||
|
MimeMessage message = new MimeMessage(session);
|
||||||
|
message.setFrom(new InternetAddress("alice@example.test"));
|
||||||
|
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
|
||||||
|
message.setSubject("Over 465");
|
||||||
|
message.setText("hello");
|
||||||
|
|
||||||
|
Transport.send(message, "alice@example.test", "secret");
|
||||||
|
|
||||||
|
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selfSignedCertificateIsCreatedOnceAndReused() throws Exception {
|
||||||
|
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
|
||||||
|
String first = java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE));
|
||||||
|
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
|
||||||
|
|
||||||
|
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE))).isEqualTo(first);
|
||||||
|
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.KEY_FILE))).startsWith("-----BEGIN PRIVATE KEY-----");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String b64(String text) {
|
||||||
|
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line-oriented test client. */
|
||||||
|
static final class Client implements AutoCloseable {
|
||||||
|
private Socket socket;
|
||||||
|
private BufferedReader in;
|
||||||
|
private OutputStream out;
|
||||||
|
|
||||||
|
Client(int port) throws Exception {
|
||||||
|
socket = new Socket("127.0.0.1", port);
|
||||||
|
socket.setSoTimeout(5000);
|
||||||
|
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
out = socket.getOutputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upgrade the connection to TLS (any certificate is trusted — the test server's is self-signed). */
|
||||||
|
void startTls() throws Exception {
|
||||||
|
SSLContext context = SSLContext.getInstance("TLS");
|
||||||
|
context.init(null, new TrustManager[]{new X509TrustManager() {
|
||||||
|
public void checkClientTrusted(X509Certificate[] chain, String authType) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public void checkServerTrusted(X509Certificate[] chain, String authType) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public X509Certificate[] getAcceptedIssuers() {
|
||||||
|
return new X509Certificate[0];
|
||||||
|
}
|
||||||
|
}}, null);
|
||||||
|
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
|
||||||
|
ssl.setUseClientMode(true);
|
||||||
|
ssl.startHandshake();
|
||||||
|
socket = ssl;
|
||||||
|
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
out = ssl.getOutputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
void write(String text) throws Exception {
|
||||||
|
out.write(text.getBytes(StandardCharsets.UTF_8));
|
||||||
|
out.flush();
|
||||||
|
Thread.sleep(50); // separate TCP packets
|
||||||
|
}
|
||||||
|
|
||||||
|
String send(String line) throws Exception {
|
||||||
|
out.write((line + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
out.flush();
|
||||||
|
return read();
|
||||||
|
}
|
||||||
|
|
||||||
|
String read() throws Exception {
|
||||||
|
return in.readLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First line of a multi-line reply; the rest is consumed. */
|
||||||
|
String readReply() throws Exception {
|
||||||
|
return readReplyLines().get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> readReplyLines() throws Exception {
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
String line;
|
||||||
|
do {
|
||||||
|
line = in.readLine();
|
||||||
|
lines.add(line);
|
||||||
|
} while (line != null && line.length() > 3 && line.charAt(3) == '-');
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Same stand as docker-compose.jambotron.yml, but from the ready image — nothing is built on the VM:
|
||||||
|
# gitea.jambotron.com/jambotron_gang/reactive-mail-server (Drone builds it from master, see .drone.yml).
|
||||||
|
# Pin a build: MAIL_IMAGE_TAG=<first 8 chars of the master commit>; default latest (pulled again on every deploy).
|
||||||
|
# Test deploy on Jambotron (v0.2.3 75.3): pick this file in the deploy wizard.
|
||||||
|
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
|
||||||
|
# SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off).
|
||||||
|
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
|
||||||
|
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in the "mail" log on the first start
|
||||||
|
# (Jambotron: project → Services → mail → Logs) and in /app/data/initial-passwords.txt
|
||||||
|
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
|
||||||
|
name: reactive-mail
|
||||||
|
|
||||||
|
services:
|
||||||
|
secrets-init:
|
||||||
|
image: alpine:3.20
|
||||||
|
restart: "no"
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
umask 077
|
||||||
|
for name in DB_PASSWORD JWT_SECRET; do
|
||||||
|
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
|
||||||
|
done
|
||||||
|
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
|
||||||
|
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
|
||||||
|
volumes:
|
||||||
|
- secrets:/secrets
|
||||||
|
|
||||||
|
db:
|
||||||
|
# Debian, not -alpine: on Jambotron staging VMs (Alpine 3.24, kernel 6.18) the Alpine build of postgres
|
||||||
|
# dies with "Bus error" even on --version; the Debian build works (2026-11-05)
|
||||||
|
image: postgres:17
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
secrets-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: mailserver
|
||||||
|
POSTGRES_USER: mail
|
||||||
|
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- secrets:/run/mail-secrets:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
mem_limit: 256m
|
||||||
|
|
||||||
|
mail:
|
||||||
|
image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
expose:
|
||||||
|
- "8080" # web client + API → domain in Jambotron
|
||||||
|
- "3025" # SMTP, inside the network only
|
||||||
|
- "3143" # IMAP (STARTTLS), inside the network only — open it in Jambotron as an internal TCP port
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
||||||
|
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
|
||||||
|
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
|
||||||
|
DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test}
|
||||||
|
# mail trap: mail to any address without its own mailbox here (also gmail.com etc.) goes into this mailbox
|
||||||
|
MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-trap@mail.test}
|
||||||
|
MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside)
|
||||||
|
OUTBOUND_MODE: "off"
|
||||||
|
SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM
|
||||||
|
SPF_ENABLED: "false"
|
||||||
|
DMARC_ENABLED: "false"
|
||||||
|
volumes:
|
||||||
|
- data:/app/data
|
||||||
|
- secrets:/run/mail-secrets:ro
|
||||||
|
mem_limit: 512m
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
secrets:
|
||||||
|
pgdata:
|
||||||
|
data:
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Test deploy on Jambotron (v0.2.3 75.3): pick this file in the deploy wizard.
|
||||||
|
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
|
||||||
|
# SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off).
|
||||||
|
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
|
||||||
|
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in the "mail" log on the first start
|
||||||
|
# (Jambotron: project → Services → mail → Logs) and in /app/data/initial-passwords.txt
|
||||||
|
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
|
||||||
|
name: reactive-mail
|
||||||
|
|
||||||
|
services:
|
||||||
|
secrets-init:
|
||||||
|
image: alpine:3.20
|
||||||
|
restart: "no"
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
umask 077
|
||||||
|
for name in DB_PASSWORD JWT_SECRET; do
|
||||||
|
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
|
||||||
|
done
|
||||||
|
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
|
||||||
|
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
|
||||||
|
volumes:
|
||||||
|
- secrets:/secrets
|
||||||
|
|
||||||
|
db:
|
||||||
|
# Debian, not -alpine: on Jambotron staging VMs (Alpine 3.24, kernel 6.18) the Alpine build of postgres
|
||||||
|
# dies with "Bus error" even on --version; the Debian build works (2026-11-05)
|
||||||
|
image: postgres:17
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
secrets-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: mailserver
|
||||||
|
POSTGRES_USER: mail
|
||||||
|
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- secrets:/run/mail-secrets:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
mem_limit: 256m
|
||||||
|
|
||||||
|
mail:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
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:
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
services:
|
|
||||||
# PostgreSQL — база данных
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: mail-postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: mailserver
|
|
||||||
POSTGRES_USER: mail
|
|
||||||
POSTGRES_PASSWORD: secret
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U mail"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
# Почтовый сервер (Spring Boot WebFlux)
|
|
||||||
mail-server:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: mail-server
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
SPRING_PROFILES_ACTIVE: prod
|
|
||||||
SPRING_R2DBC_URL: r2dbc:postgresql://postgres:5432/mailserver
|
|
||||||
SPRING_R2DBC_USERNAME: mail
|
|
||||||
SPRING_R2DBC_PASSWORD: secret
|
|
||||||
SPRING_FLYWAY_URL: jdbc:postgresql://postgres:5432/mailserver
|
|
||||||
SPRING_FLYWAY_USER: mail
|
|
||||||
SPRING_FLYWAY_PASSWORD: secret
|
|
||||||
MAILSERVER_SMTP_HOSTNAME: mail.jambotron.com
|
|
||||||
MAILSERVER_SMTP_PORT: 3025
|
|
||||||
MAILSERVER_RELAY_ENABLED: "true"
|
|
||||||
MAILSERVER_RELAY_PORT: 25
|
|
||||||
MAILSERVER_DKIM_DOMAIN: jambotron.com
|
|
||||||
ports:
|
|
||||||
- "3025:3025" # SMTP входящие (не через Caddy)
|
|
||||||
volumes:
|
|
||||||
- attachments:/app/attachments
|
|
||||||
- dkim-keys:/app/dkim-keys
|
|
||||||
- ./frontend/dist/frontend/browser:/usr/share/caddy # Статический фронтенд
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
labels:
|
|
||||||
# Caddy Docker Proxy — автоматический HTTPS + reverse proxy
|
|
||||||
caddy: mail.jambotron.com
|
|
||||||
caddy.route.api.reverse_proxy: "*{{upstreams 8080}}"
|
|
||||||
caddy.route.api.header_up.Host: "{host}"
|
|
||||||
caddy.route.api.header_up.X-Real-IP: "{remote_host}"
|
|
||||||
caddy.route.api.header_up.X-Forwarded-For: "{remote_host}"
|
|
||||||
caddy.route.api.header_up.X-Forwarded-Proto: "{scheme}"
|
|
||||||
# Статический фронтенд (Angular) — file_server для всех остальных путей
|
|
||||||
caddy.route.static.root: "* /usr/share/caddy"
|
|
||||||
caddy.route.static.file_server: "*"
|
|
||||||
caddy.route.static.encode: "gzip"
|
|
||||||
|
|
||||||
# Caddy Docker Proxy — автоматически генерирует Caddyfile из labels
|
|
||||||
caddy:
|
|
||||||
image: lucaslorentz/caddy-docker-proxy:2.8-alpine
|
|
||||||
container_name: mail-caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
- "443:443/udp" # ACME DNS-01 challenge
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- caddy-data:/data
|
|
||||||
- caddy-config:/config
|
|
||||||
environment:
|
|
||||||
CADDY_DOCKER_CADDYFILE: ""
|
|
||||||
CADDY_DOCKER_AGREE: "true"
|
|
||||||
CADDY_DOCKER_TLS_DEFAULT_ROUTE: "true"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
attachments:
|
|
||||||
dkim-keys:
|
|
||||||
caddy-data:
|
|
||||||
caddy-config:
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Real mail on the internet (v0.2.3 75.6): receives mail for MAIL_DOMAINS and sends it straight to the recipients'
|
||||||
|
# MX servers (port 25), DKIM-signed. From the ready image, like docker-compose.image.yml.
|
||||||
|
#
|
||||||
|
# Needs a VM of its own with a public address (Jambotron 60.8: «Публичный адрес» of the VM, SMTP «да» — port 25
|
||||||
|
# is closed by default) — the ports below are published on that address:
|
||||||
|
# 25 SMTP: mail from other servers (STARTTLS) and from mail programs
|
||||||
|
# 587 submission: mail programs (STARTTLS + login) — the same SMTP listener as 25
|
||||||
|
# 465 SMTPS: mail programs, implicit TLS
|
||||||
|
# 993 IMAPS: mail programs, implicit TLS
|
||||||
|
# 80 only for Let's Encrypt (HTTP-01) — service "acme"
|
||||||
|
# 443 web client + API over HTTPS, same Let's Encrypt certificate (acme): https://mail.jambocheck.com
|
||||||
|
# The web client + API can also go through Jambotron's Caddy (plain port 8080) under ANOTHER name
|
||||||
|
# (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
|
||||||
|
#
|
||||||
|
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
|
||||||
|
# A mail.<domain> → public address of the VM
|
||||||
|
# PTR public address → mail.<domain> (hoster's panel: Reverse DNS)
|
||||||
|
# MX <domain> → 10 mail.<domain>
|
||||||
|
# TXT <domain> → "v=spf1 ip4:<public address> -all"
|
||||||
|
# TXT _dmarc.<domain> → "v=DMARC1; p=none; rua=mailto:postmaster@<domain>"
|
||||||
|
# TXT mail._domainkey.<domain> → printed in the "mail" log on the first start (DKIM key, kept in the data volume)
|
||||||
|
#
|
||||||
|
# Mailbox admin@<domain>: its password is in the "mail" log on the first start and in /app/data/initial-passwords.txt.
|
||||||
|
# Registration is closed: new mailboxes only by the admin. No catch-all.
|
||||||
|
# Defaults are ours (mail.jambocheck.com / jambocheck.com) — Jambotron deploys without a .env; another domain:
|
||||||
|
# MAIL_HOSTNAME, MAIL_DOMAINS, ACME_EMAIL in .env next to this file.
|
||||||
|
name: reactive-mail-public
|
||||||
|
|
||||||
|
services:
|
||||||
|
secrets-init:
|
||||||
|
image: alpine:3.20
|
||||||
|
restart: "no"
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
umask 077
|
||||||
|
for name in DB_PASSWORD JWT_SECRET; do
|
||||||
|
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
|
||||||
|
done
|
||||||
|
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
|
||||||
|
volumes:
|
||||||
|
- secrets:/secrets
|
||||||
|
|
||||||
|
# Let's Encrypt for MAIL_HOSTNAME: issued on the first start, renewed 30 days before the end (checked twice a day).
|
||||||
|
# The deploy hook copies the pair to /tls (readable by the "mail" user 10001); the server reads it again within
|
||||||
|
# a minute — no restart
|
||||||
|
acme:
|
||||||
|
image: goacme/lego:v5.5.2
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
entrypoint:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
cat > /usr/local/bin/jb-deploy <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cp "$$LEGO_HOOK_CERT_PATH" /tls/cert.pem.new
|
||||||
|
cp "$$LEGO_HOOK_CERT_KEY_PATH" /tls/key.pem.new
|
||||||
|
chown 10001 /tls/cert.pem.new /tls/key.pem.new
|
||||||
|
chmod 600 /tls/key.pem.new
|
||||||
|
chmod 644 /tls/cert.pem.new
|
||||||
|
mv /tls/key.pem.new /tls/key.pem
|
||||||
|
mv /tls/cert.pem.new /tls/cert.pem
|
||||||
|
echo "certificate for $$LEGO_HOOK_CERT_DOMAINS copied to /tls"
|
||||||
|
EOF
|
||||||
|
chmod 755 /usr/local/bin/jb-deploy
|
||||||
|
while true; do
|
||||||
|
/lego run --accept-tos --email "$$ACME_EMAIL" --domains "$$MAIL_HOSTNAME" --key-type EC256 \
|
||||||
|
--http --http.address :80 --path /var/lib/lego --renew-days 30 --no-random-sleep \
|
||||||
|
--deploy-hook /usr/local/bin/jb-deploy || echo "lego failed — next try in 1 hour"
|
||||||
|
[ -s /tls/cert.pem ] && sleep 43200 || sleep 3600
|
||||||
|
done
|
||||||
|
environment:
|
||||||
|
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
|
||||||
|
ACME_EMAIL: ${ACME_EMAIL:-postmaster@jambocheck.com}
|
||||||
|
LEGO_LOG_FORMAT: text
|
||||||
|
volumes:
|
||||||
|
- lego:/var/lib/lego
|
||||||
|
- tls:/tls
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "test -s /tls/cert.pem && test -s /tls/key.pem"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10m
|
||||||
|
retries: 3
|
||||||
|
mem_limit: 128m
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:17
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
secrets-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: mailserver
|
||||||
|
POSTGRES_USER: mail
|
||||||
|
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- secrets:/run/mail-secrets:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
mem_limit: 256m
|
||||||
|
|
||||||
|
mail:
|
||||||
|
image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
acme:
|
||||||
|
condition: service_healthy # waits for the first certificate
|
||||||
|
ports:
|
||||||
|
- "25:3025"
|
||||||
|
- "587:3025"
|
||||||
|
- "465:3465"
|
||||||
|
- "993:3993"
|
||||||
|
- "443:8443" # web client + API over HTTPS: https://mail.jambocheck.com
|
||||||
|
expose:
|
||||||
|
- "8080" # plain HTTP, for a reverse proxy inside the network (not needed on the public address)
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
||||||
|
MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
|
||||||
|
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
|
||||||
|
MAIL_TLS_CERT: /tls/cert.pem
|
||||||
|
MAIL_TLS_KEY: /tls/key.pem
|
||||||
|
SMTPS_PORT: "3465"
|
||||||
|
IMAPS_PORT: "3993"
|
||||||
|
HTTPS_PORT: "8443"
|
||||||
|
DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
|
||||||
|
DKIM_SELECTOR: mail
|
||||||
|
DKIM_KEYS_DIR: /app/data/dkim-keys
|
||||||
|
MAIL_CATCH_ALL: ""
|
||||||
|
MAIL_REGISTRATION: "false"
|
||||||
|
OUTBOUND_MODE: mx
|
||||||
|
SMTP_AUTH_WITHOUT_TLS: "false"
|
||||||
|
IMAP_AUTH_WITHOUT_TLS: "false"
|
||||||
|
SPF_ENABLED: "true"
|
||||||
|
DMARC_ENABLED: "true"
|
||||||
|
volumes:
|
||||||
|
- data:/app/data
|
||||||
|
- secrets:/run/mail-secrets:ro
|
||||||
|
- tls:/tls:ro
|
||||||
|
mem_limit: 512m
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
secrets:
|
||||||
|
pgdata:
|
||||||
|
data:
|
||||||
|
lego:
|
||||||
|
tls:
|
||||||
+4
-39
@@ -1,32 +1,8 @@
|
|||||||
version: '3.8'
|
# Local development: Postgres on localhost:5433 for `./gradlew :backend:bootRun --args='--spring.profiles.active=dev'`
|
||||||
|
# and `cd frontend && npx ng serve` (proxy /api → :8080). Passwords here are for this laptop only.
|
||||||
services:
|
services:
|
||||||
mail-server:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./backend/Dockerfile
|
|
||||||
ports:
|
|
||||||
- "8080:8080" # REST API
|
|
||||||
- "3026:3025" # SMTP (приём писем)
|
|
||||||
- "1443:143" # IMAP (доступ к письмам)
|
|
||||||
volumes:
|
|
||||||
- attachments:/app/attachments
|
|
||||||
- dkim-keys:/app/dkim-keys
|
|
||||||
environment:
|
|
||||||
- SPRING_R2DBC_URL=r2dbc:postgresql://db:5432/mailserver
|
|
||||||
- SPRING_R2DBC_USERNAME=mail
|
|
||||||
- SPRING_R2DBC_PASSWORD=secret
|
|
||||||
- SPRING_FLYWAY_URL=jdbc:postgresql://db:5432/mailserver
|
|
||||||
- SPRING_FLYWAY_USER=mail
|
|
||||||
- SPRING_FLYWAY_PASSWORD=secret
|
|
||||||
- SPRING_MAIL_HOST=mailpit
|
|
||||||
- SPRING_MAIL_PORT=1025
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
- mailpit
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:17
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mailserver
|
POSTGRES_DB: mailserver
|
||||||
POSTGRES_USER: mail
|
POSTGRES_USER: mail
|
||||||
@@ -34,18 +10,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pg-data:/var/lib/postgresql/data
|
- pg-data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "127.0.0.1:5433:5432"
|
||||||
|
|
||||||
mailpit:
|
|
||||||
image: axllent/mailpit
|
|
||||||
ports:
|
|
||||||
- "1025:1025" # SMTP
|
|
||||||
- "8025:8025" # Web UI
|
|
||||||
volumes:
|
|
||||||
- mailpit-data:/data
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
attachments:
|
|
||||||
pg-data:
|
pg-data:
|
||||||
mailpit-data:
|
|
||||||
dkim-keys:
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
@jambotron:registry=https://gitea.jambotron.com/api/packages/jambotron_gang/npm/
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"src/material.scss",
|
||||||
"src/styles.css"
|
"src/styles.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -58,13 +59,13 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "1MB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "2MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "8kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "16kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
@@ -87,7 +88,10 @@
|
|||||||
"buildTarget": "frontend:build:development"
|
"buildTarget": "frontend:build:development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,16 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.17.0",
|
"packageManager": "npm@11.17.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/aria": "^22.2.6",
|
||||||
|
"@angular/cdk": "^22.2.6",
|
||||||
"@angular/common": "^22.2.0",
|
"@angular/common": "^22.2.0",
|
||||||
"@angular/compiler": "^22.2.0",
|
"@angular/compiler": "^22.2.0",
|
||||||
"@angular/core": "^22.2.0",
|
"@angular/core": "^22.2.0",
|
||||||
"@angular/forms": "^22.2.0",
|
"@angular/forms": "^22.2.0",
|
||||||
|
"@angular/material": "^22.2.6",
|
||||||
"@angular/platform-browser": "^22.2.0",
|
"@angular/platform-browser": "^22.2.0",
|
||||||
"@angular/router": "^22.2.0",
|
"@angular/router": "^22.2.0",
|
||||||
|
"@jambotron/aria-grid": "0.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:8080",
|
||||||
|
"secure": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideRouter, withHashLocation } from '@angular/router';
|
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { authInterceptor } from './interceptors/auth.interceptor';
|
import { authInterceptor } from './core/auth';
|
||||||
|
import { provideMailGrid } from './ui/grid';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideHttpClient(withInterceptors([authInterceptor]))
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
provideMailGrid()
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,43 +1,23 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { authGuard, guestGuard } from './core/auth';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{ path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) },
|
||||||
path: 'login',
|
{ path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) },
|
||||||
loadComponent: () => import('./components/login/login.component'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'register',
|
|
||||||
loadComponent: () => import('./components/register/register.component'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadComponent: () => import('./components/layout/layout.component'),
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () => import('./pages/shell/shell.component').then(m => m.ShellComponent),
|
||||||
children: [
|
children: [
|
||||||
{
|
{ path: '', pathMatch: 'full', loadComponent: () => import('./pages/inbox-redirect').then(m => m.InboxRedirect) },
|
||||||
path: 'inbox',
|
{ path: 'folder/:id', loadComponent: () => import('./pages/folder/folder.page').then(m => m.FolderPage) },
|
||||||
loadComponent: () => import('./components/inbox/inbox.component'),
|
{ path: 'message/:id', loadComponent: () => import('./pages/message/message.page').then(m => m.MessagePage) },
|
||||||
},
|
{ path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) },
|
||||||
{
|
{ path: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) },
|
||||||
path: 'compose',
|
{ path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) },
|
||||||
loadComponent: () => import('./components/compose/compose.component'),
|
{ path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) }
|
||||||
},
|
]
|
||||||
{
|
|
||||||
path: 'message/:id',
|
|
||||||
loadComponent: () => import('./components/message-detail/message-detail.component'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'contacts',
|
|
||||||
loadComponent: () => import('./components/contacts/contacts.component'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
redirectTo: 'inbox',
|
|
||||||
pathMatch: 'full',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '**',
|
|
||||||
redirectTo: 'login',
|
|
||||||
},
|
},
|
||||||
|
{ path: '**', redirectTo: '' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { OverlaysComponent } from './ui/overlays.component';
|
||||||
|
import { ThemeService } from './ui/theme.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet, OverlaysComponent],
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
template: `<router-outlet />`,
|
template: `<router-outlet /><app-overlays />`
|
||||||
styles: ``
|
|
||||||
})
|
})
|
||||||
export class App {}
|
export class App {
|
||||||
|
// applies the remembered palette before the first page renders
|
||||||
|
private theme = inject(ThemeService);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { RouterLink } from '@angular/router';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MailService } from '../../services/mail.service';
|
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-compose',
|
|
||||||
standalone: true,
|
|
||||||
imports: [RouterLink, FormsModule],
|
|
||||||
providers: [MailService],
|
|
||||||
template: `
|
|
||||||
<div class="glass-panel p-6">
|
|
||||||
<h2 class="text-lg font-orbitron font-bold text-cyan-500 mb-6 flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-pen-to-square text-pink-500"></i> COMPOSE MESSAGE
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
@if (success) {
|
|
||||||
<div class="mb-4 p-3 rounded-lg bg-cyan-500/20 border border-cyan-500/40 text-cyan-500 text-sm font-mono">
|
|
||||||
Message sent successfully!
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<form (ngSubmit)="onSend()" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="to" class="block text-xs font-mono text-cyan-500 mb-1">TO</label>
|
|
||||||
<input id="to" type="email" [(ngModel)]="to" name="to" required
|
|
||||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all"
|
|
||||||
placeholder="recipient@example.com">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="subject" class="block text-xs font-mono text-cyan-500 mb-1">SUBJECT</label>
|
|
||||||
<input id="subject" type="text" [(ngModel)]="subject" name="subject" required
|
|
||||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all"
|
|
||||||
placeholder="Enter subject...">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<label for="body" class="text-xs font-mono text-cyan-500">BODY</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<span class="text-xs font-mono text-slate-400">HTML</span>
|
|
||||||
<input type="checkbox" [(ngModel)]="isHtml" name="isHtml" class="sr-only">
|
|
||||||
<span class="w-10 h-5 rounded-full bg-slate-900/50 border border-cyan-500/30 relative transition-all"
|
|
||||||
[class.bg-cyan-500/30]="isHtml" [class.border-cyan-500]="isHtml">
|
|
||||||
<span class="absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-cyan-500 transition-all"
|
|
||||||
[class.translate-x-5]="isHtml"></span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<textarea id="body" [(ngModel)]="body" name="body" rows="10" required
|
|
||||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all resize-none"
|
|
||||||
[placeholder]="isHtml ? 'Write HTML or Markdown...' : 'Write your message...'"></textarea>
|
|
||||||
@if (isHtml) {
|
|
||||||
<p class="text-xs text-slate-500 font-mono mt-1"><i class="fa-solid fa-lightbulb mr-1"></i>Supports Markdown: **bold**, *italic*, - lists, code </p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-4">
|
|
||||||
<button type="submit" [disabled]="sending"
|
|
||||||
class="cyber-btn px-8 py-3 rounded-xl text-sm font-bold">
|
|
||||||
<i class="fa-solid fa-paper-plane mr-1"></i> {{ sending ? 'SENDING...' : 'SEND' }}
|
|
||||||
</button>
|
|
||||||
<a routerLink="/inbox" class="px-6 py-3 rounded-xl text-xs font-mono text-slate-400 border border-slate-600 hover:text-slate-200 hover:border-slate-400 transition-all text-center">
|
|
||||||
CANCEL
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: ``
|
|
||||||
})
|
|
||||||
export default class ComposeComponent {
|
|
||||||
private mailService = inject(MailService);
|
|
||||||
private router = inject(Router);
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
|
|
||||||
to = '';
|
|
||||||
subject = '';
|
|
||||||
body = '';
|
|
||||||
isHtml = false;
|
|
||||||
sending = false;
|
|
||||||
success = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Поддержка Reply и Forward через queryParams
|
|
||||||
this.route.queryParams.subscribe(params => {
|
|
||||||
if (params['replyTo']) {
|
|
||||||
this.to = params['replyTo'];
|
|
||||||
}
|
|
||||||
if (params['subject']) {
|
|
||||||
this.subject = params['subject'];
|
|
||||||
}
|
|
||||||
if (params['forwardSubject']) {
|
|
||||||
this.subject = params['forwardSubject'];
|
|
||||||
}
|
|
||||||
if (params['forwardBody']) {
|
|
||||||
this.body = '\n\n--- Forwarded message ---\n' + params['forwardBody'];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onSend(): void {
|
|
||||||
this.sending = true;
|
|
||||||
this.mailService.send({ to: this.to, subject: this.subject, body: this.body, html: this.isHtml }).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.success = true;
|
|
||||||
this.sending = false;
|
|
||||||
this.to = '';
|
|
||||||
this.subject = '';
|
|
||||||
this.body = '';
|
|
||||||
this.isHtml = false;
|
|
||||||
setTimeout(() => this.router.navigate(['/inbox']), 1500);
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.sending = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<div class="space-y-4">
|
|
||||||
<h2 class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-users text-pink-500"></i> CONTACTS
|
|
||||||
<span class="text-sm font-mono text-slate-400 ml-auto">{{ contacts().length }} contacts</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- Форма добавления -->
|
|
||||||
<div class="glass-panel p-5">
|
|
||||||
<h3 class="text-sm font-orbitron font-bold text-yellow-400 mb-3 flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-pen-to-square text-pink-500"></i> {{ editingContact() ? 'EDIT CONTACT' : 'ADD NEW CONTACT' }}
|
|
||||||
</h3>
|
|
||||||
<form (ngSubmit)="saveContact()" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-mono text-pink-400 mb-1">NAME</label>
|
|
||||||
<input type="text" [(ngModel)]="formName" name="name"
|
|
||||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
|
||||||
placeholder="John Doe">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-mono text-pink-400 mb-1">EMAIL *</label>
|
|
||||||
<input type="email" [(ngModel)]="formEmail" name="email" required
|
|
||||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
|
||||||
placeholder="john@example.com">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-mono text-pink-400 mb-1">PHONE</label>
|
|
||||||
<input type="text" [(ngModel)]="formPhone" name="phone"
|
|
||||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
|
||||||
placeholder="+1 234 567 8900">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-mono text-pink-400 mb-1">COMPANY</label>
|
|
||||||
<input type="text" [(ngModel)]="formCompany" name="company"
|
|
||||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
|
||||||
placeholder="Acme Corp">
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2 flex gap-2">
|
|
||||||
<button type="submit"
|
|
||||||
class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
|
||||||
@if (editingContact()) {
|
|
||||||
<i class="fa-solid fa-save mr-1"></i> UPDATE
|
|
||||||
} @else {
|
|
||||||
<i class="fa-solid fa-plus mr-1"></i> ADD
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
@if (editingContact()) {
|
|
||||||
<button type="button" (click)="cancelEdit()"
|
|
||||||
class="px-4 py-2 rounded-xl text-xs font-mono text-slate-400 border border-slate-600/40 hover:text-slate-200 hover:border-slate-400 transition-all">
|
|
||||||
<i class="fa-solid fa-xmark mr-1"></i> CANCEL
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Список контактов -->
|
|
||||||
<div class="glass-panel p-5">
|
|
||||||
@if (contacts().length === 0) {
|
|
||||||
<div class="text-center py-12 text-slate-400 font-mono">
|
|
||||||
<i class="fa-solid fa-address-card text-4xl mb-4 text-slate-600"></i>
|
|
||||||
<p>No contacts yet</p>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="space-y-2">
|
|
||||||
@for (contact of contacts(); track contact.id) {
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-xl border border-cyan-500/10 hover:border-cyan-500/40 hover:bg-cyan-500/5 transition-all">
|
|
||||||
<div class="w-10 h-10 rounded-full bg-purple-500/30 flex items-center justify-center text-cyan-400 font-bold text-lg">
|
|
||||||
{{ (contact.name || contact.email).charAt(0).toUpperCase() }}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="font-mono text-slate-200 truncate">{{ contact.name || '(No name)' }}</div>
|
|
||||||
<div class="text-xs text-slate-400 font-mono truncate">{{ contact.email }}</div>
|
|
||||||
@if (contact.phone || contact.company) {
|
|
||||||
<div class="text-xs text-slate-500 font-mono">
|
|
||||||
@if (contact.company) {
|
|
||||||
{{ contact.company }}
|
|
||||||
}
|
|
||||||
@if (contact.company && contact.phone) {
|
|
||||||
•
|
|
||||||
}
|
|
||||||
@if (contact.phone) {
|
|
||||||
{{ contact.phone }}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<button (click)="editContact(contact)"
|
|
||||||
class="px-2 py-1 text-xs font-mono text-cyan-400 hover:bg-cyan-500/10 rounded-lg transition-all">
|
|
||||||
<i class="fa-solid fa-pen"></i>
|
|
||||||
</button>
|
|
||||||
<button (click)="deleteContact(contact.id!)"
|
|
||||||
class="px-2 py-1 text-xs font-mono text-pink-400 hover:bg-pink-500/10 rounded-lg transition-all">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MailService } from '../../services/mail.service';
|
|
||||||
import { Contact } from '../../models/mail.models';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-contacts',
|
|
||||||
standalone: true,
|
|
||||||
imports: [FormsModule],
|
|
||||||
providers: [MailService],
|
|
||||||
templateUrl: './contacts.component.html',
|
|
||||||
styles: ``
|
|
||||||
})
|
|
||||||
export default class ContactsComponent {
|
|
||||||
private mailService = inject(MailService);
|
|
||||||
|
|
||||||
contacts = signal<Contact[]>([]);
|
|
||||||
editingContact = signal<Contact | null>(null);
|
|
||||||
|
|
||||||
formName = '';
|
|
||||||
formEmail = '';
|
|
||||||
formPhone = '';
|
|
||||||
formCompany = '';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.loadContacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadContacts(): void {
|
|
||||||
this.mailService.getContacts().subscribe({
|
|
||||||
next: (contacts) => this.contacts.set(contacts),
|
|
||||||
error: (e) => console.error('Failed to load contacts', e)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
saveContact(): void {
|
|
||||||
if (!this.formEmail.trim()) return;
|
|
||||||
|
|
||||||
const contact: Contact = {
|
|
||||||
name: this.formName.trim(),
|
|
||||||
email: this.formEmail.trim(),
|
|
||||||
phone: this.formPhone.trim() || undefined,
|
|
||||||
company: this.formCompany.trim() || undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.editingContact()?.id) {
|
|
||||||
this.mailService.updateContact(this.editingContact()!.id!, contact).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.resetForm();
|
|
||||||
this.loadContacts();
|
|
||||||
},
|
|
||||||
error: (e) => console.error('Failed to update contact', e)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.mailService.addContact(contact).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.resetForm();
|
|
||||||
this.loadContacts();
|
|
||||||
},
|
|
||||||
error: (e) => console.error('Failed to add contact', e)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
editContact(contact: Contact): void {
|
|
||||||
this.editingContact.set(contact);
|
|
||||||
this.formName = contact.name || '';
|
|
||||||
this.formEmail = contact.email || '';
|
|
||||||
this.formPhone = contact.phone || '';
|
|
||||||
this.formCompany = contact.company || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit(): void {
|
|
||||||
this.editingContact.set(null);
|
|
||||||
this.resetForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteContact(id: number): void {
|
|
||||||
if (confirm('Delete this contact?')) {
|
|
||||||
this.mailService.deleteContact(id).subscribe({
|
|
||||||
next: () => this.loadContacts(),
|
|
||||||
error: (e) => console.error('Failed to delete contact', e)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm(): void {
|
|
||||||
this.formName = '';
|
|
||||||
this.formEmail = '';
|
|
||||||
this.formPhone = '';
|
|
||||||
this.formCompany = '';
|
|
||||||
this.editingContact.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
|
||||||
import { RouterLink, ActivatedRoute } from '@angular/router';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MailService } from '../../services/mail.service';
|
|
||||||
import { Message, Folder } from '../../models/mail.models';
|
|
||||||
import { DatePipe, SlicePipe } from '@angular/common';
|
|
||||||
import { map, switchMap } from 'rxjs/operators';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-inbox',
|
|
||||||
standalone: true,
|
|
||||||
imports: [RouterLink, FormsModule, DatePipe, SlicePipe],
|
|
||||||
providers: [MailService],
|
|
||||||
template: `
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Поиск -->
|
|
||||||
<div class="glass-panel p-4">
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<input [(ngModel)]="searchQuery" (keyup.enter)="loadMessages()"
|
|
||||||
class="flex-1 bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
|
||||||
placeholder="Search messages...">
|
|
||||||
<button (click)="loadMessages()" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
|
||||||
<i class="fa-solid fa-magnifying-glass mr-1"></i> SEARCH
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Список писем -->
|
|
||||||
<div class="glass-panel p-4">
|
|
||||||
<h2 class="text-lg font-orbitron font-bold text-cyan-300 mb-4 flex items-center gap-2">
|
|
||||||
<i class="fa-solid fa-inbox text-pink-500"></i> {{ folderName() }}
|
|
||||||
<span class="text-sm font-mono text-slate-400 ml-auto">{{ messages()?.length || 0 }} messages</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
@if (!messages() || messages().length === 0) {
|
|
||||||
<div class="text-center py-12 text-slate-400 font-mono">
|
|
||||||
<i class="fa-solid fa-envelope-open-text text-4xl mb-4 text-slate-600"></i>
|
|
||||||
<p>No messages yet</p>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="space-y-2">
|
|
||||||
@for (msg of messages() || []; track msg.id) {
|
|
||||||
<a routerLink="/message/{{ msg.id }}"
|
|
||||||
class="block p-4 rounded-xl border border-cyan-500/10 hover:border-cyan-500/40 hover:bg-cyan-500/5 transition-all cursor-pointer group">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<!-- Индикатор прочитанности -->
|
|
||||||
<div class="mt-1">
|
|
||||||
@if (!msg.read) {
|
|
||||||
<span class="w-3 h-3 rounded-full bg-pink-500 inline-block shadow-[0_0_8px_rgba(255,0,127,0.8)]"></span>
|
|
||||||
} @else {
|
|
||||||
<span class="w-3 h-3 rounded-full border border-slate-500 inline-block"></span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="font-bold text-sm text-slate-200 font-rajdhani truncate">
|
|
||||||
{{ msg.senderName || msg.sender }}
|
|
||||||
</span>
|
|
||||||
@if (msg.spam) {
|
|
||||||
<span class="px-2 py-0.5 text-xs rounded-full bg-yellow-500/20 text-yellow-400 font-mono">
|
|
||||||
SPAM
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (msg.flagged) {
|
|
||||||
<i class="fa-solid fa-star text-yellow-400 text-xs"></i>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-cyan-400 font-rajdhani truncate group-hover:text-pink-400 transition-colors">
|
|
||||||
{{ msg.subject || '(No subject)' }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-slate-400 font-mono truncate">
|
|
||||||
{{ msg.body || '' | slice:0:100 }}{{ (msg.body || '').length > 100 ? '...' : '' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-right flex-shrink-0">
|
|
||||||
<div class="text-xs text-slate-400 font-mono">
|
|
||||||
{{ msg.sentAt | date:'MMM d, HH:mm' }}
|
|
||||||
</div>
|
|
||||||
@if (msg.hasAttachments) {
|
|
||||||
<span class="text-xs text-purple-400 font-mono"><i class="fa-solid fa-paperclip mr-1"></i>{{ msg.attachmentCount }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Пагинация -->
|
|
||||||
@if (totalPages() > 1) {
|
|
||||||
<div class="glass-panel p-4 flex items-center justify-center gap-2">
|
|
||||||
<button (click)="goToPage(page() - 1)" [disabled]="page() === 0"
|
|
||||||
class="px-3 py-1 rounded-lg text-xs font-mono text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
|
||||||
<i class="fa-solid fa-chevron-left mr-1"></i> Prev
|
|
||||||
</button>
|
|
||||||
@for (p of pageNumbers; track p) {
|
|
||||||
<button (click)="goToPage(p)"
|
|
||||||
[class.bg-pink-500/20]="p === page()" [class.text-pink-400]="p === page()"
|
|
||||||
[class.border-pink-500]="p === page()"
|
|
||||||
class="px-3 py-1 rounded-lg text-xs font-mono text-slate-400 border border-cyan-500/20 hover:bg-cyan-500/10 hover:text-cyan-300 transition-all">
|
|
||||||
{{ p + 1 }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button (click)="goToPage(page() + 1)" [disabled]="page() >= totalPages() - 1"
|
|
||||||
class="px-3 py-1 rounded-lg text-xs font-mono text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
|
||||||
Next <i class="fa-solid fa-chevron-right ml-1"></i>
|
|
||||||
</button>
|
|
||||||
<span class="text-xs font-mono text-slate-500 ml-2">
|
|
||||||
Page {{ page() + 1 }} of {{ totalPages() }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: ``
|
|
||||||
})
|
|
||||||
export default class InboxComponent {
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private mailService = inject(MailService);
|
|
||||||
messages = signal<Message[]>([]);
|
|
||||||
folderName = signal<string>('INBOX');
|
|
||||||
searchQuery = '';
|
|
||||||
folderId = signal<number>(1);
|
|
||||||
page = signal<number>(0);
|
|
||||||
totalPages = signal<number>(1);
|
|
||||||
pageSize = 20;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Подписываемся на изменения folderId в queryParams
|
|
||||||
this.route.queryParams.subscribe(params => {
|
|
||||||
const newFolderId = params['folderId'] ? Number(params['folderId']) : 1;
|
|
||||||
if (newFolderId !== this.folderId()) {
|
|
||||||
this.folderId.set(newFolderId);
|
|
||||||
this.loadFolderName(newFolderId);
|
|
||||||
this.loadMessages();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Начальная загрузка
|
|
||||||
const initialFolderId = this.route.snapshot.queryParams['folderId'] ? Number(this.route.snapshot.queryParams['folderId']) : 1;
|
|
||||||
this.folderId.set(initialFolderId);
|
|
||||||
this.loadFolderName(initialFolderId);
|
|
||||||
this.loadMessages();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFolderName(folderId: number): void {
|
|
||||||
this.mailService.getFolders().subscribe({
|
|
||||||
next: (folders) => {
|
|
||||||
const folder = folders.find(f => f.id === folderId);
|
|
||||||
this.folderName.set(folder ? folder.name : 'INBOX');
|
|
||||||
},
|
|
||||||
error: () => this.folderName.set('INBOX')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMessages(): void {
|
|
||||||
const currentFolderId = this.folderId();
|
|
||||||
const currentPage = this.page();
|
|
||||||
if (this.searchQuery.trim()) {
|
|
||||||
this.mailService.searchMessages(currentFolderId, this.searchQuery.trim()).subscribe({
|
|
||||||
next: (msgs) => this.messages.set(msgs),
|
|
||||||
error: () => console.error('Search failed')
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.mailService.getMessages(currentFolderId, currentPage, this.pageSize).subscribe({
|
|
||||||
next: (result) => {
|
|
||||||
this.messages.set(result.content);
|
|
||||||
this.totalPages.set(result.totalPages);
|
|
||||||
},
|
|
||||||
error: () => console.error('Failed to load messages')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
goToPage(page: number): void {
|
|
||||||
if (page >= 0 && page < this.totalPages()) {
|
|
||||||
this.page.set(page);
|
|
||||||
this.loadMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get pageNumbers(): number[] {
|
|
||||||
const pages: number[] = [];
|
|
||||||
const total = this.totalPages();
|
|
||||||
const current = this.page();
|
|
||||||
const start = Math.max(0, current - 2);
|
|
||||||
const end = Math.min(total - 1, current + 2);
|
|
||||||
for (let i = start; i <= end; i++) {
|
|
||||||
pages.push(i);
|
|
||||||
}
|
|
||||||
return pages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user