From 165edf1a1ed5ed58ee8b48d653c9f841918ac2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?jambotron=20=CE=B1?= Date: Thu, 5 Nov 2026 03:37:43 +0100 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C,=20SMTP,=20=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=8C=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8,=20DKIM,=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BD=D0=B0=20Jambotron=20(Jambotron=20v0.2.3=2075.1=E2=80=937?= =?UTF-8?q?5.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 7 + README.md | 485 ++------------- backend/Dockerfile | 35 +- backend/build.gradle | 15 +- .../mailserver/config/DataInitializer.java | 34 +- .../java/com/mailserver/config/JwtConfig.java | 11 +- .../com/mailserver/config/MailDomains.java | 51 ++ .../mailserver/config/MailServerConfig.java | 52 ++ .../com/mailserver/config/SecurityConfig.java | 14 +- .../mailserver/config/SmtpServerConfig.java | 379 ------------ .../com/mailserver/config/SpaWebFilter.java | 26 + .../mailserver/controller/MailController.java | 129 ++-- .../mailserver/delivery/DeliveryWorker.java | 96 +++ .../mailserver/delivery/OutboundMessage.java | 57 ++ .../mailserver/delivery/OutboundQueue.java | 28 + .../delivery/OutboundRepository.java | 13 + .../delivery/SmtpDeliveryClient.java | 244 ++++++++ .../com/mailserver/dev/DevController.java | 31 + .../com/mailserver/service/DkimService.java | 565 +++++------------- .../com/mailserver/service/LocalDelivery.java | 130 ++++ .../com/mailserver/service/MailService.java | 257 ++------ .../mailserver/service/SubmissionService.java | 74 +++ .../java/com/mailserver/smtp/MailRouter.java | 83 +++ .../java/com/mailserver/smtp/SmtpBackend.java | 41 ++ .../java/com/mailserver/smtp/SmtpServer.java | 460 ++++++++++++++ .../main/resources/application-dev.properties | 14 + .../resources/application-prod.properties | 54 -- .../src/main/resources/application.properties | 83 ++- .../db/migration/V2__outbound_queue.sql | 15 + .../controller/MailControllerTest.java | 18 + .../delivery/DeliveryWorkerTest.java | 88 +++ .../delivery/SmtpDeliveryClientTest.java | 109 ++++ .../mailserver/service/DkimServiceTest.java | 94 +++ .../mailserver/service/LocalDeliveryTest.java | 55 ++ .../com/mailserver/smtp/SmtpServerTest.java | 270 +++++++++ docker-compose.jambotron.yml | 75 +++ docker-compose.prod.yml | 91 --- docker-compose.yml | 43 +- frontend/angular.json | 5 +- frontend/proxy.conf.json | 6 + frontend/src/app/services/auth.service.ts | 2 +- frontend/src/app/services/mail.service.ts | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradlew | 0 44 files changed, 2626 insertions(+), 1715 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/config/MailDomains.java create mode 100644 backend/src/main/java/com/mailserver/config/MailServerConfig.java delete mode 100644 backend/src/main/java/com/mailserver/config/SmtpServerConfig.java create mode 100644 backend/src/main/java/com/mailserver/config/SpaWebFilter.java create mode 100644 backend/src/main/java/com/mailserver/delivery/DeliveryWorker.java create mode 100644 backend/src/main/java/com/mailserver/delivery/OutboundMessage.java create mode 100644 backend/src/main/java/com/mailserver/delivery/OutboundQueue.java create mode 100644 backend/src/main/java/com/mailserver/delivery/OutboundRepository.java create mode 100644 backend/src/main/java/com/mailserver/delivery/SmtpDeliveryClient.java create mode 100644 backend/src/main/java/com/mailserver/dev/DevController.java create mode 100644 backend/src/main/java/com/mailserver/service/LocalDelivery.java create mode 100644 backend/src/main/java/com/mailserver/service/SubmissionService.java create mode 100644 backend/src/main/java/com/mailserver/smtp/MailRouter.java create mode 100644 backend/src/main/java/com/mailserver/smtp/SmtpBackend.java create mode 100644 backend/src/main/java/com/mailserver/smtp/SmtpServer.java create mode 100644 backend/src/main/resources/application-dev.properties delete mode 100644 backend/src/main/resources/application-prod.properties create mode 100644 backend/src/main/resources/db/migration/V2__outbound_queue.sql create mode 100644 backend/src/test/java/com/mailserver/controller/MailControllerTest.java create mode 100644 backend/src/test/java/com/mailserver/delivery/DeliveryWorkerTest.java create mode 100644 backend/src/test/java/com/mailserver/delivery/SmtpDeliveryClientTest.java create mode 100644 backend/src/test/java/com/mailserver/service/DkimServiceTest.java create mode 100644 backend/src/test/java/com/mailserver/service/LocalDeliveryTest.java create mode 100644 backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java create mode 100644 docker-compose.jambotron.yml delete mode 100644 docker-compose.prod.yml create mode 100644 frontend/proxy.conf.json create mode 100644 gradle/wrapper/gradle-wrapper.jar mode change 100644 => 100755 gradlew diff --git a/.gitignore b/.gitignore index 457220d..2065aef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Java / Gradle *.class *.jar +!gradle/wrapper/gradle-wrapper.jar *.war *.ear *.log @@ -46,3 +47,9 @@ dkim-keys/ # Environment variables .env .env.local + +# local data of the dev server +backend/attachments/ +backend/dkim-keys/ +attachments/ +dkim-keys/ diff --git a/README.md b/README.md index 7eb8679..0ad9a7e 100644 --- a/README.md +++ b/README.md @@ -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 ._domainkey.`. +- **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `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 `. - -### Получить папки - -```bash -curl http://localhost:8080/api/folders \ - -H "Authorization: Bearer " -``` - -### Получить письма из папки - -```bash -curl "http://localhost:8080/api/messages?folderId=1&page=0&size=20" \ - -H "Authorization: Bearer " -``` - -### Отправить письмо - -```bash -curl -X POST http://localhost:8080/api/send \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -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 " -``` - -### Удалить письмо - -```bash -curl -X DELETE http://localhost:8080/api/messages/1 \ - -H "Authorization: Bearer " -``` - -### Загрузить вложение к письму - -```bash -curl -X POST http://localhost:8080/api/messages/1/attachments \ - -H "Authorization: Bearer " \ - -F "file=@/path/to/file.pdf" -``` - -### Получить список вложений письма - -```bash -curl http://localhost:8080/api/messages/1/attachments \ - -H "Authorization: Bearer " -``` - -### Скачать вложение - -```bash -curl http://localhost:8080/api/attachments/1/download \ - -H "Authorization: Bearer " \ - -o file.pdf -``` - -### Удалить вложение - -```bash -curl -X DELETE http://localhost:8080/api/attachments/1 \ - -H "Authorization: Bearer " -``` - -### Поиск по письмам - -Поиск поддерживает два режима: простой поиск по ключевому слову и расширенный поиск с отдельными фильтрами. - -**Простой поиск** — ищет по ключевому слову в теме, теле, отправителе и получателе: - -```bash -curl "http://localhost:8080/api/messages/search?folderId=1&keyword=hello" \ - -H "Authorization: Bearer " -``` - -**Расширенный поиск** — используйте отдельные параметры для точной фильтрации: - -```bash -# Поиск по теме -curl "http://localhost:8080/api/messages/search?folderId=1&subject=report" \ - -H "Authorization: Bearer " - -# Поиск по тексту письма -curl "http://localhost:8080/api/messages/search?folderId=1&body=invoice" \ - -H "Authorization: Bearer " - -# Поиск по отправителю -curl "http://localhost:8080/api/messages/search?folderId=1&sender=company.com" \ - -H "Authorization: Bearer " - -# Комбинированный поиск (все фильтры применяются одновременно) -curl "http://localhost:8080/api/messages/search?folderId=1&subject=report&sender=company.com" \ - -H "Authorization: Bearer " -``` - -Все параметры поиска (`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 " -``` - -**Добавить отправителя в чёрный список:** - -```bash -curl -X POST http://localhost:8080/api/spam/blacklist \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -d '{"senderEmail":"spammer@example.com","reason":"Спам"}' -``` - -**Удалить отправителя из чёрного списка:** - -```bash -curl -X DELETE "http://localhost:8080/api/spam/blacklist?senderEmail=spammer@example.com" \ - -H "Authorization: Bearer " -``` - -Письма от заблокированных отправителей автоматически получают флаг `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.` - -**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: -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). ## Лицензия diff --git a/backend/Dockerfile b/backend/Dockerfile index 5dabb70..63f1fb6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,17 +1,32 @@ -# Stage 1: Build +# Build context: repository root (docker compose: context ".", dockerfile backend/Dockerfile) + +# 1. Web client (Angular) — served by the backend from classpath:/static +FROM node:22-alpine 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-alpine AS build WORKDIR /app -COPY backend/build.gradle ./ -COPY gradlew ./ +COPY gradlew settings.gradle ./ COPY gradle gradle -COPY backend/src src -RUN chmod +x gradlew -RUN ./gradlew bootJar --no-daemon +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 -# Stage 2: Run +# 3. Runtime: not root; data in /app/data (volume) FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl && addgroup -S mail && adduser -S -G mail mail \ + && mkdir -p /app/data/attachments /app/data/dkim-keys && chown -R mail:mail /app/data WORKDIR /app -COPY --from=build /app/build/libs/*.jar app.jar -RUN mkdir -p /app/attachments /app/dkim-keys +COPY --from=build /app/backend/build/libs/*.jar app.jar +USER mail +ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys 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"] diff --git a/backend/build.gradle b/backend/build.gradle index 79ab7e3..9533f51 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -23,6 +23,8 @@ dependencies { // R2DBC — реактивный доступ к PostgreSQL (вместо JPA/Hibernate) implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc' implementation 'org.springframework.boot:spring-boot-starter-validation' + // health для проверки контейнера (только /actuator/health) + implementation 'org.springframework.boot:spring-boot-starter-actuator' // SMTP сервер на Spring — встроенный, без внешних зависимостей implementation 'org.eclipse.angus:angus-mail:2.0.3' implementation 'org.springframework.boot:spring-boot-starter-security' @@ -35,6 +37,12 @@ dependencies { implementation 'org.bouncycastle:bcpkix-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 драйвер runtimeOnly 'org.postgresql:r2dbc-postgresql:1.0.4.RELEASE' @@ -43,10 +51,15 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' 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' } +// only the executable jar (the plain jar would make two files in build/libs) +tasks.named('jar') { + enabled = false +} + tasks.named('test') { useJUnitPlatform() } diff --git a/backend/src/main/java/com/mailserver/config/DataInitializer.java b/backend/src/main/java/com/mailserver/config/DataInitializer.java index a3e4f79..f7f7af5 100644 --- a/backend/src/main/java/com/mailserver/config/DataInitializer.java +++ b/backend/src/main/java/com/mailserver/config/DataInitializer.java @@ -4,24 +4,46 @@ import com.mailserver.repository.UserRepository; import com.mailserver.service.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; 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@<first domain>) with + * {@code mailserver.admin.password}; no password given — a random one, printed to the log once. + */ @Configuration public class DataInitializer { private static final Logger log = LoggerFactory.getLogger(DataInitializer.class); @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 -> { - userRepository.count().block(); // Ждём инициализации - if (userRepository.count().block() == 0) { - log.info("База данных пуста, создаю тестового пользователя..."); - userService.register("admin@mailserver.local", "admin123", "Администратор").block(); - log.info("✓ Тестовый пользователь создан: admin@mailserver.local / admin123"); + if (userRepository.count().block() > 0) { + return; + } + String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase(); + 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); + } } diff --git a/backend/src/main/java/com/mailserver/config/JwtConfig.java b/backend/src/main/java/com/mailserver/config/JwtConfig.java index e20fd42..6d531a0 100644 --- a/backend/src/main/java/com/mailserver/config/JwtConfig.java +++ b/backend/src/main/java/com/mailserver/config/JwtConfig.java @@ -5,14 +5,17 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.nio.charset.StandardCharsets; + @Configuration public class JwtConfig { - @Value("${jwt.secret:MailServerSecretKeyForJWTTokenGenerationMustBeLongEnough2024!}") - private String jwtSecret; - + /** JWT_SECRET from the environment, at least 32 bytes; no default — a known key would let anyone log in. */ @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); } } diff --git a/backend/src/main/java/com/mailserver/config/MailDomains.java b/backend/src/main/java/com/mailserver/config/MailDomains.java new file mode 100644 index 0000000..94cb4c4 --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/MailDomains.java @@ -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 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 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); + } +} diff --git a/backend/src/main/java/com/mailserver/config/MailServerConfig.java b/backend/src/main/java/com/mailserver/config/MailServerConfig.java new file mode 100644 index 0000000..f708a37 --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/MailServerConfig.java @@ -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)); + } +} diff --git a/backend/src/main/java/com/mailserver/config/SecurityConfig.java b/backend/src/main/java/com/mailserver/config/SecurityConfig.java index a159237..f05809b 100644 --- a/backend/src/main/java/com/mailserver/config/SecurityConfig.java +++ b/backend/src/main/java/com/mailserver/config/SecurityConfig.java @@ -16,15 +16,19 @@ import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; public class SecurityConfig { 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.corsOrigins = corsOrigins; } @Bean public CorsConfigurationSource corsConfigurationSource() { 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.setAllowedHeaders(java.util.Arrays.asList("Authorization", "Content-Type", "Accept", "X-Requested-With")); configuration.setAllowCredentials(true); @@ -40,8 +44,10 @@ public class SecurityConfig { .csrf(ServerHttpSecurity.CsrfSpec::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeExchange(exchange -> exchange - .pathMatchers("/api/login", "/api/register").permitAll() - .anyExchange().authenticated() + .pathMatchers("/api/login", "/api/register", "/actuator/health").permitAll() + .pathMatchers("/api/**", "/actuator/**").authenticated() + // the web client (static files, SPA routes) + .anyExchange().permitAll() ) .addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION); diff --git a/backend/src/main/java/com/mailserver/config/SmtpServerConfig.java b/backend/src/main/java/com/mailserver/config/SmtpServerConfig.java deleted file mode 100644 index 43d232e..0000000 --- a/backend/src/main/java/com/mailserver/config/SmtpServerConfig.java +++ /dev/null @@ -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 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 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:
\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:
\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 .\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 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(" 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 recipients = new ArrayList<>(); - boolean dataReady = false; - StringBuilder dataBody = new StringBuilder(); - - void reset() { - mailFrom = null; - recipients.clear(); - dataReady = false; - dataBody.setLength(0); - } - } -} diff --git a/backend/src/main/java/com/mailserver/config/SpaWebFilter.java b/backend/src/main/java/com/mailserver/config/SpaWebFilter.java new file mode 100644 index 0000000..28cade1 --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/SpaWebFilter.java @@ -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 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); + } +} diff --git a/backend/src/main/java/com/mailserver/controller/MailController.java b/backend/src/main/java/com/mailserver/controller/MailController.java index c1494ab..a5dc521 100644 --- a/backend/src/main/java/com/mailserver/controller/MailController.java +++ b/backend/src/main/java/com/mailserver/controller/MailController.java @@ -16,6 +16,10 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; 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.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -46,10 +50,18 @@ public class MailController { private final UserService userService; 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.mailService = mailService; + this.submissionService = submissionService; + this.domains = domains; + this.registrationEnabled = registrationEnabled; } private Mono getCurrentUserEmail(Mono authenticationMono) { @@ -59,9 +71,15 @@ public class MailController { // --- Users --- @PostMapping("/register") - public Mono register(@Valid @RequestBody Mono requestMono) { - return requestMono.flatMap(request -> - userService.register(request.getEmail(), request.getPassword(), request.getDisplayName()) + public Mono> register(@Valid @RequestBody Mono requestMono) { + if (!registrationEnabled) { + 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) .body(Map.of("message", "User registered"))) .onErrorResume(IllegalArgumentException.class, e -> @@ -124,25 +142,15 @@ public class MailController { } @PostMapping("/send") - public Mono sendMessage( + public Mono sendMessage( Mono authenticationMono, @Valid @RequestBody Mono requestMono) { return authenticationMono.zipWith(requestMono) - .flatMap(pair -> { - 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()))); - }); - }); + .flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2())); } @PutMapping("/messages/{id}/read") - public Mono markAsRead(@PathVariable Long id, Mono authenticationMono) { + public Mono> markAsRead(@PathVariable Long id, Mono authenticationMono) { return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName()) .flatMap(user -> mailService.markAsRead(id, user.getId()) @@ -151,7 +159,7 @@ public class MailController { } @DeleteMapping("/messages/{id}") - public Mono deleteMessage(@PathVariable Long id, Mono authenticationMono) { + public Mono> deleteMessage(@PathVariable Long id, Mono authenticationMono) { return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName()) .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> 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 --- @PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public Mono uploadAttachment( + public Mono> uploadAttachment( @PathVariable Long messageId, Mono authenticationMono, @RequestBody Mono> filePartsMono) { @@ -192,7 +181,7 @@ public class MailController { .flatMapMany(user -> pair.getT2()) .next() .flatMap(file -> { - String originalFilename = file.filename(); + String originalFilename = safeFilename(file.filename()); String uniqueName = UUID.randomUUID() + "_" + originalFilename; Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName); try { @@ -214,7 +203,8 @@ public class MailController { return userService.findByEmail(email) .flatMap(user -> mailService.saveAttachmentReactive( messageId, user.getId(), - originalFilename, file.headers().getContentType().toString(), + originalFilename, + file.headers().getContentType() != null ? file.headers().getContentType().toString() : "application/octet-stream", bytes.length, filePath.toString())) .map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response)); }); @@ -233,39 +223,40 @@ public class MailController { } @GetMapping("/attachments/{attachmentId}/download") - public Mono downloadAttachment( + public Mono> downloadAttachment( @PathVariable Long attachmentId, Mono authenticationMono) { - return authenticationMono.flatMap(auth -> - userService.findByEmail(auth.getName()) - .flatMap(user -> { - try { - AttachmentResponse info = mailService.getAttachmentInfo(attachmentId, user.getId()); - Path filePath = Paths.get(mailService.getAttachmentFilePath(attachmentId, user.getId())); - byte[] bytes; - try { - bytes = Files.readAllBytes(filePath); - } 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() - .header(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + info.getFilename() + "\"") - .contentType(MediaType.parseMediaType( - info.getContentType() != null ? info.getContentType() : "application/octet-stream")) - .body(buffer) - ); - } catch (IllegalArgumentException e) { - return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))); - } - }) - ); + return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName())) + .flatMap(user -> mailService.getAttachment(attachmentId, user.getId())) + .flatMap(attachment -> Mono.fromCallable(() -> Files.readAllBytes(Paths.get(attachment.getFilePath()))) + .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()) + .map(bytes -> ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() + .filename(attachment.getFilename(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(contentType(attachment.getContentType())) + .body(bytes))); + } + + private static MediaType contentType(String value) { + try { + return value == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(value); + } catch (org.springframework.util.InvalidMimeTypeException e) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } + + /** 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}") - public Mono deleteAttachment( + public Mono> deleteAttachment( @PathVariable Long attachmentId, Mono authenticationMono) { return authenticationMono.flatMap(auth -> @@ -333,7 +324,7 @@ public class MailController { } @DeleteMapping("/contacts/{id}") - public Mono deleteContact( + public Mono> deleteContact( @PathVariable Long id, Mono authenticationMono) { return authenticationMono.flatMap(auth -> diff --git a/backend/src/main/java/com/mailserver/delivery/DeliveryWorker.java b/backend/src/main/java/com/mailserver/delivery/DeliveryWorker.java new file mode 100644 index 0000000..e2a0139 --- /dev/null +++ b/backend/src/main/java/com/mailserver/delivery/DeliveryWorker.java @@ -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 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 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); + } +} diff --git a/backend/src/main/java/com/mailserver/delivery/OutboundMessage.java b/backend/src/main/java/com/mailserver/delivery/OutboundMessage.java new file mode 100644 index 0000000..c633ecd --- /dev/null +++ b/backend/src/main/java/com/mailserver/delivery/OutboundMessage.java @@ -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; } +} diff --git a/backend/src/main/java/com/mailserver/delivery/OutboundQueue.java b/backend/src/main/java/com/mailserver/delivery/OutboundQueue.java new file mode 100644 index 0000000..7574f47 --- /dev/null +++ b/backend/src/main/java/com/mailserver/delivery/OutboundQueue.java @@ -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 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); + } +} diff --git a/backend/src/main/java/com/mailserver/delivery/OutboundRepository.java b/backend/src/main/java/com/mailserver/delivery/OutboundRepository.java new file mode 100644 index 0000000..d9e9774 --- /dev/null +++ b/backend/src/main/java/com/mailserver/delivery/OutboundRepository.java @@ -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 { + + @Query("SELECT * FROM outbound_queue WHERE status = 'PENDING' AND next_attempt_at <= :now ORDER BY next_attempt_at LIMIT :limit") + Flux findDue(Instant now, int limit); +} diff --git a/backend/src/main/java/com/mailserver/delivery/SmtpDeliveryClient.java b/backend/src/main/java/com/mailserver/delivery/SmtpDeliveryClient.java new file mode 100644 index 0000000..2696f60 --- /dev/null +++ b/backend/src/main/java/com/mailserver/delivery/SmtpDeliveryClient.java @@ -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. + *
    + *
  • mx — 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};
  • + *
  • smarthost — everything through one provider (Resend, SES, …) with login and verified TLS;
  • + *
  • off — nothing leaves the server.
  • + *
+ * 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 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 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 dnsMx(String domain) throws TemporaryDnsException { + try { + Lookup lookup = new Lookup(domain, Type.MX); + Record[] records = lookup.run(); + switch (lookup.getResult()) { + case Lookup.SUCCESSFUL -> { + List 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; + } + } +} diff --git a/backend/src/main/java/com/mailserver/dev/DevController.java b/backend/src/main/java/com/mailserver/dev/DevController.java new file mode 100644 index 0000000..d57481f --- /dev/null +++ b/backend/src/main/java/com/mailserver/dev/DevController.java @@ -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>> simulateReceive(@RequestBody Map 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()))))); + } +} diff --git a/backend/src/main/java/com/mailserver/service/DkimService.java b/backend/src/main/java/com/mailserver/service/DkimService.java index 1db7b87..74a502f 100644 --- a/backend/src/main/java/com/mailserver/service/DkimService.java +++ b/backend/src/main/java/com/mailserver/service/DkimService.java @@ -1,458 +1,209 @@ 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.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; 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.Path; import java.nio.file.Paths; -import java.security.*; -import java.security.spec.InvalidKeySpecException; -import java.security.cert.CertificateException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.KeyFactory; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.RSAPublicKeySpec; import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Properties; /** - * Сервис для DKIM (DomainKeys Identified Mail) подписи и верификации писем. - * - * DKIM добавляет криптографическую подпись к каждому исходящему письму, - * чтобы получатель мог проверить, что письмо действительно отправлено - * владельцем домена и не было изменено в пути. - * - * При старте генерирует RSA-ключ (2048 бит) и сохраняет его в файл. - * Подписывает заголовки From, Subject, Date, To и тело сообщения. + * 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 + * 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()}. */ @Service public class DkimService { 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 String domain; + private final 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 String keysDir; - - @Value("${mailserver.dkim.enabled:true}") - private boolean enabled; - - private PrivateKey privateKey; - private PublicKey publicKey; + private RSAPrivateKey privateKey; 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 public void init() { - Security.addProvider(new BouncyCastleProvider()); - if (!enabled) { - log.info("DKIM подписи отключены"); + log.info("DKIM signing disabled"); return; } - try { - Path keyDir = Paths.get(keysDir); - Files.createDirectories(keyDir); - - Path privateKeyFile = keyDir.resolve("dkim-private.pem"); - Path publicKeyFile = keyDir.resolve("dkim-public.pem"); - - if (Files.exists(privateKeyFile) && Files.exists(publicKeyFile)) { - // Загружаем существующие ключи - loadKeys(privateKeyFile, publicKeyFile); - log.info("DKIM: загружены существующие ключи для домена {}", domain); + Files.createDirectories(keysDir); + Path keyFile = keysDir.resolve("dkim-private.pem"); + if (Files.exists(keyFile)) { + privateKey = readPrivateKey(Files.readString(keyFile)); + log.info("DKIM: key loaded for {}", domain); } else { - // Генерируем новые ключи - KeyPair keyPair = generateKeyPair(); - privateKey = keyPair.getPrivate(); - publicKey = keyPair.getPublic(); - saveKeys(privateKeyFile, publicKeyFile, keyPair); - log.info("DKIM: сгенерированы новые ключи для домена {}", domain); + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + privateKey = (RSAPrivateKey) generator.generateKeyPair().getPrivate(); + String pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(privateKey.getEncoded()) + + "\n-----END PRIVATE KEY-----\n"; + Files.writeString(keyFile, pem); + restrict(keyFile); + log.info("DKIM: new key generated for {}", domain); } - - // Формируем публичный ключ в формате DNS TXT-записи - publicKeyBase64 = encodePublicKeyForDns(publicKey); - log.info("DKIM публичный ключ для DNS TXT-записи (_{}._domainkey.{}):", DKIM_SELECTOR, domain); - log.info("v=DKIM1; k=rsa; p={}", publicKeyBase64); - + publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyOf(privateKey)); + log.info("DKIM DNS record — TXT {}._domainkey.{}: {}", selector, domain, dnsRecord()); } catch (Exception e) { - log.error("Ошибка инициализации DKIM", e); + throw new IllegalStateException("DKIM key cannot be loaded from " + keysDir, e); } } - /** - * Генерирует RSA-ключ 2048 бит для DKIM. - */ - private KeyPair generateKeyPair() throws NoSuchAlgorithmException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); - keyPairGenerator.initialize(2048); - return keyPairGenerator.generateKeyPair(); + /** TXT value to publish at {@code ._domainkey.}. */ + public String dnsRecord() { + return publicKeyBase64 == null ? null : "v=DKIM1; k=rsa; p=" + publicKeyBase64; } /** - * Сохраняет ключи в PEM-формат. + * Signs the message when it is from our domain; otherwise returns it unchanged. + * The result is the exact byte sequence to send — do not re-serialize it. */ - private void saveKeys(Path privateKeyFile, Path publicKeyFile, KeyPair keyPair) - 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 { + public byte[] sign(byte[] message) throws MessagingException, IOException { 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 { - // Извлекаем заголовки для подписи - 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); - - // Формируем канонизированное тело (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"); + List valid; + synchronized (verifier) { // DKIMVerifier keeps results between calls + verifier.resetResults(); + valid = verifier.verify(new ByteArrayInputStream(message)); } - - // Вычисляем хэш тела - byte[] bodyHash = hashBody(canonicalBody); - - // Формируем signing input - String headerCanon = headersToSign.toString(); - byte[] signingInput = (headerCanon + "bh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash) + "\rbh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash)).getBytes(); - - // Подписываем - Signature signature = Signature.getInstance("SHA256withRSA", "BC"); - signature.initSign(privateKey); - signature.update(signingInput); - byte[] signatureBytes = signature.sign(); - - // Формируем 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); - // Не бросаем исключение — письмо будет отправлено без подписи + if (valid == null) { + return Verification.NONE; + } + if (valid.isEmpty()) { + return new Verification(true, false, null); + } + return new Verification(true, true, valid.get(0).getDToken().toString().toLowerCase(Locale.ROOT)); + } catch (FailException e) { + log.info("DKIM: signature failed: {}", e.getMessage()); + return new Verification(true, false, null); + } catch (IOException e) { + log.warn("DKIM: message cannot be read: {}", e.getMessage()); + return new Verification(true, false, null); } } - /** - * Верифицирует DKIM-подпись входящего сообщения. - * - * @param mimeMessage MIME-сообщение с DKIM-подписью - * @return true если подпись валидна, false если подпись отсутствует или невалидна - */ - public boolean verifyMessage(MimeMessage mimeMessage) { - if (!enabled) { - return true; // Если DKIM отключён, считаем все письма валидными - } + static String domainOf(String address) { + int at = address == null ? -1 : address.lastIndexOf('@'); + return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT); + } + 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 { - String dkimHeader = mimeMessage.getHeader("DKIM-Signature")[0]; - if (dkimHeader == null || dkimHeader.isEmpty()) { - log.debug("DKIM: подпись не найдена в сообщении"); - return false; - } - - // Извлекаем домен из подписи - String domain = extractDkimValue(dkimHeader, "d="); - if (domain == null || domain.isEmpty()) { - log.warn("DKIM: не удалось извлечь домен из подписи"); - return false; - } - - // В реальном мире здесь мы бы: - // 1. Сделали DNS-запрос для _domainkey. - // 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; + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------")); + } catch (UnsupportedOperationException | IOException ignored) { + // not a POSIX file system } } - - /** - * Верифицирует подпись локального домена. - */ - 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; - } } diff --git a/backend/src/main/java/com/mailserver/service/LocalDelivery.java b/backend/src/main/java/com/mailserver/service/LocalDelivery.java new file mode 100644 index 0000000..52c9014 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/LocalDelivery.java @@ -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; + } +} diff --git a/backend/src/main/java/com/mailserver/service/MailService.java b/backend/src/main/java/com/mailserver/service/MailService.java index 166e8c8..732e1f6 100644 --- a/backend/src/main/java/com/mailserver/service/MailService.java +++ b/backend/src/main/java/com/mailserver/service/MailService.java @@ -7,7 +7,6 @@ import com.mailserver.domain.Message; import com.mailserver.domain.User; import com.mailserver.dto.AttachmentResponse; import com.mailserver.dto.MessageResponse; -import com.mailserver.dto.SendRequest; import com.mailserver.repository.AttachmentRepository; import com.mailserver.repository.ContactRepository; import com.mailserver.repository.FolderRepository; @@ -15,16 +14,14 @@ import com.mailserver.repository.MessageRepository; import com.mailserver.repository.UserRepository; import org.slf4j.Logger; 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.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; import java.io.*; import java.nio.charset.StandardCharsets; @@ -36,8 +33,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Properties; import java.util.UUID; -import reactor.netty.Connection; -import reactor.netty.tcp.TcpClient; @Service public class MailService { @@ -50,64 +45,44 @@ public class MailService { private final AttachmentRepository attachmentRepository; private final ContactRepository contactRepository; private final SpamFilterService spamFilterService; - private final DkimService dkimService; @Value("${mailserver.attachments.dir:./attachments}") 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, UserRepository userRepository, AttachmentRepository attachmentRepository, ContactRepository contactRepository, - SpamFilterService spamFilterService, - DkimService dkimService) { + SpamFilterService spamFilterService) { this.messageRepository = messageRepository; this.folderRepository = folderRepository; this.userRepository = userRepository; this.attachmentRepository = attachmentRepository; this.contactRepository = contactRepository; this.spamFilterService = spamFilterService; - this.dkimService = dkimService; } - public Mono sendMessage(String fromEmail, SendRequest request) { - return userRepository.findByEmail(fromEmail) - .switchIfEmpty(Mono.error(new IllegalArgumentException("Sender not found: " + fromEmail))) - .flatMap(fromUser -> - folderRepository.findByUserIdAndName(fromUser.getId(), "Sent") - .switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found"))) - .flatMap(sentFolder -> { - Message message = new Message(); - message.setFolderId(sentFolder.getId()); - message.setSenderEmail(fromEmail); - message.setSenderName(fromUser.getDisplayName()); - message.setRecipientEmail(request.getTo()); - message.setSubject(request.getSubject()); - message.setBody(request.getBody()); - message.setHtml(request.isHtml()); - message.setRead(true); - message.setReceivedAt(Instant.now()); - message.setMessageId(UUID.randomUUID().toString()); - message.setMessageSize((long) (request.getSubject().length() + request.getBody().length())); - return messageRepository.save(message); - }) - .flatMap(message -> - sendViaSmtp(fromEmail, request.getTo(), request.getSubject(), request.getBody(), - request.isHtml(), message.getMessageId()) - .doOnSuccess(v -> log.info("Email sent via SMTP: {} -> {}", fromEmail, request.getTo())) - .onErrorResume(e -> { - log.error("SMTP send failed: {}", e.getMessage(), e); - return Mono.empty(); - }) - .thenReturn(message) - ) - .map(this::toResponse) - ); + /** Copy of a message we sent, in the sender's "Sent" folder. */ + public Mono saveSent(User fromUser, String to, String subject, String body, boolean html, String messageId) { + return folderRepository.findByUserIdAndName(fromUser.getId(), "Sent") + .switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found"))) + .flatMap(sentFolder -> { + Message message = new Message(); + message.setFolderId(sentFolder.getId()); + message.setSenderEmail(fromUser.getEmail()); + message.setSenderName(fromUser.getDisplayName()); + message.setRecipientEmail(to); + message.setSubject(subject); + message.setBody(body); + message.setHtml(html); + message.setRead(true); + message.setReceivedAt(Instant.now()); + message.setMessageId(messageId); + message.setMessageSize((long) (subject.length() + body.length())); + return messageRepository.save(message); + }) + .map(this::toResponse); } public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, @@ -149,13 +124,15 @@ public class MailService { public Flux getMessages(Long folderId, Long userId, int page, int size) { 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); } public Flux searchMessages(Long folderId, Long userId, String keyword, int page, int size) { 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); } @@ -164,17 +141,17 @@ public class MailService { String sender, String recipient, int page, int size) { 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); } public Mono getMessage(Long messageId, Long userId) { - return messageRepository.findById(messageId) - .map(this::toResponse); + return ownedMessage(messageId, userId).map(this::toResponse); } public Mono markAsRead(Long messageId, Long userId) { - return messageRepository.findById(messageId) + return ownedMessage(messageId, userId) .flatMap(message -> { message.setRead(true); return messageRepository.save(message).then(); @@ -182,7 +159,29 @@ public class MailService { } public Mono 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 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 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 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) { @@ -206,7 +205,7 @@ public class MailService { public Mono saveAttachmentReactive(Long messageId, Long userId, String originalFilename, String contentType, long sizeBytes, String filePath) { - return messageRepository.findById(messageId) + return ownedMessage(messageId, userId) .flatMap(message -> { Attachment attachment = new Attachment(); attachment.setMessageId(messageId); @@ -223,30 +222,24 @@ public class MailService { return attachmentsDir; } - public String getAttachmentFilePath(Long attachmentId, Long userId) { - return attachmentRepository.findById(attachmentId) - .map(Attachment::getFilePath) - .block(); - } - - public AttachmentResponse getAttachmentInfo(Long attachmentId, Long userId) { - return attachmentRepository.findById(attachmentId) - .map(this::toAttachmentResponse) - .block(); + /** The attachment record (file path, name, type) — only the owner's. */ + public Mono getAttachment(Long attachmentId, Long userId) { + return ownedAttachment(attachmentId, userId); } public Flux getMessageAttachments(Long messageId, Long userId) { - return attachmentRepository.findByMessageId(messageId) + return ownedMessage(messageId, userId) + .flatMapMany(message -> attachmentRepository.findByMessageId(message.getId())) .map(this::toAttachmentResponse); } public Mono deleteAttachment(Long attachmentId, Long userId) { - return attachmentRepository.findById(attachmentId) + return ownedAttachment(attachmentId, userId) .flatMap(attachment -> { try { Files.deleteIfExists(Paths.get(attachment.getFilePath())); } catch (IOException e) { - // Игнорируем + log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage()); } return attachmentRepository.delete(attachment); }); @@ -261,132 +254,6 @@ public class MailService { return resp; } - private Mono 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 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 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 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 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 sendAndAwait(Connection connection, String command, - Sinks.Many responseSink, - String expectedCode) { - log.debug("SMTP клиент команда: {}", command.trim()); - - // Отправляем команду - Mono send = connection.outbound() - .sendString(Mono.just(command)) - .then(); - - // Ждём ответ из sink — каждый вызов получает свой Flux подписчика - Mono 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 --- public Flux getContacts(Long userId) { diff --git a/backend/src/main/java/com/mailserver/service/SubmissionService.java b/backend/src/main/java/com/mailserver/service/SubmissionService.java new file mode 100644 index 0000000..532260b --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/SubmissionService.java @@ -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 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))); + } +} diff --git a/backend/src/main/java/com/mailserver/smtp/MailRouter.java b/backend/src/main/java/com/mailserver/smtp/MailRouter.java new file mode 100644 index 0000000..7d55efd --- /dev/null +++ b/backend/src/main/java/com/mailserver/smtp/MailRouter.java @@ -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); + } + } + } +} diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java b/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java new file mode 100644 index 0000000..669a5e9 --- /dev/null +++ b/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java @@ -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 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; +} diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java new file mode 100644 index 0000000..987fe99 --- /dev/null +++ b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java @@ -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. + *
    + *
  • input is split into lines by {@link LineBasedFrameDecoder} — a network packet may hold half a line or many + * pipelined commands, both work;
  • + *
  • 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;
  • + *
  • the message is stored or queued before the client gets 250 — on failure 451 and the client retries;
  • + *
  • database and DNS work runs on a worker thread, not on the event loop.
  • + *
+ * 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 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 recipients = new ArrayList<>(); + final ByteArrayOutputStream data = new ByteArrayOutputStream(); + boolean dataTooBig; + int badCommands; + volatile boolean closing; + + Session(String remoteIp) { + this.remoteIp = remoteIp; + } + + Mono 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 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 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 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 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 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 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 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 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:
"); + } + 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 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:
"); + } + 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 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 ."); + } + + private Mono 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 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 reply(String line) { + return Mono.just(line + "\r\n"); + } + + private static Mono blocking(java.util.concurrent.Callable work) { + return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()); + } + + /** " 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; + } + } +} diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties new file mode 100644 index 0000000..cc0a671 --- /dev/null +++ b/backend/src/main/resources/application-dev.properties @@ -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 diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties deleted file mode 100644 index bf979d9..0000000 --- a/backend/src/main/resources/application-prod.properties +++ /dev/null @@ -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 diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 6036e63..75463d9 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,54 +1,71 @@ 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 +# Secrets: environment variables, or files in a config tree (file name = variable name, e.g. /run/mail-secrets/DB_PASSWORD). +# No defaults — the server does not start without them (dev profile: application-dev.properties). +spring.config.import=optional:configtree:${MAILSERVER_SECRETS_DIR:/run/mail-secrets}/ -# 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.url=jdbc:postgresql://localhost:5432/mailserver -spring.flyway.user=mail -spring.flyway.password=secret +spring.flyway.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mailserver} +spring.flyway.user=${DB_USER:mail} +spring.flyway.password=${DB_PASSWORD} spring.flyway.locations=classpath:db/migration spring.flyway.baseline-on-migrate=true -# Вложения -mailserver.attachments.dir=./attachments +jwt.secret=${JWT_SECRET} + +# 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-request-size=10MB -# SMTP-сервер для приёма входящих писем -mailserver.smtp.port=3025 -mailserver.smtp.hostname=localhost +# SMTP server (incoming mail and submission). AUTH without TLS only in a closed network (docker network, VM) +mailserver.smtp.port=${SMTP_PORT:3025} +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.port=1143 mailserver.imap.hostname=localhost -# DKIM — криптографическая подпись писем -mailserver.dkim.enabled=false -mailserver.dkim.domain=localhost -mailserver.dkim.keys-dir=./dkim-keys +# DKIM — signing of our mail; DNS record printed to the log at start +mailserver.dkim.enabled=${DKIM_ENABLED:true} +mailserver.dkim.domain=${DKIM_DOMAIN:localhost} +mailserver.dkim.selector=${DKIM_SELECTOR:mail} +mailserver.dkim.keys-dir=${DKIM_KEYS_DIR:./dkim-keys} -# SPF — проверка отправителей через DNS -mailserver.spf.enabled=false +# SPF / DMARC for incoming mail +mailserver.spf.enabled=${SPF_ENABLED:true} mailserver.spf.local-bypass=true - -# DMARC — политика обработки писем, не прошедших SPF/DKIM -mailserver.dmarc.enabled=false +mailserver.dmarc.enabled=${DMARC_ENABLED:true} mailserver.dmarc.local-bypass=true -# SMTP релей — доставка писем на внешнюю почту -# Порт 587 — submission с STARTTLS (для отправки через SMTP-сервер) -mailserver.relay.enabled=true -mailserver.relay.port=587 -mailserver.relay.starttls=true -mailserver.relay.auth-enabled=false -mailserver.relay.username= -mailserver.relay.password= +# error responses carry the reason ("No such mailbox", "Sending outside is disabled") for the web client +server.error.include-message=always -logging.level.com.mailserver=DEBUG +management.endpoints.web.exposure.include=health +management.endpoint.health.show-details=never + +logging.level.com.mailserver=INFO diff --git a/backend/src/main/resources/db/migration/V2__outbound_queue.sql b/backend/src/main/resources/db/migration/V2__outbound_queue.sql new file mode 100644 index 0000000..4fad700 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__outbound_queue.sql @@ -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'; diff --git a/backend/src/test/java/com/mailserver/controller/MailControllerTest.java b/backend/src/test/java/com/mailserver/controller/MailControllerTest.java new file mode 100644 index 0000000..d91929b --- /dev/null +++ b/backend/src/test/java/com/mailserver/controller/MailControllerTest.java @@ -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"); + } +} diff --git a/backend/src/test/java/com/mailserver/delivery/DeliveryWorkerTest.java b/backend/src/test/java/com/mailserver/delivery/DeliveryWorkerTest.java new file mode 100644 index 0000000..ee889e4 --- /dev/null +++ b/backend/src/test/java/com/mailserver/delivery/DeliveryWorkerTest.java @@ -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 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 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"); + } +} diff --git a/backend/src/test/java/com/mailserver/delivery/SmtpDeliveryClientTest.java b/backend/src/test/java/com/mailserver/delivery/SmtpDeliveryClientTest.java new file mode 100644 index 0000000..03468e4 --- /dev/null +++ b/backend/src/test/java/com/mailserver/delivery/SmtpDeliveryClientTest.java @@ -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 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(); + } +} diff --git a/backend/src/test/java/com/mailserver/service/DkimServiceTest.java b/backend/src/test/java/com/mailserver/service/DkimServiceTest.java new file mode 100644 index 0000000..c8ddf2a --- /dev/null +++ b/backend/src/test/java/com/mailserver/service/DkimServiceTest.java @@ -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 \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(); + } +} diff --git a/backend/src/test/java/com/mailserver/service/LocalDeliveryTest.java b/backend/src/test/java/com/mailserver/service/LocalDeliveryTest.java new file mode 100644 index 0000000..3c0bb70 --- /dev/null +++ b/backend/src/test/java/com/mailserver/service/LocalDeliveryTest.java @@ -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 +

html

\r + --a--\r + --m\r + Content-Type: text/html\r + Content-Disposition: attachment; filename="x.html"\r + \r +

attached

\r + --m--\r + """); + + LocalDelivery.Body body = LocalDelivery.body(message); + assertThat(body.html()).isTrue(); + assertThat(body.text()).contains("

html

").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"); + } +} diff --git a/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java b/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java new file mode 100644 index 0000000..75a565d --- /dev/null +++ b/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java @@ -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 passwords = Map.of("alice@example.test", "secret"); + final List mailboxes = List.of("alice@example.test", "bob@example.test"); + final List envelopes = new CopyOnWriteArrayList<>(); + final List 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:\r\nRCPT TO:\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:"); + assertThat(client.send("RCPT TO:")).startsWith("550 5.7.1 Relaying denied"); + assertThat(client.send("RCPT TO:")).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:")).startsWith("553"); + assertThat(client.send("MAIL FROM:")).startsWith("250"); + assertThat(client.send("RCPT TO:")).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:"); + client.send("RCPT TO:"); + client.send("DATA"); + assertThat(client.send("x".repeat(3000) + "\r\n.")).startsWith("552"); + assertThat(client.send("MAIL FROM: 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:"); + client.send("RCPT TO:"); + 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(" 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("")).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 readReplyLines() throws Exception { + List 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(); + } + } +} diff --git a/docker-compose.jambotron.yml b/docker-compose.jambotron.yml new file mode 100644 index 0000000..7be8a75 --- /dev/null +++ b/docker-compose.jambotron.yml @@ -0,0 +1,75 @@ +# 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@) 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 "mail" — readable inside these containers only + chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET + volumes: + - secrets:/secrets + + db: + image: postgres:17-alpine + 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: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 15d7318..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index c815e94..1edddc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: - 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: - image: postgres:17 + image: postgres:17-alpine environment: POSTGRES_DB: mailserver POSTGRES_USER: mail @@ -34,18 +10,7 @@ services: volumes: - pg-data:/var/lib/postgresql/data ports: - - "5433:5432" - - mailpit: - image: axllent/mailpit - ports: - - "1025:1025" # SMTP - - "8025:8025" # Web UI - volumes: - - mailpit-data:/data + - "127.0.0.1:5433:5432" volumes: - attachments: pg-data: - mailpit-data: - dkim-keys: diff --git a/frontend/angular.json b/frontend/angular.json index ebb9006..ba56fdc 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -87,7 +87,10 @@ "buildTarget": "frontend:build:development" } }, - "defaultConfiguration": "development" + "defaultConfiguration": "development", + "options": { + "proxyConfig": "proxy.conf.json" + } } } } diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 0000000..7d4976b --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://localhost:8080", + "secure": false + } +} diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index 6f9e4d8..cc696a2 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -9,7 +9,7 @@ import { LoginRequest, LoginResponse, RegisterRequest } from '../models/mail.mod }) export class AuthService { private http = inject(HttpClient); - private apiUrl = 'http://localhost:8080/api'; + private apiUrl = '/api'; private currentUserSubject = new BehaviorSubject( localStorage.getItem('email') diff --git a/frontend/src/app/services/mail.service.ts b/frontend/src/app/services/mail.service.ts index 6d99f19..924c0e4 100644 --- a/frontend/src/app/services/mail.service.ts +++ b/frontend/src/app/services/mail.service.ts @@ -8,7 +8,7 @@ import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse, }) export class MailService { private http = inject(HttpClient); - private apiUrl = 'http://localhost:8080/api'; + private apiUrl = '/api'; // Папки getFolders(): Observable { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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