Безопасность, 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>
This commit is contained in:
jambotron α
2026-11-05 03:37:43 +01:00
parent b6f6d41813
commit 165edf1a1e
44 changed files with 2626 additions and 1715 deletions
+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.
## Как устроено
```
┌─────────────────────────────────────────────────────┐
│ Angular Frontend │
│ (веб-почта: список, чтение, написание, контакты) │
└──────────────────────┬──────────────────────────────┘
│ REST API (JSON)
┌──────────────────────▼──────────────────────────────┐
│ Spring Boot Backend │
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Users │ │ Messages │ │ Contacts │ │
│ └────────────┘ └────────────┘ └──────────────┘ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Folders │ │Attachments │ │
│ └────────────┘ └────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ JavaMailSender │ │
│ │ (SMTP отправка)│ │
│ └─────────────────┘ │
└──────────────────────┬──────────────────────────────┘
│ JPA
┌──────────────────────▼──────────────────────────────┐
│ PostgreSQL 17 │
└─────────────────────────────────────────────────────┘
SMTP-клиент / чужой сервер ──► SmtpServer (Netty, порт 3025) ──┐
├─► MailRouter ─┬─► LocalDelivery → свой ящик (PostgreSQL)
Веб-почта (Angular) ──► REST /api ──► SubmissionService ──────┘ └─► OutboundQueue → DeliveryWorker → SMTP получателя
```
## Запуск
- **Приём (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
cd mail-server
docker compose up --build
docker compose up -d # PostgreSQL на 127.0.0.1:5433
./gradlew :backend:bootRun --args='--spring.profiles.active=dev' # API :8080, SMTP :3025, admin@mailserver.local / admin123
cd frontend && npm install && npx ng serve # http://localhost:4200, /api → :8080
./gradlew :backend:test # тесты (база не нужна)
```
Сервисы:
- **REST API**: http://localhost:8080
- **SMTP (приём)**: localhost:3025
- **IMAP (доступ к письмам)**: localhost:143
- **Mailpit UI**: http://localhost:8025 (SMTP на порту 1025)
- **PostgreSQL**: localhost:5432
Профиль `dev` — только для своего компьютера: известные пароли, открытая регистрация, `POST /api/simulate/receive` (письмо в ящик без SMTP).
### 2. Локально
## Деплой на Jambotron (тест)
Требования:
- Java 21
- PostgreSQL 17
В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`.
```bash
# Запустите PostgreSQL
docker run -d --name mail-db -p 5432:5432 \
-e POSTGRES_DB=mailserver \
-e POSTGRES_USER=mail \
-e POSTGRES_PASSWORD=secret \
postgres:17
- Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет.
- Первый ящик — `admin@mail.test`, пароль — в логах сервиса `mail` при первом запуске.
- Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
- Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`).
# Запустите Mailpit для тестовой отправки
docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
# Соберите и запустите бэкенд
cd backend
./gradlew bootRun
```
## Не сделано
## API
### Тестовый пользователь
При первом запуске приложения автоматически создаётся тестовый пользователь:
- **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
- [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети.
- [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird.
- [ ] Вложения входящих писем не сохраняются (берётся только текст или HTML).
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
- [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6).
## Лицензия