Compare commits

...
Author SHA1 Message Date
Liosha84 fdd6b8a51f Dockerfile без Alpine: на ВМ staging бинарники Alpine 3.24.3 падают (Bus error), curl в healthcheck тоже
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:36:58 +01:00
Liosha84 f1cf20f439 Merge pull request 'compose для Jambotron: postgres:17 (Debian) вместо -alpine' (#3) from fix/postgres-debian into master
Reviewed-on: #3
2026-11-05 04:07:23 +01:00
Liosha84 5f8a8607a9 compose для Jambotron: postgres:17 (Debian) — Alpine-сборка падает с Bus error на ВМ staging
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:05:50 +01:00
Liosha84 c8cf5c195f Merge pull request 'Dockerfile: свой пользователь app вместо mail' (#2) from fix/dockerfile-user into master
Reviewed-on: #2
2026-11-05 03:54:37 +01:00
Liosha84andClaude Opus 5.5 3805be7b42 Dockerfile: свой пользователь app (uid 10001) — mail в Alpine уже есть, addgroup падал
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:53:55 +01:00
Liosha84 f744def6ed Merge pull request 'Безопасность, SMTP, очередь, DKIM, деплой на Jambotron (75.1–75.3)' (#1) from fix/75-security-smtp into master
Reviewed-on: #1
2026-11-05 03:45:06 +01:00
jambotron α 165edf1a1e Безопасность, SMTP, очередь отправки, DKIM, деплой на Jambotron (Jambotron v0.2.3 75.1–75.3)
- SMTP-сервер переписан: разбор по строкам, AUTH PLAIN/LOGIN, нет открытого релея,
  MAIL FROM только свой после входа, 250 только после сохранения (иначе 451), лимит размера
- отправка наружу: очередь outbound_queue (V2) с повторами ~22 ч и уведомлением о недоставке;
  Jakarta Mail, режимы off / mx / smarthost, байты письма не меняются (DKIM)
- DKIM: подпись utils-mail-dkim, проверка Apache jDKIM; ключ PKCS#8, старый PKCS#1 читается
- права: письма, папки, вложения — только владельцу (было: любой по номеру); имена вложений без ../
- секреты без значений по умолчанию (DB_PASSWORD, JWT_SECRET), регистрация закрыта,
  admin123 и /api/simulate/receive — только профиль dev
- веб-клиент внутри jar (относительный /api), Dockerfile в 3 этапа, не root, health;
  docker-compose.jambotron.yml: секреты создаются при первом запуске, наружу только 8080
- gradle-wrapper.jar был в .gitignore — сборка не запускалась
- тесты: 26 (SMTP реальным клиентом и сокетом, GreenMail, DKIM подпись+проверка, очередь)

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:37:43 +01:00
44 changed files with 2635 additions and 1719 deletions
+7
View File
@@ -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/
+65 -420
View File
@@ -1,440 +1,85 @@
# Mail Server # Reactive Mail Server
Прототип почтового сервера на **Spring Boot + Angular** с REST API для управления письмами. Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
## Архитектура Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend.
## Как устроено
``` ```
┌─────────────────────────────────────────────────────┐ SMTP-клиент / чужой сервер ──► SmtpServer (Netty, порт 3025) ──┐
│ 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 идёт не в сетевом потоке.
- **Не открытый релей.**
- Письмо на свой домен принимается от кого угодно.
- Письмо на чужой домен — только после `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 (рекомендуется) ## Настройки (переменные окружения)
Секреты без значений по умолчанию — без них сервер не стартует. Их можно передать переменными или файлами в `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` | первый ящик на пустой базе; без пароля — случайный, один раз в логе | `admin@<первый домен>` |
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
| `MAIL_CATCH_ALL` | ящик-ловушка: вся почта сюда, наружу ничего | — |
| `SMTP_PORT` | порт SMTP | `3025` |
| `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` |
| `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` |
| `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` |
| `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` |
| `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` |
| `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — |
## Разработка
```bash ```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
```bash - Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет.
# Запустите PostgreSQL - Первый ящик — `admin@mail.test`, пароль — в логах сервиса `mail` при первом запуске.
docker run -d --name mail-db -p 5432:5432 \ - Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
-e POSTGRES_DB=mailserver \ - Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`).
-e POSTGRES_USER=mail \
-e POSTGRES_PASSWORD=secret \
postgres:17
# Запустите Mailpit для тестовой отправки Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
# Соберите и запустите бэкенд ## Не сделано
cd backend
./gradlew bootRun
```
## API - [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети.
- [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird.
### Тестовый пользователь - [ ] Вложения входящих писем не сохраняются (берётся только текст или HTML).
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
При первом запуске приложения автоматически создаётся тестовый пользователь: - [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6).
- **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** | Почтовый ящик, пароль, квота |
| **Folder** | INBOX, Sent, Drafts, Trash, Spam |
| **Message** | Заголовки, тело, флаги (read, flagged, spam) |
| **BlacklistEntry** | Заблокированный отправитель, привязка к пользователю |
| **Attachment** | Имя, MIME-тип, размер, путь к файлу |
| **Contact** | Имя, email, телефон, компания |
## Деплой на VPS
### Caddy Docker Proxy
Проект использует **Caddy Docker Proxy** для автоматического HTTPS и reverse proxy. Caddy сам генерирует конфиг из `labels` в `docker-compose.prod.yml` — отдельный Caddyfile не нужен.
**Как это работает:**
- Контейнер `mail-server` имеет `labels` с настройками Caddy
- `lucaslorentz/caddy-docker-proxy` следит за Docker через socket и автоматически создаёт виртуальные хосты
- HTTPS-сертификат от Let's Encrypt получается автоматически
**Ключевые labels:**
| 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:**
```bash
# Клонируйте репозиторий
git clone https://gitea.jambotron.com/jambotron_gang/reactive-mail-server.git
cd reactive-mail-server
# Соберите фронтенд
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
View File
@@ -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 ./
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 && 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
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
View File
@@ -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,46 @@ 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.security.SecureRandom;
import java.util.Base64;
/**
* First mailbox on an empty database: {@code mailserver.admin.email} (default admin@&lt;first domain&gt;) with
* {@code mailserver.admin.password}; no password given — a random one, printed to the log once.
*/
@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) {
return args -> { return args -> {
userRepository.count().block(); // Ждём инициализации if (userRepository.count().block() > 0) {
if (userRepository.count().block() == 0) { return;
log.info("База данных пуста, создаю тестового пользователя..."); }
userService.register("admin@mailserver.local", "admin123", "Администратор").block(); String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase();
log.info("✓ Тестовый пользователь создан: admin@mailserver.local / admin123"); boolean generated = password.isBlank();
String secret = generated ? randomPassword() : password;
userService.register(address, secret, "Administrator").block();
if (generated) {
log.warn("First mailbox created: {} — password: {} (shown once, change it)", address, secret);
} else {
log.info("First mailbox created: {}", address);
} }
}; };
} }
private static String randomPassword() {
byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
} }
@@ -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,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,52 @@
package com.mailserver.config;
import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpServer;
import org.springframework.beans.factory.annotation.Value;
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.time.Duration;
import java.util.Locale;
/** SMTP server, outbound delivery client and the queue scheduler. */
@Configuration
@EnableScheduling
public class MailServerConfig {
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
public SmtpServer smtpServer(MailRouter router,
@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), router);
}
/**
* 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));
}
}
@@ -16,15 +16,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);
@@ -40,8 +44,10 @@ public class SecurityConfig {
.csrf(ServerHttpSecurity.CsrfSpec::disable) .csrf(ServerHttpSecurity.CsrfSpec::disable)
.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", "/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,26 @@
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, …) are pages of the web client: serve index.html for them. */
@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).contains(".");
if (page) {
return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build());
}
return chain.filter(exchange);
}
}
@@ -16,6 +16,10 @@ 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.config.MailDomains;
import com.mailserver.service.SubmissionService;
import org.springframework.beans.factory.annotation.Value;
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.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -46,10 +50,18 @@ public class MailController {
private final UserService userService; private final UserService userService;
private final MailService mailService; private final MailService mailService;
private final SubmissionService submissionService;
private final MailDomains domains;
private final boolean registrationEnabled;
public MailController(UserService userService, MailService mailService) { public MailController(UserService userService, MailService mailService, SubmissionService submissionService,
MailDomains domains,
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) {
this.userService = userService; this.userService = userService;
this.mailService = mailService; this.mailService = mailService;
this.submissionService = submissionService;
this.domains = domains;
this.registrationEnabled = registrationEnabled;
} }
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) { private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
@@ -59,9 +71,15 @@ public class MailController {
// --- Users --- // --- Users ---
@PostMapping("/register") @PostMapping("/register")
public Mono<?> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) { public Mono<ResponseEntity<?>> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
return requestMono.flatMap(request -> if (!registrationEnabled) {
userService.register(request.getEmail(), request.getPassword(), request.getDisplayName()) return Mono.just(org.springframework.http.ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "Registration is closed")));
}
return requestMono.flatMap(request -> !domains.isLocal(request.getEmail())
? Mono.just(org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", "Mailboxes can only be created in: " + String.join(", ", domains.domains()))))
: userService.register(request.getEmail().trim().toLowerCase(java.util.Locale.ROOT), request.getPassword(), request.getDisplayName())
.map(user -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED) .map(user -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("message", "User registered"))) .body(Map.of("message", "User registered")))
.onErrorResume(IllegalArgumentException.class, e -> .onErrorResume(IllegalArgumentException.class, e ->
@@ -124,25 +142,15 @@ public class MailController {
} }
@PostMapping("/send") @PostMapping("/send")
public Mono<Object> sendMessage( public Mono<MessageResponse> sendMessage(
Mono<Authentication> authenticationMono, Mono<Authentication> authenticationMono,
@Valid @RequestBody Mono<SendRequest> requestMono) { @Valid @RequestBody Mono<SendRequest> requestMono) {
return authenticationMono.zipWith(requestMono) return authenticationMono.zipWith(requestMono)
.flatMap(pair -> { .flatMap(pair -> submissionService.send(pair.getT1().getName(), 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<ResponseEntity<?>> markAsRead(@PathVariable Long id, Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth -> return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName()) userService.findByEmail(auth.getName())
.flatMap(user -> mailService.markAsRead(id, user.getId()) .flatMap(user -> mailService.markAsRead(id, user.getId())
@@ -151,7 +159,7 @@ public class MailController {
} }
@DeleteMapping("/messages/{id}") @DeleteMapping("/messages/{id}")
public Mono<?> deleteMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) { public Mono<ResponseEntity<?>> deleteMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth -> return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName()) userService.findByEmail(auth.getName())
.flatMap(user -> mailService.deleteMessage(id, user.getId()) .flatMap(user -> mailService.deleteMessage(id, user.getId())
@@ -159,29 +167,10 @@ public class MailController {
); );
} }
// --- Simulate incoming message (для тестов) ---
@PostMapping("/simulate/receive")
public Mono<?> simulateReceive(@RequestBody Mono<Map<String, String>> bodyMono) {
return bodyMono.flatMap(body ->
mailService.receiveMessage(
body.get("senderEmail"),
body.get("senderName"),
body.get("recipientEmail"),
body.get("subject"),
body.get("body"),
"true".equals(body.get("html"))
)
.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 ---
@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<?>> uploadAttachment(
@PathVariable Long messageId, @PathVariable Long messageId,
Mono<Authentication> authenticationMono, Mono<Authentication> authenticationMono,
@RequestBody Mono<Flux<FilePart>> filePartsMono) { @RequestBody Mono<Flux<FilePart>> filePartsMono) {
@@ -192,7 +181,7 @@ public class MailController {
.flatMapMany(user -> pair.getT2()) .flatMapMany(user -> pair.getT2())
.next() .next()
.flatMap(file -> { .flatMap(file -> {
String originalFilename = file.filename(); String originalFilename = safeFilename(file.filename());
String uniqueName = UUID.randomUUID() + "_" + originalFilename; String uniqueName = UUID.randomUUID() + "_" + originalFilename;
Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName); Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName);
try { try {
@@ -214,7 +203,8 @@ public class MailController {
return userService.findByEmail(email) return userService.findByEmail(email)
.flatMap(user -> mailService.saveAttachmentReactive( .flatMap(user -> mailService.saveAttachmentReactive(
messageId, user.getId(), messageId, user.getId(),
originalFilename, file.headers().getContentType().toString(), originalFilename,
file.headers().getContentType() != null ? file.headers().getContentType().toString() : "application/octet-stream",
bytes.length, filePath.toString())) bytes.length, filePath.toString()))
.map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response)); .map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response));
}); });
@@ -233,39 +223,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())));
} }
DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes);
return Mono.just((Object) org.springframework.http.ResponseEntity.ok() private static MediaType contentType(String value) {
.header(HttpHeaders.CONTENT_DISPOSITION, try {
"attachment; filename=\"" + info.getFilename() + "\"") return value == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(value);
.contentType(MediaType.parseMediaType( } catch (org.springframework.util.InvalidMimeTypeException e) {
info.getContentType() != null ? info.getContentType() : "application/octet-stream")) return MediaType.APPLICATION_OCTET_STREAM;
.body(buffer)
);
} catch (IllegalArgumentException e) {
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())));
} }
}) }
);
/** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */
static String safeFilename(String name) {
String base = name == null ? "" : name.replace('\\', '/');
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 +324,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,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())))));
}
}
@@ -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>}. */
public String dnsRecord() {
return publicKeyBase64 == null ? null : "v=DKIM1; k=rsa; p=" + publicKeyBase64;
}
/** /**
* Генерирует RSA-ключ 2048 бит для DKIM. * 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 KeyPair generateKeyPair() throws NoSuchAlgorithmException { public byte[] sign(byte[] message) throws MessagingException, IOException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
}
/**
* Сохраняет ключи в PEM-формат.
*/
private void saveKeys(Path privateKeyFile, Path publicKeyFile, KeyPair keyPair)
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,130 @@
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 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.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;
public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService,
DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname) {
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" : "");
}
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam).block();
}
/** 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());
}
}
record Body(String text, boolean html) {
}
/** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */
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())) {
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;
}
}
@@ -7,7 +7,6 @@ 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.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 +14,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;
@@ -36,8 +33,6 @@ import java.util.ArrayList;
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,64 +45,44 @@ 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) {
DkimService dkimService) {
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) { /** Copy of a message we sent, in the sender's "Sent" folder. */
return userRepository.findByEmail(fromEmail) public Mono<MessageResponse> saveSent(User fromUser, String to, String subject, String body, boolean html, String messageId) {
.switchIfEmpty(Mono.error(new IllegalArgumentException("Sender not found: " + fromEmail))) return folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
.flatMap(fromUser ->
folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found"))) .switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
.flatMap(sentFolder -> { .flatMap(sentFolder -> {
Message message = new Message(); Message message = new Message();
message.setFolderId(sentFolder.getId()); message.setFolderId(sentFolder.getId());
message.setSenderEmail(fromEmail); message.setSenderEmail(fromUser.getEmail());
message.setSenderName(fromUser.getDisplayName()); message.setSenderName(fromUser.getDisplayName());
message.setRecipientEmail(request.getTo()); message.setRecipientEmail(to);
message.setSubject(request.getSubject()); message.setSubject(subject);
message.setBody(request.getBody()); message.setBody(body);
message.setHtml(request.isHtml()); message.setHtml(html);
message.setRead(true); message.setRead(true);
message.setReceivedAt(Instant.now()); message.setReceivedAt(Instant.now());
message.setMessageId(UUID.randomUUID().toString()); message.setMessageId(messageId);
message.setMessageSize((long) (request.getSubject().length() + request.getBody().length())); message.setMessageSize((long) (subject.length() + body.length()));
return messageRepository.save(message); return messageRepository.save(message);
}) })
.flatMap(message -> .map(this::toResponse);
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, public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
@@ -149,13 +124,15 @@ public class MailService {
public Flux<MessageResponse> getMessages(Long folderId, Long userId, int page, int size) { public Flux<MessageResponse> getMessages(Long folderId, Long userId, 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.findByFolderId(folderId, pageable) return ownedFolder(folderId, userId)
.flatMapMany(folder -> messageRepository.findByFolderId(folder.getId(), pageable))
.map(this::toResponse); .map(this::toResponse);
} }
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)
.flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable))
.map(this::toResponse); .map(this::toResponse);
} }
@@ -164,17 +141,17 @@ 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)
.flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable))
.map(this::toResponse); .map(this::toResponse);
} }
public Mono<MessageResponse> getMessage(Long messageId, Long userId) { public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
return messageRepository.findById(messageId) return ownedMessage(messageId, userId).map(this::toResponse);
.map(this::toResponse);
} }
public Mono<Void> markAsRead(Long messageId, Long userId) { public Mono<Void> markAsRead(Long messageId, Long userId) {
return messageRepository.findById(messageId) return ownedMessage(messageId, userId)
.flatMap(message -> { .flatMap(message -> {
message.setRead(true); message.setRead(true);
return messageRepository.save(message).then(); return messageRepository.save(message).then();
@@ -182,7 +159,29 @@ public class MailService {
} }
public Mono<Void> deleteMessage(Long messageId, Long userId) { public Mono<Void> deleteMessage(Long messageId, Long userId) {
return messageRepository.deleteById(messageId); return ownedMessage(messageId, userId).flatMap(messageRepository::delete);
}
/** 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 MessageResponse toResponse(Message message) { private MessageResponse toResponse(Message message) {
@@ -206,7 +205,7 @@ public class MailService {
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 ownedMessage(messageId, userId)
.flatMap(message -> { .flatMap(message -> {
Attachment attachment = new Attachment(); Attachment attachment = new Attachment();
attachment.setMessageId(messageId); attachment.setMessageId(messageId);
@@ -223,30 +222,24 @@ 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 { try {
Files.deleteIfExists(Paths.get(attachment.getFilePath())); Files.deleteIfExists(Paths.get(attachment.getFilePath()));
} catch (IOException e) { } catch (IOException e) {
// Игнорируем log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage());
} }
return attachmentRepository.delete(attachment); return attachmentRepository.delete(attachment);
}); });
@@ -261,132 +254,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,74 @@
package com.mailserver.service;
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.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
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.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.UUID;
/** "Send" in the web client: builds the MIME message and routes it like an authenticated SMTP submission. */
@Service
public class SubmissionService {
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) {
String to = request.getTo().trim().toLowerCase(Locale.ROOT);
return users.findByEmail(fromEmail)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(user -> Mono.fromCallable(() -> {
SmtpBackend.Route route = router.route(to, user.getEmail());
switch (route) {
case LOCAL, OUTBOUND -> { }
case UNKNOWN_USER -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No such mailbox: " + to);
case OUTBOUND_DISABLED -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Sending outside is disabled on this server");
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
}
String messageId = UUID.randomUUID() + "@" + hostname;
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
mime.setRecipient(Message.RecipientType.TO, new InternetAddress(to));
mime.setSubject(request.getSubject(), "UTF-8");
mime.setText(request.getBody(), "UTF-8", request.isHtml() ? "html" : "plain");
mime.setSentDate(new Date());
mime.saveChanges();
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(),
List.of(new SmtpBackend.Recipient(to, route))), bytes.toByteArray());
return messageId;
}).subscribeOn(Schedulers.boundedElastic())
.flatMap(messageId -> mailService.saveSent(user, to, request.getSubject(), request.getBody(),
request.isHtml(), messageId)));
}
}
@@ -0,0 +1,83 @@
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:
* our domain → our mailbox (or "no such user"); catch-all set → everything into that mailbox;
* 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.catchAll() != null) {
return Route.LOCAL;
}
if (domains.isLocal(recipient)) {
return Boolean.TRUE.equals(users.existsByEmail(recipient.toLowerCase(Locale.ROOT)).block())
? Route.LOCAL : 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 = domains.catchAll() != null ? domains.catchAll() : 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);
}
}
}
}
@@ -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,460 @@
package com.mailserver.smtp;
import io.netty.handler.codec.LineBasedFrameDecoder;
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.DisposableServer;
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>
* No STARTTLS yet: AUTH is offered only when {@code authWithoutTls} is on (a closed network: inside a VM or a docker network).
*/
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;
public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout) {
}
private final Settings settings;
private final SmtpBackend backend;
private DisposableServer server;
public SmtpServer(Settings settings, SmtpBackend backend) {
this.settings = settings;
this.backend = backend;
}
public void start() {
server = 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)))
.handle((inbound, outbound) -> {
String[] remoteIp = {"unknown"};
inbound.withConnection(c -> {
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
remoteIp[0] = address.getAddress().getHostAddress();
}
});
Session session = new Session(remoteIp[0]);
Flux<String> replies = inbound.receive().asByteArray()
.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);
return outbound.sendString(Flux.concat(
Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies), StandardCharsets.US_ASCII);
})
.bindNow(Duration.ofSeconds(10));
log.info("SMTP server listening on {}:{} ({})", settings.host(), port(), settings.hostname());
}
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;
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;
volatile boolean closing;
Session(String remoteIp) {
this.remoteIp = remoteIp;
}
Mono<String> onLine(byte[] raw) {
byte[] line = stripCr(raw);
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" -> reply("454 4.7.0 TLS not available");
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.authWithoutTls()) {
sb.append("250-AUTH PLAIN LOGIN\r\n");
}
sb.append("250 ENHANCEDSTATUSCODES\r\n");
return Mono.just(sb.toString());
}
private Mono<String> auth(String arg) {
if (!settings.authWithoutTls()) {
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);
return blocking(() -> backend.authenticate(username, password))
.map(ok -> {
if (ok) {
authenticatedUser = username;
log.info("SMTP {}: authenticated as {}", remoteIp, username);
return "235 2.7.0 Authentication successful\r\n";
}
log.warn("SMTP {}: authentication failed for {}", remoteIp, username);
badCommands++;
return "535 5.7.8 Authentication credentials invalid\r\n";
});
}
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 " + (authenticatedUser != null ? "ESMTPA" : "ESMTP") + ";\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,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,71 @@
spring.application.name=mail-server spring.application.name=mail-server
server.port=8080 server.port=8080
# 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: every message to any address goes into this one mailbox (mail trap)
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:}
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
# IMAP-сервер для доступа к письмам # Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
mailserver.outbound.mode=${OUTBOUND_MODE:off}
mailserver.outbound.host=${OUTBOUND_HOST:}
mailserver.outbound.port=${OUTBOUND_PORT:587}
mailserver.outbound.username=${OUTBOUND_USERNAME:}
mailserver.outbound.password=${OUTBOUND_PASSWORD:}
mailserver.outbound.tls=${OUTBOUND_TLS:starttls}
# IMAP — off: simplified, blocks the event loop (v0.2.3 75.2)
mailserver.imap.enabled=false mailserver.imap.enabled=false
mailserver.imap.port=1143 mailserver.imap.port=1143
mailserver.imap.hostname=localhost mailserver.imap.hostname=localhost
# DKIM — криптографическая подпись писем # DKIM — signing of our mail; DNS record printed to the log at start
mailserver.dkim.enabled=false mailserver.dkim.enabled=${DKIM_ENABLED:true}
mailserver.dkim.domain=localhost mailserver.dkim.domain=${DKIM_DOMAIN:localhost}
mailserver.dkim.keys-dir=./dkim-keys mailserver.dkim.selector=${DKIM_SELECTOR:mail}
mailserver.dkim.keys-dir=${DKIM_KEYS_DIR:./dkim-keys}
# SPF — проверка отправителей через DNS # SPF / DMARC for incoming mail
mailserver.spf.enabled=false 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
@@ -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,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,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,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,270 @@
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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
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 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();
}
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 final Socket socket;
private final BufferedReader in;
private final 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();
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();
}
}
}
+77
View File
@@ -0,0 +1,77 @@
# 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.
# The first mailbox (admin@<MAIL_DOMAINS>) and its password: in the "mail" logs on the first start.
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
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_CATCH_ALL: ${MAIL_CATCH_ALL:-}
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:
-91
View File
@@ -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:
+4 -39
View File
@@ -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:
+4 -1
View File
@@ -87,7 +87,10 @@
"buildTarget": "frontend:build:development" "buildTarget": "frontend:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
} }
} }
} }
+6
View File
@@ -0,0 +1,6 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { LoginRequest, LoginResponse, RegisterRequest } from '../models/mail.mod
}) })
export class AuthService { export class AuthService {
private http = inject(HttpClient); private http = inject(HttpClient);
private apiUrl = 'http://localhost:8080/api'; private apiUrl = '/api';
private currentUserSubject = new BehaviorSubject<string | null>( private currentUserSubject = new BehaviorSubject<string | null>(
localStorage.getItem('email') localStorage.getItem('email')
+1 -1
View File
@@ -8,7 +8,7 @@ import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse,
}) })
export class MailService { export class MailService {
private http = inject(HttpClient); private http = inject(HttpClient);
private apiUrl = 'http://localhost:8080/api'; private apiUrl = '/api';
// Папки // Папки
getFolders(): Observable<Folder[]> { getFolders(): Observable<Folder[]> {
Binary file not shown.
Vendored Regular → Executable
View File