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 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew old mode 100644 new mode 100755