commit 0339413d6ebd56a8981ead8e1092b75005744aa4 Author: Liosha84 Date: Thu Nov 5 01:07:29 2026 +0100 Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend Co-authored-by: Junie diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..457220d --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Java / Gradle +*.class +*.jar +*.war +*.ear +*.log +.gradle/ +build/ +**/build/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +out/ +.vscode/ + +# Node / Angular +node_modules/ +dist/ +**/dist/ +.angular/ +package-lock.json + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Temp +*.tmp +*.swp +*.swo +*~ + +# Docker +docker-compose.override.yml + +# Attachments (local dev) +attachments/ + +# DKIM keys (generate on each server) +dkim-keys/ + +# Environment variables +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..00fe240 --- /dev/null +++ b/README.md @@ -0,0 +1,394 @@ +# Mail Server + +Прототип почтового сервера на **Spring Boot + Angular** с REST API для управления письмами. + +## Архитектура + +``` +┌─────────────────────────────────────────────────────┐ +│ Angular Frontend │ +│ (веб-почта: список, чтение, написание, контакты) │ +└──────────────────────┬──────────────────────────────┘ + │ REST API (JSON) +┌──────────────────────▼──────────────────────────────┐ +│ Spring Boot Backend │ +│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ Users │ │ Messages │ │ Contacts │ │ +│ └────────────┘ └────────────┘ └──────────────┘ │ +│ ┌────────────┐ ┌────────────┐ │ +│ │ Folders │ │Attachments │ │ +│ └────────────┘ └────────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ JavaMailSender │ │ +│ │ (SMTP отправка)│ │ +│ └─────────────────┘ │ +└──────────────────────┬──────────────────────────────┘ + │ JPA +┌──────────────────────▼──────────────────────────────┐ +│ PostgreSQL 17 │ +└─────────────────────────────────────────────────────┘ +``` + +## Запуск + +### 1. Docker Compose (рекомендуется) + +```bash +cd mail-server +docker compose up --build +``` + +Сервисы: +- **REST API**: http://localhost:8080 +- **SMTP (приём)**: localhost:3025 +- **IMAP (доступ к письмам)**: localhost:143 +- **Mailpit UI**: http://localhost:8025 (SMTP на порту 1025) +- **PostgreSQL**: localhost:5432 + +### 2. Локально + +Требования: +- Java 21 +- PostgreSQL 17 + +```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 + +# Запустите Mailpit для тестовой отправки +docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit + +# Соберите и запустите бэкенд +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, телефон, компания | + +## TODO + +- [x] Хэширование паролей (BCrypt) +- [x] Аутентификация (JWT) +- [x] Приём SMTP (SubEtha SMTP встроенный сервер) +- [x] IMAP-сервер (встроенный, порт 143) +- [x] Вложения (загрузка/скачивание) +- [x] Поиск по письмам (по теме, телу, отправителю, получателю) +- [x] Angular фронтенд (v22 + Tailwind CSS + Synthwave дизайн) +- [x] Антиспам (чёрный список отправителей) +- [x] DKIM/SPF/DMARC (подпись, верификация, DNS-проверка) +- [ ] TLS шифрование + +## Лицензия + +MIT diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6781888 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jdk-alpine +WORKDIR /app +COPY build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 0000000..79ab7e3 --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.1' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.mailserver' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // WebFlux — реактивный HTTP сервер на Netty + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // R2DBC — реактивный доступ к PostgreSQL (вместо JPA/Hibernate) + implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc' + implementation 'org.springframework.boot:spring-boot-starter-validation' + // SMTP сервер на Spring — встроенный, без внешних зависимостей + implementation 'org.eclipse.angus:angus-mail:2.0.3' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-crypto' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Bouncy Castle — криптография для DKIM подписи и верификации + implementation 'org.bouncycastle:bcpkix-jdk18on:1.78.1' + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + + // R2DBC PostgreSQL драйвер + runtimeOnly 'org.postgresql:r2dbc-postgresql:1.0.4.RELEASE' + + // Flyway — миграции базы данных (требует JDBC драйвер) + implementation 'org.flywaydb:flyway-database-postgresql' + runtimeOnly 'org.postgresql:postgresql' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.icegreen:greenmail:2.0.1' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/backend/src/main/java/com/mailserver/MailServerApplication.java b/backend/src/main/java/com/mailserver/MailServerApplication.java new file mode 100644 index 0000000..0ff83bb --- /dev/null +++ b/backend/src/main/java/com/mailserver/MailServerApplication.java @@ -0,0 +1,12 @@ +package com.mailserver; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MailServerApplication { + + public static void main(String[] args) { + SpringApplication.run(MailServerApplication.class, args); + } +} diff --git a/backend/src/main/java/com/mailserver/config/DataInitializer.java b/backend/src/main/java/com/mailserver/config/DataInitializer.java new file mode 100644 index 0000000..a3e4f79 --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/DataInitializer.java @@ -0,0 +1,27 @@ +package com.mailserver.config; + +import com.mailserver.repository.UserRepository; +import com.mailserver.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DataInitializer { + + private static final Logger log = LoggerFactory.getLogger(DataInitializer.class); + + @Bean + public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository) { + 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"); + } + }; + } +} diff --git a/backend/src/main/java/com/mailserver/config/ImapServerConfig.java b/backend/src/main/java/com/mailserver/config/ImapServerConfig.java new file mode 100644 index 0000000..3dd8dbf --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/ImapServerConfig.java @@ -0,0 +1,580 @@ +package com.mailserver.config; + +import com.mailserver.repository.FolderRepository; +import com.mailserver.repository.MessageRepository; +import com.mailserver.repository.UserRepository; +import com.mailserver.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Встроенный IMAP-сервер для почтового сервера. + * Поддерживает основные команды IMAP4rev1: LOGIN, SELECT, FETCH, SEARCH, STORE, EXPUNGE, LOGOUT. + * Запускается при старте приложения и останавливается при завершении. + */ +@Configuration +public class ImapServerConfig { + + private static final Logger log = LoggerFactory.getLogger(ImapServerConfig.class); + + @Value("${mailserver.imap.enabled:true}") + private boolean enabled; + + @Value("${mailserver.imap.port:143}") + private int port; + + @Value("${mailserver.imap.hostname:localhost}") + private String hostname; + + private final UserRepository userRepository; + private final FolderRepository folderRepository; + private final MessageRepository messageRepository; + private final UserService userService; + + private ServerSocket serverSocket; + private ExecutorService executorService; + + public ImapServerConfig(UserRepository userRepository, FolderRepository folderRepository, + MessageRepository messageRepository, UserService userService) { + this.userRepository = userRepository; + this.folderRepository = folderRepository; + this.messageRepository = messageRepository; + this.userService = userService; + } + + @PostConstruct + public void start() { + if (!enabled) { + log.info("IMAP-сервер отключён (mailserver.imap.enabled=false)"); + return; + } + + executorService = Executors.newCachedThreadPool(); + + try { + serverSocket = new ServerSocket(port); + log.info("IMAP-сервер запущен на порту {} (хост: {})", port, hostname); + + // Принимаем подключения в цикле + while (!serverSocket.isClosed()) { + try { + Socket clientSocket = serverSocket.accept(); + executorService.submit(() -> handleClient(clientSocket)); + } catch (IOException e) { + if (!serverSocket.isClosed()) { + log.error("Ошибка при принятии подключения", e); + } + } + } + } catch (IOException e) { + if (serverSocket == null || !serverSocket.isClosed()) { + log.error("Не удалось запустить IMAP-сервер на порту {}", port, e); + } + } + } + + @PreDestroy + public void stop() { + log.info("Остановка IMAP-сервера..."); + try { + if (serverSocket != null && !serverSocket.isClosed()) { + serverSocket.close(); + } + } catch (IOException e) { + log.error("Ошибка при остановке IMAP-сервера", e); + } + + if (executorService != null) { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + log.info("IMAP-сервер остановлен"); + } + + private void handleClient(Socket socket) { + String clientAddress = socket.getInetAddress().getHostAddress(); + log.info("Новое IMAP-подключение от {}", clientAddress); + + try (socket; + PrintWriter out = new PrintWriter(socket.getOutputStream(), true); + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { + + // Отправляем приветствие + out.println("* OK IMAP4rev1 Server Ready"); + + String username = null; + String line; + + while (username == null && (line = in.readLine()) != null) { + String[] parts = parseCommand(line); + if (parts == null) continue; + + String tag = parts[0]; + String command = parts[1].toUpperCase(); + + switch (command) { + case "LOGIN" -> { + if (parts.length >= 4) { + String email = parts[2].toLowerCase(); + String password = parts[3]; + + // Проверяем пароль через BCrypt + com.mailserver.domain.User user = userRepository.findByEmail(email).block(); + if (user != null && userService.checkPassword(user, password)) { + username = email; + out.println(tag + " OK LOGIN completed"); + log.info("Пользователь {} успешно авторизован через IMAP", email); + } else { + out.println(tag + " NO LOGIN failed: invalid credentials"); + log.warn("Неудачная попытка IMAP-авторизации для пользователя {}", email); + } + } else { + out.println(tag + " BAD Invalid LOGIN command"); + } + } + case "CAPABILITY" -> { + out.println("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE LITERAL+"); + out.println(tag + " OK Capability completed"); + } + case "LOGOUT" -> { + out.println("* BYE IMAP4rev1 server terminating"); + out.println(tag + " OK LOGOUT completed"); + return; + } + default -> { + if (username == null) { + out.println(tag + " BAD Authentication required"); + } else { + handleAuthenticatedCommand(tag, command, parts, username, out, in); + } + } + } + } + } catch (IOException e) { + log.error("Ошибка при обработке IMAP-подключения от {}", clientAddress, e); + } finally { + log.info("IMAP-подключение от {} закрыто", clientAddress); + } + } + + private void handleAuthenticatedCommand(String tag, String command, String[] parts, + String username, PrintWriter out, BufferedReader in) { + switch (command) { + case "SELECT" -> handleSelect(tag, parts, username, out); + case "EXAMINE" -> handleSelect(tag, parts, username, out); // EXAMINE = SELECT в read-only режиме + case "FETCH" -> handleFetch(tag, parts, username, out); + case "SEARCH" -> handleSearch(tag, parts, username, out); + case "STORE" -> handleStore(tag, parts, username, out); + case "EXPUNGE" -> handleExpunge(tag, username, out); + case "LIST" -> handleList(tag, username, out); + case "LSUB" -> handleList(tag, username, out); // LSUB = LIST для подписок + case "STATUS" -> handleStatus(tag, parts, username, out); + case "ID" -> { + out.println("* ID (\"name\" \"MailServer IMAP\" \"version\" \"1.0\")"); + out.println(tag + " OK ID completed"); + } + default -> { + out.println(tag + " BAD Unknown command: " + command); + } + } + } + + private void handleSelect(String tag, String[] parts, String username, PrintWriter out) { + if (parts.length < 3) { + out.println(tag + " BAD Invalid SELECT command"); + return; + } + + String folderName = parts[2].replace("\"", ""); + com.mailserver.domain.User userTemp = userRepository.findByEmail(username).block(); + if (userTemp == null) { + out.println(tag + " NO User not found"); + return; + } + com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName( + userTemp.getId(), folderName).block(); + + if (folder == null) { + out.println(tag + " NO [NONEXISTENT] Mailbox does not exist"); + return; + } + + long messageCount = messageRepository.findByFolderId(folder.getId(), + org.springframework.data.domain.PageRequest.of(0, 1)).count().block(); + + out.println("* " + messageCount + " EXISTS"); + out.println("* 0 RECENT"); + out.println("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)"); + out.println(tag + " [OK] [UNSEEN 1] " + (messageCount > 0 ? "There are unseen messages in the mailbox." : "The mailbox has no unseen messages.")); + out.println(tag + " OK [READ-WRITE] SELECT completed"); + } + + private void handleFetch(String tag, String[] parts, String username, PrintWriter out) { + if (parts.length < 4) { + out.println(tag + " BAD Invalid FETCH command"); + return; + } + + String messageId = parts[2]; + String item = parts[3]; + + com.mailserver.domain.User user = userRepository.findByEmail(username).block(); + if (user == null) { + out.println(tag + " NO User not found"); + return; + } + + // Получаем все сообщения пользователя + java.util.List folders = folderRepository.findByUserId(user.getId()).collectList().block(); + java.util.List allMessages = new java.util.ArrayList<>(); + for (com.mailserver.domain.Folder folder : folders) { + allMessages.addAll(messageRepository.findByFolderId(folder.getId(), + org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block()); + } + + // Находим сообщение по UID или индексу + com.mailserver.domain.Message message = null; + if (item.contains("UID")) { + // UID FETCH + try { + long uid = Long.parseLong(messageId); + for (com.mailserver.domain.Message msg : allMessages) { + if (msg.getId().equals(uid)) { + message = msg; + break; + } + } + } catch (NumberFormatException e) { + out.println(tag + " BAD Invalid UID"); + return; + } + } else { + // FETCH по индексу (1-based) + try { + int index = Integer.parseInt(messageId) - 1; + if (index >= 0 && index < allMessages.size()) { + message = allMessages.get(index); + } + } catch (NumberFormatException e) { + out.println(tag + " BAD Invalid message set"); + return; + } + } + + if (message == null) { + out.println(tag + " NO Message not found"); + return; + } + + // Формируем ответ FETCH + StringBuilder flags = new StringBuilder(); + if (message.isRead()) flags.append("\\Seen "); + if (message.isFlagged()) flags.append("\\Flagged "); + if (message.isSpam()) flags.append("\\Deleted "); + if (message.getBody() != null && !message.getBody().isEmpty()) flags.append("\\Answered "); + + out.println("* " + messageId + " FETCH ("); + out.println(" UID " + message.getId()); + out.println(" FLAGS (" + flags.toString().trim() + ")"); + out.println(" INTERNALDATE \"" + formatImapDate(message.getReceivedAt()) + "\""); + out.println(" RFC822.SIZE " + (message.getMessageSize() != null ? message.getMessageSize() : 0)); + out.println(" ENVELOPE ("); + out.println(" \"" + escapeString(message.getReceivedAt() != null ? message.getReceivedAt().toString() : "") + "\""); + out.println(" \"" + escapeString(message.getSubject() != null ? message.getSubject() : "") + "\""); + out.println(" (\"" + escapeString(message.getSenderName() != null ? message.getSenderName() : "") + "\" 0 \"\" \"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")"); + out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")"); + out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")"); + out.println(" (\"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")"); + out.println(" NIL"); + out.println(" NIL"); + out.println(" NIL"); + out.println(" )"); + + // Если запрошено тело сообщения + if (item.contains("BODY[]") || item.contains("RFC822")) { + out.println(" BODY[] {"); + String body = message.getBody() != null ? message.getBody() : ""; + out.println(body.length()); + out.println(body); + out.println(")"); + } + + out.println(")"); + out.println(tag + " OK FETCH completed"); + } + + private void handleSearch(String tag, String[] parts, String username, PrintWriter out) { + com.mailserver.domain.User user = userRepository.findByEmail(username).block(); + if (user == null) { + out.println(tag + " NO User not found"); + return; + } + + com.mailserver.domain.Folder inbox = folderRepository.findByUserIdAndName(user.getId(), "INBOX").block(); + if (inbox == null) { + out.println(tag + " OK SEARCH completed"); + return; + } + + // Простой поиск по ключевому слову + String keyword = null; + for (int i = 2; i < parts.length; i++) { + if (!parts[i].equals("ALL") && !parts[i].equals("FROM") && !parts[i].equals("SUBJECT") && !parts[i].equals("BODY")) { + keyword = parts[i]; + break; + } + } + + java.util.List messageIds; + if (keyword != null && !keyword.isEmpty()) { + var results = messageRepository.searchInFolder(inbox.getId(), keyword, + org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block(); + messageIds = results.stream() + .map(com.mailserver.domain.Message::getId) + .collect(java.util.stream.Collectors.toList()); + } else { + var results = messageRepository.findByFolderId(inbox.getId(), + org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block(); + messageIds = results.stream() + .map(com.mailserver.domain.Message::getId) + .collect(java.util.stream.Collectors.toList()); + } + + StringBuilder ids = new StringBuilder(); + for (Long id : messageIds) { + ids.append(id).append(" "); + } + + out.println("* SEARCH " + ids.toString().trim()); + out.println(tag + " OK SEARCH completed"); + } + + private void handleStore(String tag, String[] parts, String username, PrintWriter out) { + if (parts.length < 5) { + out.println(tag + " BAD Invalid STORE command"); + return; + } + + String messageId = parts[2]; + String item = parts[3].toUpperCase(); + String value = parts[4].toUpperCase(); + + com.mailserver.domain.User user = userRepository.findByEmail(username).block(); + if (user == null) { + out.println(tag + " NO User not found"); + return; + } + + // Получаем все сообщения пользователя + java.util.List folders = folderRepository.findByUserId(user.getId()).collectList().block(); + java.util.List allMessages = new java.util.ArrayList<>(); + for (com.mailserver.domain.Folder folder : folders) { + allMessages.addAll(messageRepository.findByFolderId(folder.getId(), + org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block()); + } + + com.mailserver.domain.Message message = null; + try { + int index = Integer.parseInt(messageId) - 1; + if (index >= 0 && index < allMessages.size()) { + message = allMessages.get(index); + } + } catch (NumberFormatException e) { + out.println(tag + " BAD Invalid message set"); + return; + } + + if (message == null) { + out.println(tag + " NO Message not found"); + return; + } + + boolean saved = false; + if (item.contains("+FLAGS") || item.contains("FLAGS")) { + if (value.contains("\\SEEN")) { + message.setRead(true); + saved = true; + } + if (value.contains("\\FLAGGED")) { + message.setFlagged(true); + saved = true; + } + if (value.contains("\\DELETED")) { + message.setSpam(true); // Используем spam как флаг удаления для IMAP + saved = true; + } + } + + if (item.contains("-FLAGS")) { + if (value.contains("\\SEEN")) { + message.setRead(false); + saved = true; + } + if (value.contains("\\FLAGGED")) { + message.setFlagged(false); + saved = true; + } + } + + if (saved) { + messageRepository.save(message).block(); + } + + out.println("* " + messageId + " FETCH (FLAGS (" + formatFlags(message) + "))"); + out.println(tag + " OK STORE completed"); + } + + private void handleExpunge(String tag, String username, PrintWriter out) { + com.mailserver.domain.User user = userRepository.findByEmail(username).block(); + if (user == null) { + out.println(tag + " NO User not found"); + return; + } + + java.util.List folders = folderRepository.findByUserId(user.getId()).collectList().block(); + for (com.mailserver.domain.Folder folder : folders) { + var messages = messageRepository.findByFolderId(folder.getId(), + org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block(); + for (com.mailserver.domain.Message message : messages) { + if (message.isSpam()) { + messageRepository.delete(message).block(); + folder.setMessageCount(Math.max(0, folder.getMessageCount() - 1)); + folderRepository.save(folder).block(); + } + } + } + + out.println("* 0 EXPUNGE"); + out.println(tag + " OK EXPUNGE completed"); + } + + private void handleList(String tag, String username, PrintWriter out) { + com.mailserver.domain.User user = userRepository.findByEmail(username).block(); + if (user == null) { + out.println(tag + " NO User not found"); + return; + } + + java.util.List folders = folderRepository.findByUserId(user.getId()).collectList().block(); + for (com.mailserver.domain.Folder folder : folders) { + out.println("* LIST (\\HasNoChildren) \".\" \"" + folder.getName() + "\""); + } + + out.println(tag + " OK LIST completed"); + } + + private void handleStatus(String tag, String[] parts, String username, PrintWriter out) { + if (parts.length < 4) { + out.println(tag + " BAD Invalid STATUS command"); + return; + } + + String folderName = parts[2].replace("\"", ""); + com.mailserver.domain.User user = userRepository.findByEmail(username).block(); + if (user == null) { + out.println(tag + " NO User not found"); + return; + } + + com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName(user.getId(), folderName).block(); + if (folder == null) { + out.println(tag + " NO [NONEXISTENT] Mailbox does not exist"); + return; + } + + long messageCount = messageRepository.findByFolderId(folder.getId(), + org.springframework.data.domain.PageRequest.of(0, 1)).count().block(); + long unreadCount = messageRepository.countUnreadByFolderId(folder.getId()).block(); + + out.println("* STATUS " + folderName + " (MESSAGES " + messageCount + " UNSEEN " + unreadCount + " UIDNEXT " + (messageCount + 1) + " UIDVALIDITY 1)"); + out.println(tag + " OK STATUS completed"); + } + + private String[] parseCommand(String line) { + if (line == null || line.trim().isEmpty()) return null; + + line = line.trim(); + int spaceIndex = line.indexOf(' '); + if (spaceIndex == -1) return new String[]{line, ""}; + + String tag = line.substring(0, spaceIndex); + String rest = line.substring(spaceIndex + 1).trim(); + + // Разбираем команду и аргументы + String[] parts = new String[20]; // Максимум 20 аргументов + parts[0] = tag; + int index = 1; + + int i = 0; + while (i < rest.length() && index < parts.length) { + if (rest.charAt(i) == '"') { + // Строка в кавычках + int end = rest.indexOf('"', i + 1); + if (end == -1) break; + parts[index++] = rest.substring(i + 1, end); + i = end + 1; + } else if (Character.isWhitespace(rest.charAt(i))) { + i++; + } else { + int end = rest.indexOf(' ', i); + if (end == -1) end = rest.length(); + parts[index++] = rest.substring(i, end); + i = end; + } + } + + // Обрезаем массив до нужного размера + String[] result = new String[index]; + System.arraycopy(parts, 0, result, 0, index); + return result; + } + + private String formatImapDate(Instant instant) { + if (instant == null) return ""; + // Формат IMAP: "dd-MMM-yyyy hh:mm:ss +0000" + String[] months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + java.time.LocalDate date = instant.atZone(java.time.ZoneOffset.UTC).toLocalDate(); + java.time.LocalTime time = instant.atZone(java.time.ZoneOffset.UTC).toLocalTime(); + + return String.format("%02d-%s-%04d %02d:%02d:%02d +0000", + date.getDayOfMonth(), months[date.getMonthValue() - 1], date.getYear(), + time.getHour(), time.getMinute(), time.getSecond()); + } + + private String escapeString(String str) { + if (str == null) return ""; + return str.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private String formatFlags(com.mailserver.domain.Message message) { + StringBuilder flags = new StringBuilder(); + if (message.isRead()) flags.append("\\Seen "); + if (message.isFlagged()) flags.append("\\Flagged "); + if (message.isSpam()) flags.append("\\Deleted "); + return flags.toString().trim(); + } +} diff --git a/backend/src/main/java/com/mailserver/config/JwtConfig.java b/backend/src/main/java/com/mailserver/config/JwtConfig.java new file mode 100644 index 0000000..e20fd42 --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/JwtConfig.java @@ -0,0 +1,18 @@ +package com.mailserver.config; + +import com.mailserver.util.JwtUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JwtConfig { + + @Value("${jwt.secret:MailServerSecretKeyForJWTTokenGenerationMustBeLongEnough2024!}") + private String jwtSecret; + + @Bean + public JwtUtils jwtUtils() { + return new JwtUtils(jwtSecret); + } +} diff --git a/backend/src/main/java/com/mailserver/config/SecurityConfig.java b/backend/src/main/java/com/mailserver/config/SecurityConfig.java new file mode 100644 index 0000000..a159237 --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/SecurityConfig.java @@ -0,0 +1,50 @@ +package com.mailserver.config; + +import com.mailserver.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsConfigurationSource; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebFluxSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(java.util.Arrays.asList("http://localhost:4200", "http://localhost:4201")); + 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); + configuration.setExposedHeaders(java.util.Arrays.asList("Authorization")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + return source; + } + + @Bean + public SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .authorizeExchange(exchange -> exchange + .pathMatchers("/api/login", "/api/register").permitAll() + .anyExchange().authenticated() + ) + .addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION); + + return http.build(); + } +} diff --git a/backend/src/main/java/com/mailserver/config/SmtpServerConfig.java b/backend/src/main/java/com/mailserver/config/SmtpServerConfig.java new file mode 100644 index 0000000..43d232e --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/SmtpServerConfig.java @@ -0,0 +1,379 @@ +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/controller/AuthenticationController.java b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java new file mode 100644 index 0000000..658b663 --- /dev/null +++ b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java @@ -0,0 +1,53 @@ +package com.mailserver.controller; + +import com.mailserver.dto.LoginRequest; +import com.mailserver.dto.LoginResponse; +import com.mailserver.domain.User; +import com.mailserver.service.UserService; +import com.mailserver.util.JwtUtils; +import jakarta.validation.Valid; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.Map; + +@RestController +@RequestMapping("/api") +public class AuthenticationController { + + private final UserService userService; + private final JwtUtils jwtUtils; + + public AuthenticationController(UserService userService, JwtUtils jwtUtils) { + this.userService = userService; + this.jwtUtils = jwtUtils; + } + + @PostMapping("/login") + public Mono> login(@Valid @RequestBody Mono requestMono) { + return requestMono.flatMap(request -> + userService.findByEmail(request.getEmail()) + .switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials"))) + .flatMap(user -> { + if (!user.isActive()) { + return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated"))); + } + if (!userService.checkPassword(user, request.getPassword())) { + return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))); + } + String token = jwtUtils.generateToken(user.getEmail()); + LoginResponse response = new LoginResponse(); + response.setToken(token); + response.setEmail(user.getEmail()); + return Mono.just(ResponseEntity.ok(response)); + }) + .onErrorResume(IllegalArgumentException.class, e -> + Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))) + ) + ); + } +} diff --git a/backend/src/main/java/com/mailserver/controller/MailController.java b/backend/src/main/java/com/mailserver/controller/MailController.java new file mode 100644 index 0000000..27f9c45 --- /dev/null +++ b/backend/src/main/java/com/mailserver/controller/MailController.java @@ -0,0 +1,279 @@ +package com.mailserver.controller; + +import com.mailserver.dto.AttachmentResponse; +import com.mailserver.dto.FolderResponse; +import com.mailserver.dto.MessageResponse; +import com.mailserver.dto.RegisterRequest; +import com.mailserver.dto.SendRequest; +import com.mailserver.service.MailService; +import com.mailserver.service.UserService; +import jakarta.validation.Valid; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api") +public class MailController { + + private static final Logger log = LoggerFactory.getLogger(MailController.class); + + private final UserService userService; + private final MailService mailService; + + public MailController(UserService userService, MailService mailService) { + this.userService = userService; + this.mailService = mailService; + } + + private Mono getCurrentUserEmail(Mono authenticationMono) { + return authenticationMono.map(Authentication::getName); + } + + // --- Users --- + + @PostMapping("/register") + public Mono register(@Valid @RequestBody Mono requestMono) { + return requestMono.flatMap(request -> + userService.register(request.getEmail(), request.getPassword(), request.getDisplayName()) + .map(user -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED) + .body(Map.of("message", "User registered"))) + .onErrorResume(IllegalArgumentException.class, e -> + Mono.just(org.springframework.http.ResponseEntity.badRequest() + .body(Map.of("error", e.getMessage())))) + ); + } + + @GetMapping("/folders") + public Flux getFolders(Mono authenticationMono) { + return authenticationMono.flatMapMany(auth -> + userService.findByEmail(auth.getName()) + .flatMapMany(user -> userService.getFolders(user.getId())) + ); + } + + // --- Messages --- + + @GetMapping("/messages") + public Flux getMessages( + Mono authenticationMono, + @RequestParam Long folderId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return authenticationMono.flatMapMany(auth -> + userService.findByEmail(auth.getName()) + .flatMapMany(user -> mailService.getMessages(folderId, user.getId(), page, size)) + ); + } + + @GetMapping("/messages/search") + public Flux searchMessages( + Mono authenticationMono, + @RequestParam Long folderId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String subject, + @RequestParam(required = false) String body, + @RequestParam(required = false) String sender, + @RequestParam(required = false) String recipient, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return authenticationMono.flatMapMany(auth -> + userService.findByEmail(auth.getName()) + .flatMapMany(user -> { + if (keyword != null && !keyword.trim().isEmpty()) { + return mailService.searchMessages(folderId, user.getId(), keyword.trim(), page, size); + } + return mailService.searchMessagesWithFilters(folderId, user.getId(), + subject, body, sender, recipient, page, size); + }) + ); + } + + @GetMapping("/messages/{id}") + public Mono getMessage(@PathVariable Long id, Mono authenticationMono) { + return authenticationMono.flatMap(auth -> + userService.findByEmail(auth.getName()) + .flatMap(user -> mailService.getMessage(id, user.getId())) + ); + } + + @PostMapping("/send") + 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()))); + }); + }); + } + + @PutMapping("/messages/{id}/read") + public Mono markAsRead(@PathVariable Long id, Mono authenticationMono) { + return authenticationMono.flatMap(auth -> + userService.findByEmail(auth.getName()) + .flatMap(user -> mailService.markAsRead(id, user.getId()) + .then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Marked as read"))))) + ); + } + + @DeleteMapping("/messages/{id}") + public Mono deleteMessage(@PathVariable Long id, Mono authenticationMono) { + return authenticationMono.flatMap(auth -> + userService.findByEmail(auth.getName()) + .flatMap(user -> mailService.deleteMessage(id, user.getId()) + .then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message deleted"))))) + ); + } + + // --- 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( + @PathVariable Long messageId, + Mono authenticationMono, + @RequestBody Mono> filePartsMono) { + return authenticationMono.zipWith(filePartsMono) + .flatMap(pair -> { + String email = pair.getT1().getName(); + return userService.findByEmail(email) + .flatMapMany(user -> pair.getT2()) + .next() + .flatMap(file -> { + String originalFilename = file.filename(); + String uniqueName = UUID.randomUUID() + "_" + originalFilename; + Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName); + try { + Files.createDirectories(filePath.getParent()); + } catch (IOException e) { + return Mono.error(new RuntimeException("Failed to create directory", e)); + } + + return DataBufferUtils.join(file.content()) + .flatMap(dataBuffer -> { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + try { + Files.write(filePath, bytes); + } catch (IOException e) { + return Mono.error(new RuntimeException("Failed to save file", e)); + } + return userService.findByEmail(email) + .flatMap(user -> mailService.saveAttachmentReactive( + messageId, user.getId(), + originalFilename, file.headers().getContentType().toString(), + bytes.length, filePath.toString())) + .map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response)); + }); + }); + }); + } + + @GetMapping("/messages/{messageId}/attachments") + public Flux getMessageAttachments( + @PathVariable Long messageId, + Mono authenticationMono) { + return authenticationMono.flatMapMany(auth -> + userService.findByEmail(auth.getName()) + .flatMapMany(user -> mailService.getMessageAttachments(messageId, user.getId())) + ); + } + + @GetMapping("/attachments/{attachmentId}/download") + 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()))); + } + }) + ); + } + + @DeleteMapping("/attachments/{attachmentId}") + public Mono deleteAttachment( + @PathVariable Long attachmentId, + Mono authenticationMono) { + return authenticationMono.flatMap(auth -> + userService.findByEmail(auth.getName()) + .flatMap(user -> mailService.deleteAttachment(attachmentId, user.getId()) + .then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Attachment deleted"))))) + .onErrorResume(IllegalArgumentException.class, e -> + Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())))) + ); + } +} diff --git a/backend/src/main/java/com/mailserver/controller/SpamController.java b/backend/src/main/java/com/mailserver/controller/SpamController.java new file mode 100644 index 0000000..98fcc57 --- /dev/null +++ b/backend/src/main/java/com/mailserver/controller/SpamController.java @@ -0,0 +1,61 @@ +package com.mailserver.controller; + +import com.mailserver.domain.BlacklistEntry; +import com.mailserver.service.SpamFilterService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; + +/** + * Контроллер для управления чёрным списком отправителей. + */ +@RestController +@RequestMapping("/api/spam") +public class SpamController { + + private final SpamFilterService spamFilterService; + + public SpamController(SpamFilterService spamFilterService) { + this.spamFilterService = spamFilterService; + } + + private String getCurrentUserEmail(Authentication authentication) { + return authentication.getName(); + } + + @GetMapping("/blacklist") + public Flux getBlacklist(Mono authenticationMono) { + return authenticationMono.flatMapMany(auth -> + spamFilterService.getBlacklist(getCurrentUserEmail(auth)) + ); + } + + @PostMapping("/blacklist") + public Mono addToBlacklist(Mono authenticationMono, @RequestBody Mono> requestMono) { + return authenticationMono.zipWith(requestMono).flatMap(pair -> { + String email = getCurrentUserEmail(pair.getT1()); + String senderEmail = pair.getT2().get("senderEmail"); + String reason = pair.getT2().get("reason"); + if (senderEmail == null || senderEmail.trim().isEmpty()) { + return Mono.error(new IllegalArgumentException("senderEmail is required")); + } + return spamFilterService.addToBlacklist(email, senderEmail.trim(), reason); + }); + } + + @DeleteMapping("/blacklist") + public Mono removeFromBlacklist(Mono authenticationMono, @RequestParam String senderEmail) { + return authenticationMono.flatMap(auth -> + spamFilterService.removeFromBlacklist(getCurrentUserEmail(auth), senderEmail) + ); + } +} diff --git a/backend/src/main/java/com/mailserver/domain/Attachment.java b/backend/src/main/java/com/mailserver/domain/Attachment.java new file mode 100644 index 0000000..abf27fd --- /dev/null +++ b/backend/src/main/java/com/mailserver/domain/Attachment.java @@ -0,0 +1,51 @@ +package com.mailserver.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +@Table("attachments") +public class Attachment { + + @Id + private Long id; + + @Column("message_id") + private Long messageId; + + @Column("filename") + private String filename; + + @Column("content_type") + private String contentType; + + @Column("size_bytes") + private Long sizeBytes; + + @Column("file_path") + private String filePath; + + @Column("content_id") + private String contentId; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getMessageId() { return messageId; } + public void setMessageId(Long messageId) { this.messageId = messageId; } + + public String getFilename() { return filename; } + public void setFilename(String filename) { this.filename = filename; } + + public String getContentType() { return contentType; } + public void setContentType(String contentType) { this.contentType = contentType; } + + public Long getSizeBytes() { return sizeBytes; } + public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; } + + public String getFilePath() { return filePath; } + public void setFilePath(String filePath) { this.filePath = filePath; } + + public String getContentId() { return contentId; } + public void setContentId(String contentId) { this.contentId = contentId; } +} diff --git a/backend/src/main/java/com/mailserver/domain/BlacklistEntry.java b/backend/src/main/java/com/mailserver/domain/BlacklistEntry.java new file mode 100644 index 0000000..c273ec7 --- /dev/null +++ b/backend/src/main/java/com/mailserver/domain/BlacklistEntry.java @@ -0,0 +1,44 @@ +package com.mailserver.domain; + +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("blacklist") +public class BlacklistEntry { + + @Id + private Long id; + + @Column("user_id") + private Long userId; + + @Column("sender_email") + private String senderEmail; + + @Column("reason") + private String reason; + + @Column("created_at") + private Instant createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getSenderEmail() { return senderEmail; } + public void setSenderEmail(String senderEmail) { this.senderEmail = senderEmail; } + + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/mailserver/domain/Contact.java b/backend/src/main/java/com/mailserver/domain/Contact.java new file mode 100644 index 0000000..5e39b17 --- /dev/null +++ b/backend/src/main/java/com/mailserver/domain/Contact.java @@ -0,0 +1,45 @@ +package com.mailserver.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +@Table("contacts") +public class Contact { + + @Id + private Long id; + + @Column("user_id") + private Long userId; + + @Column("name") + private String name; + + @Column("email") + private String email; + + @Column("phone") + private String phone; + + @Column("company") + private String company; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + + public String getCompany() { return company; } + public void setCompany(String company) { this.company = company; } +} diff --git a/backend/src/main/java/com/mailserver/domain/Folder.java b/backend/src/main/java/com/mailserver/domain/Folder.java new file mode 100644 index 0000000..e5d928f --- /dev/null +++ b/backend/src/main/java/com/mailserver/domain/Folder.java @@ -0,0 +1,47 @@ +package com.mailserver.domain; + +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("folders") +public class Folder { + + @Id + private Long id; + + @Column("user_id") + private Long userId; + + @Column("name") + private String name; + + @Column("message_count") + private Integer messageCount = 0; + + @Column("unread_count") + private Integer unreadCount = 0; + + @Column("created_at") + private Instant createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getMessageCount() { return messageCount; } + public void setMessageCount(Integer messageCount) { this.messageCount = messageCount; } + + public Integer getUnreadCount() { return unreadCount; } + public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/mailserver/domain/Message.java b/backend/src/main/java/com/mailserver/domain/Message.java new file mode 100644 index 0000000..e9a2644 --- /dev/null +++ b/backend/src/main/java/com/mailserver/domain/Message.java @@ -0,0 +1,95 @@ +package com.mailserver.domain; + +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("messages") +public class Message { + + @Id + private Long id; + + @Column("folder_id") + private Long folderId; + + @Column("sender_email") + private String senderEmail; + + @Column("sender_name") + private String senderName; + + @Column("recipient_email") + private String recipientEmail; + + @Column("subject") + private String subject; + + @Column("body") + private String body; + + @Column("is_html") + private boolean isHtml = false; + + @Column("is_read") + private boolean isRead = false; + + @Column("is_flagged") + private boolean isFlagged = false; + + @Column("is_spam") + private boolean isSpam = false; + + @Column("message_size") + private Long messageSize; + + @Column("received_at") + private Instant receivedAt; + + @Column("message_id") + private String messageId; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getFolderId() { return folderId; } + public void setFolderId(Long folderId) { this.folderId = folderId; } + + public String getSenderEmail() { return senderEmail; } + public void setSenderEmail(String senderEmail) { this.senderEmail = senderEmail; } + + public String getSenderName() { return senderName; } + public void setSenderName(String senderName) { this.senderName = senderName; } + + public String getRecipientEmail() { return recipientEmail; } + public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; } + + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + + public String getBody() { return body; } + public void setBody(String body) { this.body = body; } + + public boolean isHtml() { return isHtml; } + public void setHtml(boolean html) { isHtml = html; } + + public boolean isRead() { return isRead; } + public void setRead(boolean read) { isRead = read; } + + public boolean isFlagged() { return isFlagged; } + public void setFlagged(boolean flagged) { isFlagged = flagged; } + + public boolean isSpam() { return isSpam; } + public void setSpam(boolean spam) { isSpam = spam; } + + public Long getMessageSize() { return messageSize; } + public void setMessageSize(Long messageSize) { this.messageSize = messageSize; } + + public Instant getReceivedAt() { return receivedAt; } + public void setReceivedAt(Instant receivedAt) { this.receivedAt = receivedAt; } + + public String getMessageId() { return messageId; } + public void setMessageId(String messageId) { this.messageId = messageId; } +} diff --git a/backend/src/main/java/com/mailserver/domain/User.java b/backend/src/main/java/com/mailserver/domain/User.java new file mode 100644 index 0000000..69176e4 --- /dev/null +++ b/backend/src/main/java/com/mailserver/domain/User.java @@ -0,0 +1,59 @@ +package com.mailserver.domain; + +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("users") +public class User { + + @Id + private Long id; + + @Column("email") + private String email; + + @Column("password") + private String password; + + @Column("display_name") + private String displayName; + + @Column("quota_bytes") + private Long quotaBytes = 1024L * 1024 * 1024; + + @Column("used_bytes") + private Long usedBytes = 0L; + + @Column("is_active") + private boolean isActive = true; + + @Column("created_at") + private Instant createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + + public Long getQuotaBytes() { return quotaBytes; } + public void setQuotaBytes(Long quotaBytes) { this.quotaBytes = quotaBytes; } + + public Long getUsedBytes() { return usedBytes; } + public void setUsedBytes(Long usedBytes) { this.usedBytes = usedBytes; } + + public boolean isActive() { return isActive; } + public void setActive(boolean active) { isActive = active; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/mailserver/dto/AttachmentResponse.java b/backend/src/main/java/com/mailserver/dto/AttachmentResponse.java new file mode 100644 index 0000000..f761181 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/AttachmentResponse.java @@ -0,0 +1,21 @@ +package com.mailserver.dto; + +public class AttachmentResponse { + + private Long id; + private String filename; + private String contentType; + private Long sizeBytes; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getFilename() { return filename; } + public void setFilename(String filename) { this.filename = filename; } + + public String getContentType() { return contentType; } + public void setContentType(String contentType) { this.contentType = contentType; } + + public Long getSizeBytes() { return sizeBytes; } + public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; } +} diff --git a/backend/src/main/java/com/mailserver/dto/FolderResponse.java b/backend/src/main/java/com/mailserver/dto/FolderResponse.java new file mode 100644 index 0000000..7539230 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/FolderResponse.java @@ -0,0 +1,23 @@ +package com.mailserver.dto; + +import java.time.Instant; + +public class FolderResponse { + + private Long id; + private String name; + private Integer messageCount; + private Integer unreadCount; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getMessageCount() { return messageCount; } + public void setMessageCount(Integer messageCount) { this.messageCount = messageCount; } + + public Integer getUnreadCount() { return unreadCount; } + public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; } +} diff --git a/backend/src/main/java/com/mailserver/dto/LoginRequest.java b/backend/src/main/java/com/mailserver/dto/LoginRequest.java new file mode 100644 index 0000000..5ae0042 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/LoginRequest.java @@ -0,0 +1,18 @@ +package com.mailserver.dto; + +import jakarta.validation.constraints.NotBlank; + +public class LoginRequest { + + @NotBlank + private String email; + + @NotBlank + private String password; + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } +} diff --git a/backend/src/main/java/com/mailserver/dto/LoginResponse.java b/backend/src/main/java/com/mailserver/dto/LoginResponse.java new file mode 100644 index 0000000..2c54770 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/LoginResponse.java @@ -0,0 +1,13 @@ +package com.mailserver.dto; + +public class LoginResponse { + + private String token; + private String email; + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } +} diff --git a/backend/src/main/java/com/mailserver/dto/MessageResponse.java b/backend/src/main/java/com/mailserver/dto/MessageResponse.java new file mode 100644 index 0000000..2de7810 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/MessageResponse.java @@ -0,0 +1,67 @@ +package com.mailserver.dto; + +import java.time.Instant; + +public class MessageResponse { + + private Long id; + private String senderEmail; + private String senderName; + private String recipientEmail; + private String subject; + private String body; + private boolean html; + private boolean read; + private boolean flagged; + private boolean spam; + private Long messageSize; + private Instant receivedAt; + private Long folderId; + private boolean hasAttachments; + private int attachmentCount; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getSenderEmail() { return senderEmail; } + public void setSenderEmail(String senderEmail) { this.senderEmail = senderEmail; } + + public String getSenderName() { return senderName; } + public void setSenderName(String senderName) { this.senderName = senderName; } + + public String getRecipientEmail() { return recipientEmail; } + public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; } + + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + + public String getBody() { return body; } + public void setBody(String body) { this.body = body; } + + public boolean isHtml() { return html; } + public void setHtml(boolean html) { this.html = html; } + + public boolean isRead() { return read; } + public void setRead(boolean read) { this.read = read; } + + public boolean isFlagged() { return flagged; } + public void setFlagged(boolean flagged) { this.flagged = flagged; } + + public boolean isSpam() { return spam; } + public void setSpam(boolean spam) { this.spam = spam; } + + public Long getMessageSize() { return messageSize; } + public void setMessageSize(Long messageSize) { this.messageSize = messageSize; } + + public Instant getReceivedAt() { return receivedAt; } + public void setReceivedAt(Instant receivedAt) { this.receivedAt = receivedAt; } + + public Long getFolderId() { return folderId; } + public void setFolderId(Long folderId) { this.folderId = folderId; } + + public boolean isHasAttachments() { return hasAttachments; } + public void setHasAttachments(boolean hasAttachments) { this.hasAttachments = hasAttachments; } + + public int getAttachmentCount() { return attachmentCount; } + public void setAttachmentCount(int attachmentCount) { this.attachmentCount = attachmentCount; } +} diff --git a/backend/src/main/java/com/mailserver/dto/RegisterRequest.java b/backend/src/main/java/com/mailserver/dto/RegisterRequest.java new file mode 100644 index 0000000..5c14745 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/RegisterRequest.java @@ -0,0 +1,29 @@ +package com.mailserver.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class RegisterRequest { + + @NotBlank + @Email + @Size(max = 200) + private String email; + + @NotBlank + @Size(min = 6, max = 100) + private String password; + + @Size(max = 100) + private String displayName; + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } +} diff --git a/backend/src/main/java/com/mailserver/dto/SendRequest.java b/backend/src/main/java/com/mailserver/dto/SendRequest.java new file mode 100644 index 0000000..93837b3 --- /dev/null +++ b/backend/src/main/java/com/mailserver/dto/SendRequest.java @@ -0,0 +1,31 @@ +package com.mailserver.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class SendRequest { + + @NotBlank + @Email + private String to; + + @NotBlank + private String subject; + + @NotBlank + private String body; + + private boolean html = false; + + public String getTo() { return to; } + public void setTo(String to) { this.to = to; } + + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + + public String getBody() { return body; } + public void setBody(String body) { this.body = body; } + + public boolean isHtml() { return html; } + public void setHtml(boolean html) { this.html = html; } +} diff --git a/backend/src/main/java/com/mailserver/repository/AttachmentRepository.java b/backend/src/main/java/com/mailserver/repository/AttachmentRepository.java new file mode 100644 index 0000000..6e34c16 --- /dev/null +++ b/backend/src/main/java/com/mailserver/repository/AttachmentRepository.java @@ -0,0 +1,9 @@ +package com.mailserver.repository; + +import com.mailserver.domain.Attachment; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Flux; + +public interface AttachmentRepository extends R2dbcRepository { + Flux findByMessageId(Long messageId); +} diff --git a/backend/src/main/java/com/mailserver/repository/BlacklistRepository.java b/backend/src/main/java/com/mailserver/repository/BlacklistRepository.java new file mode 100644 index 0000000..056cea0 --- /dev/null +++ b/backend/src/main/java/com/mailserver/repository/BlacklistRepository.java @@ -0,0 +1,19 @@ +package com.mailserver.repository; + +import com.mailserver.domain.BlacklistEntry; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface BlacklistRepository extends R2dbcRepository { + + Flux findByUserId(Long userId); + + Mono findByUserIdAndSenderEmail(Long userId, String senderEmail); + + @Query("SELECT COUNT(*) > 0 FROM blacklist WHERE user_id = :#{#userId} AND LOWER(sender_email) = LOWER(:#{#senderEmail})") + Mono isBlacklisted(Long userId, String senderEmail); + + Mono deleteByUserIdAndSenderEmail(Long userId, String senderEmail); +} diff --git a/backend/src/main/java/com/mailserver/repository/ContactRepository.java b/backend/src/main/java/com/mailserver/repository/ContactRepository.java new file mode 100644 index 0000000..bb6aecc --- /dev/null +++ b/backend/src/main/java/com/mailserver/repository/ContactRepository.java @@ -0,0 +1,11 @@ +package com.mailserver.repository; + +import com.mailserver.domain.Contact; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface ContactRepository extends R2dbcRepository { + Flux findByUserId(Long userId); + Mono findByUserIdAndEmail(Long userId, String email); +} diff --git a/backend/src/main/java/com/mailserver/repository/FolderRepository.java b/backend/src/main/java/com/mailserver/repository/FolderRepository.java new file mode 100644 index 0000000..7a5d323 --- /dev/null +++ b/backend/src/main/java/com/mailserver/repository/FolderRepository.java @@ -0,0 +1,12 @@ +package com.mailserver.repository; + +import com.mailserver.domain.Folder; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface FolderRepository extends R2dbcRepository { + Flux findByUserId(Long userId); + Mono findByUserIdAndName(Long userId, String name); +} diff --git a/backend/src/main/java/com/mailserver/repository/MessageRepository.java b/backend/src/main/java/com/mailserver/repository/MessageRepository.java new file mode 100644 index 0000000..14c3f2e --- /dev/null +++ b/backend/src/main/java/com/mailserver/repository/MessageRepository.java @@ -0,0 +1,38 @@ +package com.mailserver.repository; + +import com.mailserver.domain.Message; +import org.springframework.data.domain.Pageable; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface MessageRepository extends R2dbcRepository { + + @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC") + Flux findByFolderId(Long folderId, Pageable pageable); + + @Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId} AND is_read = false") + Mono countUnreadByFolderId(Long folderId); + + @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND is_read = false ORDER BY received_at DESC") + Flux findUnreadByFolderId(Long folderId, Pageable pageable); + + @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND " + + "(LOWER(subject) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + + "LOWER(sender_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + + "LOWER(sender_name) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + + "LOWER(body) LIKE '%' || LOWER(:#{#keyword}) || '%') " + + "ORDER BY received_at DESC") + Flux searchInFolder(Long folderId, String keyword, Pageable pageable); + + @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND (" + + "(:#{#subject} IS NULL OR LOWER(subject) LIKE '%' || LOWER(:#{#subject}) || '%') AND " + + "(:#{#body} IS NULL OR LOWER(body) LIKE '%' || LOWER(:#{#body}) || '%') AND " + + "(:#{#sender} IS NULL OR LOWER(sender_email) LIKE '%' || LOWER(:#{#sender}) || '%' OR " + + "LOWER(sender_name) LIKE '%' || LOWER(:#{#sender}) || '%') AND " + + "(:#{#recipient} IS NULL OR LOWER(recipient_email) LIKE '%' || LOWER(:#{#recipient}) || '%')) " + + "ORDER BY received_at DESC") + Flux searchWithFilters(Long folderId, String subject, String body, + String sender, String recipient, Pageable pageable); +} diff --git a/backend/src/main/java/com/mailserver/repository/UserRepository.java b/backend/src/main/java/com/mailserver/repository/UserRepository.java new file mode 100644 index 0000000..38085bd --- /dev/null +++ b/backend/src/main/java/com/mailserver/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.mailserver.repository; + +import com.mailserver.domain.User; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Mono; + +public interface UserRepository extends R2dbcRepository { + Mono findByEmail(String email); + Mono existsByEmail(String email); +} diff --git a/backend/src/main/java/com/mailserver/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/mailserver/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a2141b7 --- /dev/null +++ b/backend/src/main/java/com/mailserver/security/JwtAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.mailserver.security; + +import com.mailserver.util.JwtUtils; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +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; + +import java.util.Collections; + +/** + * Реактивный JWT-фильтр для WebFlux. + * Извлекает токен из заголовка Authorization и устанавливает аутентификацию в SecurityContext. + */ +@Component +public class JwtAuthenticationFilter implements WebFilter { + + private final JwtUtils jwtUtils; + + public JwtAuthenticationFilter(JwtUtils jwtUtils) { + this.jwtUtils = jwtUtils; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return chain.filter(exchange); + } + + String token = authHeader.substring(7); + + if (jwtUtils.isValidToken(token)) { + String email = jwtUtils.getEmailFromToken(token); + + if (email != null) { + UserDetails userDetails = new User(email, "", Collections.emptyList()); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, Collections.emptyList()); + + return chain.filter(exchange) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)); + } + } + + return chain.filter(exchange); + } +} diff --git a/backend/src/main/java/com/mailserver/service/DkimService.java b/backend/src/main/java/com/mailserver/service/DkimService.java new file mode 100644 index 0000000..1db7b87 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/DkimService.java @@ -0,0 +1,458 @@ +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.internet.MimeMessage; +import org.springframework.core.io.ClassPathResource; + +import java.io.*; +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.util.Base64; + +/** + * Сервис для DKIM (DomainKeys Identified Mail) подписи и верификации писем. + * + * DKIM добавляет криптографическую подпись к каждому исходящему письму, + * чтобы получатель мог проверить, что письмо действительно отправлено + * владельцем домена и не было изменено в пути. + * + * При старте генерирует RSA-ключ (2048 бит) и сохраняет его в файл. + * Подписывает заголовки From, Subject, Date, To и тело сообщения. + */ +@Service +public class DkimService { + + private static final Logger log = LoggerFactory.getLogger(DkimService.class); + + private static final String DKIM_SELECTOR = "default"; + + @Value("${mailserver.dkim.domain:localhost}") + private String domain; + + @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 String publicKeyBase64; + + @PostConstruct + public void init() { + Security.addProvider(new BouncyCastleProvider()); + + if (!enabled) { + log.info("DKIM подписи отключены"); + 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); + } else { + // Генерируем новые ключи + KeyPair keyPair = generateKeyPair(); + privateKey = keyPair.getPrivate(); + publicKey = keyPair.getPublic(); + saveKeys(privateKeyFile, publicKeyFile, keyPair); + log.info("DKIM: сгенерированы новые ключи для домена {}", domain); + } + + // Формируем публичный ключ в формате DNS TXT-записи + publicKeyBase64 = encodePublicKeyForDns(publicKey); + log.info("DKIM публичный ключ для DNS TXT-записи (_{}._domainkey.{}):", DKIM_SELECTOR, domain); + log.info("v=DKIM1; k=rsa; p={}", publicKeyBase64); + + } catch (Exception e) { + log.error("Ошибка инициализации DKIM", e); + } + } + + /** + * Генерирует RSA-ключ 2048 бит для DKIM. + */ + private KeyPair generateKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + /** + * Сохраняет ключи в PEM-формат. + */ + private void saveKeys(Path privateKeyFile, Path publicKeyFile, KeyPair keyPair) + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + try (FileWriter fw = new FileWriter(privateKeyFile.toFile()); + JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) { + pemWriter.writeObject(keyPair.getPrivate()); + pemWriter.flush(); + } + + try (FileWriter fw = new FileWriter(publicKeyFile.toFile()); + JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) { + pemWriter.writeObject(keyPair.getPublic()); + pemWriter.flush(); + } + } + + /** + * Загружает ключи из PEM-файлов. + */ + private void loadKeys(Path privateKeyFile, Path publicKeyFile) + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + String pkcs8Private = Files.readString(privateKeyFile); + String derEncoded = pkcs8Private + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.getDecoder().decode(derEncoded); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + privateKey = keyFactory.generatePrivate(spec); + + String pkcs8Public = Files.readString(publicKeyFile); + String derEncodedPublic = pkcs8Public + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + byte[] decodedPublic = Base64.getDecoder().decode(derEncodedPublic); + X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(decodedPublic); + publicKey = keyFactory.generatePublic(pubSpec); + } + + /** + * Кодирует публичный ключ для DNS TXT-записи. + */ + private String encodePublicKeyForDns(PublicKey key) { + byte[] encoded = key.getEncoded(); + return Base64.getEncoder().withoutPadding().encodeToString(encoded); + } + + /** + * Подписывает MIME-сообщение DKIM-подписью. + * Добавляет заголовок DKIM-Signature в письмо. + * + * @param mimeMessage MIME-сообщение для подписи + * @throws MessagingException если не удалось добавить заголовок + */ + public void signMessage(MimeMessage mimeMessage) throws MessagingException { + if (!enabled || privateKey == null) { + return; + } + + 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"); + } + + // Вычисляем хэш тела + 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); + // Не бросаем исключение — письмо будет отправлено без подписи + } + } + + /** + * Верифицирует DKIM-подпись входящего сообщения. + * + * @param mimeMessage MIME-сообщение с DKIM-подписью + * @return true если подпись валидна, false если подпись отсутствует или невалидна + */ + public boolean verifyMessage(MimeMessage mimeMessage) { + if (!enabled) { + return true; // Если DKIM отключён, считаем все письма валидными + } + + 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; + } + } + + /** + * Верифицирует подпись локального домена. + */ + 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/DmarcService.java b/backend/src/main/java/com/mailserver/service/DmarcService.java new file mode 100644 index 0000000..b17dea8 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/DmarcService.java @@ -0,0 +1,318 @@ +package com.mailserver.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.naming.Context; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; +import java.util.Hashtable; +import java.util.HashSet; +import java.util.Set; + +/** + * Сервис для DMARC (Domain-based Message Authentication, Reporting & Conformance) проверки. + * + * DMARC объединяет SPF и DKIM, позволяя владельцу домена указать, что делать + * с письмами, которые не прошли проверку SPF и/или DKIM. + * + * Политики DMARC: + * - none: только мониторить, не предпринимать действий + * - quarantine: отправлять в спам + * - reject: отклонять письмо + * + * При получении письма DMARC проверяет: + * 1. Прошло ли письмо SPF-проверку с alignment (домен в From совпадает с доменом SPF) + * 2. Прошла ли письмо DKIM-проверку с alignment (домен в From совпадает с доменом DKIM) + * 3. Если ни одна проверка не пройдена — применяется политика из DNS + */ +@Service +public class DmarcService { + + private static final Logger log = LoggerFactory.getLogger(DmarcService.class); + + @Value("${mailserver.dmarc.enabled:true}") + private boolean enabled; + + @Value("${mailserver.dmarc.local-bypass:true}") + private boolean localBypass; + + private final SpfService spfService; + + public DmarcService(SpfService spfService) { + this.spfService = spfService; + } + + /** + * Результат DMARC-проверки. + */ + public enum DmarcResult { + PASS("DMARC проверка пройдена"), + QUARANTINE("DMARC рекомендует отправить в спам"), + REJECT("DMARC рекомендует отклонить письмо"), + NONE("DMARC политика — только мониторинг"), + ERROR("Ошибка при проверке DMARC"); + + private final String description; + + DmarcResult(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + } + + /** + * Политика DMARC из DNS-записи. + */ + public enum DmarcPolicy { + NONE("Только мониторинг"), + QUARANTINE("Отправлять в спам"), + REJECT("Отклонять"); + + private final String description; + + DmarcPolicy(String description) { + this.description = description; + } + } + + /** + * Проверяет DMARC для входящего письма. + * + * @param fromEmail email из заголовка From (visible sender) + * @param senderIp IP-адрес SMTP-клиента + * @param spfPassed результат SPF-проверки + * @param dkimPassed результат DKIM-проверки + * @param dkimDomain домен из DKIM-подписи (если есть) + * @return результат DMARC-проверки + */ + public DmarcResult checkDmarc(String fromEmail, String senderIp, + boolean spfPassed, boolean dkimPassed, + String dkimDomain) { + if (!enabled) { + log.debug("DMARC проверка отключена"); + return DmarcResult.PASS; + } + + if (fromEmail == null || fromEmail.isEmpty()) { + log.warn("DMARC: email отправителя пуст"); + return DmarcResult.ERROR; + } + + try { + // Извлекаем домен из заголовка From + String fromDomain = extractDomain(fromEmail); + if (fromDomain == null || fromDomain.isEmpty()) { + log.warn("DMARC: не удалось извлечь домен из {}", fromEmail); + return DmarcResult.ERROR; + } + + // Локальные домены пропускаем + if (localBypass && isLocalDomain(fromDomain)) { + log.debug("DMARC: локальный домен {} пропущен", fromDomain); + return DmarcResult.PASS; + } + + // Проверяем SPF alignment + boolean spfAligned = spfPassed; + // SPF alignment: домен в From должен совпадать с доменом отправителя SMTP + // Для локального сервера считаем, что alignment выполнен, если SPF прошёл + + // Проверяем DKIM alignment + boolean dkimAligned = false; + if (dkimPassed && dkimDomain != null) { + // DKIM alignment: домен в From должен совпадать с доменом DKIM + dkimAligned = fromDomain.equalsIgnoreCase(dkimDomain) + || isSubdomain(dkimDomain, fromDomain) + || isSubdomain(fromDomain, dkimDomain); + } + + // Если хотя бы одна проверка пройдена с alignment — DMARC PASSED + if (spfAligned || dkimAligned) { + log.info("DMARC: проверка пройдена для {} (SPF: {}, DKIM: {})", + fromDomain, spfAligned, dkimAligned); + return DmarcResult.PASS; + } + + // Ни одна проверка не пройдена — получаем политику из DNS + DmarcPolicy policy = getDmarcPolicy(fromDomain); + if (policy == null) { + // DMARC-запись не найдена — проверяем родительский домен + String parentDomain = getParentDomain(fromDomain); + if (parentDomain != null) { + policy = getDmarcPolicy(parentDomain); + } + + if (policy == null) { + // Нет DMARC-записи — письмо не обрабатывается DMARC + log.info("DMARC: запись не найдена для {}", fromDomain); + return DmarcResult.NONE; + } + } + + log.info("DMARC: политика {} для {} (SPF: {}, DKIM: {})", + policy, fromDomain, spfPassed, dkimPassed); + + // Применяем политику + switch (policy) { + case REJECT: + return DmarcResult.REJECT; + case QUARANTINE: + return DmarcResult.QUARANTINE; + case NONE: + return DmarcResult.NONE; + default: + return DmarcResult.NONE; + } + + } catch (Exception e) { + log.error("DMARC: ошибка проверки для {}", fromEmail, e); + return DmarcResult.ERROR; + } + } + + /** + * Извлекает домен из email-адреса. + */ + private String extractDomain(String email) { + int atIndex = email.lastIndexOf('@'); + if (atIndex == -1 || atIndex == email.length() - 1) { + return null; + } + return email.substring(atIndex + 1).toLowerCase(); + } + + /** + * Проверяет, является ли домен локальным. + */ + private boolean isLocalDomain(String domain) { + return domain.equals("localhost") + || domain.endsWith(".localhost") + || domain.endsWith(".local") + || domain.endsWith(".internal"); + } + + /** + * Проверяет, является ли domain поддоменом parent. + */ + private boolean isSubdomain(String domain, String parent) { + if (domain.equals(parent)) return true; + return domain.endsWith("." + parent); + } + + /** + * Получает родительский домен. + * Например, mail.example.com -> example.com + */ + private String getParentDomain(String domain) { + int dotIndex = domain.indexOf('.'); + if (dotIndex == -1) return null; + + int secondDotIndex = domain.indexOf('.', dotIndex + 1); + if (secondDotIndex == -1) return null; + + return domain.substring(dotIndex + 1); + } + + /** + * Получает DMARC-политику из DNS. + */ + private DmarcPolicy getDmarcPolicy(String domain) { + try { + // DMARC-запись находится в _dmarc. + String dmarcDomain = "_dmarc." + domain; + + Hashtable env = new Hashtable<>(); + env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); + env.put(Context.PROVIDER_URL, "dns:"); + + DirContext ctx = new InitialDirContext(env); + Attributes attrs = ctx.getAttributes(dmarcDomain, new String[]{"TXT"}); + Attribute txtAttr = attrs.get("TXT"); + + if (txtAttr == null || txtAttr.size() == 0) { + return null; + } + + String dmarcRecord = txtAttr.get().toString(); + // Убираем кавычки + if (dmarcRecord.startsWith("\"") && dmarcRecord.endsWith("\"")) { + dmarcRecord = dmarcRecord.substring(1, dmarcRecord.length() - 1); + } + + log.debug("DMARC: запись для {} — {}", domain, dmarcRecord); + + // Парсим политику + return parseDmarcPolicy(dmarcRecord); + + } catch (Exception e) { + log.debug("DMARC: ошибка DNS-запроса для {} — {}", domain, e.getMessage()); + return null; + } + } + + /** + * Парсит DMARC-политику из DNS-записи. + */ + private DmarcPolicy parseDmarcPolicy(String dmarcRecord) { + // Проверяем версию + if (!dmarcRecord.toLowerCase().startsWith("v=dmarc1")) { + log.warn("DMARC: неверная версия записи"); + return null; + } + + // Извлекаем политику + String policyStr = extractValue(dmarcRecord, "p="); + if (policyStr == null) { + return null; + } + + policyStr = policyStr.toLowerCase(); + switch (policyStr) { + case "reject": + return DmarcPolicy.REJECT; + case "quarantine": + return DmarcPolicy.QUARANTINE; + case "none": + return DmarcPolicy.NONE; + default: + log.warn("DMARC: неизвестная политика {}", policyStr); + return DmarcPolicy.NONE; + } + } + + /** + * Извлекает значение параметра из DMARC-записи. + */ + private String extractValue(String record, String param) { + int index = record.toLowerCase().indexOf(param.toLowerCase()); + if (index == -1) return null; + + int start = index + param.length(); + int end = record.indexOf(';', start); + if (end == -1) end = record.length(); + + return record.substring(start, end).trim(); + } + + /** + * Проверяет, рекомендует ли DMARC пометить письмо как спам. + */ + public boolean shouldMarkAsSpam(DmarcResult result) { + return result == DmarcResult.QUARANTINE || result == DmarcResult.REJECT; + } + + /** + * Проверяет, рекомендует ли DMARC отклонить письмо. + */ + public boolean shouldReject(DmarcResult result) { + return result == DmarcResult.REJECT; + } +} diff --git a/backend/src/main/java/com/mailserver/service/MailService.java b/backend/src/main/java/com/mailserver/service/MailService.java new file mode 100644 index 0000000..1c62eb2 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/MailService.java @@ -0,0 +1,384 @@ +package com.mailserver.service; + +import com.mailserver.domain.Attachment; +import com.mailserver.domain.Folder; +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.FolderRepository; +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.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +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 { + + private static final Logger log = LoggerFactory.getLogger(MailService.class); + + private final MessageRepository messageRepository; + private final FolderRepository folderRepository; + private final UserRepository userRepository; + private final AttachmentRepository attachmentRepository; + 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, + SpamFilterService spamFilterService, + DkimService dkimService) { + this.messageRepository = messageRepository; + this.folderRepository = folderRepository; + this.userRepository = userRepository; + this.attachmentRepository = attachmentRepository; + 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) + ); + } + + public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, + String subject, String body, boolean isHtml) { + return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false); + } + + public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, + String subject, String body, boolean isHtml, boolean externalSpamFlag) { + return userRepository.findByEmail(recipientEmail) + .switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail))) + .flatMap(recipient -> + folderRepository.findByUserIdAndName(recipient.getId(), "INBOX") + .switchIfEmpty(Mono.error(new IllegalStateException("INBOX not found"))) + .flatMap(inbox -> + spamFilterService.isBlacklisted(recipient.getId(), senderEmail) + .flatMap(isBlacklisted -> { + boolean isSpam = isBlacklisted || externalSpamFlag; + Message message = new Message(); + message.setFolderId(inbox.getId()); + message.setSenderEmail(senderEmail); + message.setSenderName(senderName); + message.setRecipientEmail(recipientEmail); + message.setSubject(subject); + message.setBody(body); + message.setHtml(isHtml); + message.setRead(false); + message.setSpam(isSpam); + message.setReceivedAt(Instant.now()); + message.setMessageId(UUID.randomUUID().toString()); + message.setMessageSize((long) (subject.length() + body.length())); + return messageRepository.save(message) + .then(folderRepository.save(inbox)); + }) + ) + ) + .then(); + } + + public Flux 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) + .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) + .map(this::toResponse); + } + + public Flux searchMessagesWithFilters(Long folderId, Long userId, + String subject, String body, + 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) + .map(this::toResponse); + } + + public Mono getMessage(Long messageId, Long userId) { + return messageRepository.findById(messageId) + .map(this::toResponse); + } + + public Mono markAsRead(Long messageId, Long userId) { + return messageRepository.findById(messageId) + .flatMap(message -> { + message.setRead(true); + return messageRepository.save(message).then(); + }); + } + + public Mono deleteMessage(Long messageId, Long userId) { + return messageRepository.deleteById(messageId); + } + + private MessageResponse toResponse(Message message) { + MessageResponse resp = new MessageResponse(); + resp.setId(message.getId()); + resp.setSenderEmail(message.getSenderEmail()); + resp.setSenderName(message.getSenderName()); + resp.setRecipientEmail(message.getRecipientEmail()); + resp.setSubject(message.getSubject()); + resp.setBody(message.getBody()); + resp.setHtml(message.isHtml()); + resp.setRead(message.isRead()); + resp.setFlagged(message.isFlagged()); + resp.setSpam(message.isSpam()); + resp.setMessageSize(message.getMessageSize()); + resp.setReceivedAt(message.getReceivedAt()); + resp.setFolderId(message.getFolderId()); + return resp; + } + + public Mono saveAttachmentReactive(Long messageId, Long userId, + String originalFilename, String contentType, + long sizeBytes, String filePath) { + return messageRepository.findById(messageId) + .flatMap(message -> { + Attachment attachment = new Attachment(); + attachment.setMessageId(messageId); + attachment.setFilename(originalFilename); + attachment.setContentType(contentType); + attachment.setSizeBytes(sizeBytes); + attachment.setFilePath(filePath); + return attachmentRepository.save(attachment); + }) + .map(this::toAttachmentResponse); + } + + public String getAttachmentsDir() { + 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(); + } + + public Flux getMessageAttachments(Long messageId, Long userId) { + return attachmentRepository.findByMessageId(messageId) + .map(this::toAttachmentResponse); + } + + public Mono deleteAttachment(Long attachmentId, Long userId) { + return attachmentRepository.findById(attachmentId) + .flatMap(attachment -> { + try { + Files.deleteIfExists(Paths.get(attachment.getFilePath())); + } catch (IOException e) { + // Игнорируем + } + return attachmentRepository.delete(attachment); + }); + } + + private AttachmentResponse toAttachmentResponse(Attachment attachment) { + AttachmentResponse resp = new AttachmentResponse(); + resp.setId(attachment.getId()); + resp.setFilename(attachment.getFilename()); + resp.setContentType(attachment.getContentType()); + resp.setSizeBytes(attachment.getSizeBytes()); + 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); + } +} diff --git a/backend/src/main/java/com/mailserver/service/SmtpRelayService.java b/backend/src/main/java/com/mailserver/service/SmtpRelayService.java new file mode 100644 index 0000000..8bbfa7d --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/SmtpRelayService.java @@ -0,0 +1,307 @@ +package com.mailserver.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.netty.Connection; +import reactor.netty.tcp.TcpClient; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * SMTP релей — доставляет письма на внешнюю почту. + * Находит MX-запись домена получателя, подключается к его SMTP-серверу + * и пересылает письмо через SMTP протокол. + */ +@Service +public class SmtpRelayService { + + private static final Logger log = LoggerFactory.getLogger(SmtpRelayService.class); + + @Value("${mailserver.relay.enabled:true}") + private boolean enabled; + + @Value("${mailserver.relay.port:587}") + private int relayPort; + + @Value("${mailserver.relay.starttls:true}") + private boolean startTls; + + @Value("${mailserver.relay.auth-enabled:false}") + private boolean authEnabled; + + @Value("${mailserver.relay.username:}") + private String username; + + @Value("${mailserver.relay.password:}") + private String password; + + @Value("${mailserver.smtp.hostname:localhost}") + private String localHostname; + + /** + * Доставляет письмо на внешний адрес через SMTP релей. + */ + public Mono relayMessage(String from, String to, String mimeContent) { + if (!enabled) { + log.warn("SMTP релей отключён (mailserver.relay.enabled=false)"); + return Mono.empty(); + } + + String domain = extractDomain(to); + if (domain == null) { + return Mono.error(new RuntimeException("Invalid recipient: " + to)); + } + + log.info("SMTP релей: доставка {} -> {} (домен: {})", from, to, domain); + + return findMxServer(domain) + .flatMap(mxHost -> { + log.info("SMTP релей: MX-сервер для {}: {}", domain, mxHost); + return sendToExternalServer(from, to, mimeContent, mxHost); + }) + .doOnSuccess(v -> log.info("SMTP релей: письмо доставлено {}", to)) + .onErrorResume(e -> { + log.error("SMTP релей: ошибка доставки {}: {}", to, e.getMessage()); + return Mono.empty(); + }); + } + + /** + * Извлекает домен из email адреса. + */ + private String extractDomain(String email) { + int atIndex = email.lastIndexOf('@'); + if (atIndex == -1) return null; + return email.substring(atIndex + 1).toLowerCase(); + } + + /** + * Находит MX-сервер для домена через DNS. + * Использует JNDI DNS для поиска MX-записи. + */ + private Mono findMxServer(String domain) { + return Mono.fromCallable(() -> { + // Пытаемся найти MX через JNDI DNS + try { + javax.naming.directory.DirContext ictx = new javax.naming.directory.InitialDirContext(); + String searchBase = domain; + String searchFilter = "(objectClass=*)"; + javax.naming.directory.SearchControls ctls = new javax.naming.directory.SearchControls(); + ctls.setSearchScope(javax.naming.directory.SearchControls.OBJECT_SCOPE); + ctls.setReturningAttributes(new String[]{"mx"}); + + javax.naming.NamingEnumeration answer = + ictx.search(searchBase, searchFilter, ctls); + + while (answer.hasMore()) { + javax.naming.directory.SearchResult result = answer.next(); + javax.naming.directory.Attributes attrs = result.getAttributes(); + if (attrs != null) { + javax.naming.NamingEnumeration attrEnum = attrs.getAll(); + while (attrEnum.hasMore()) { + javax.naming.directory.Attribute attr = attrEnum.next(); + if ("mx".equalsIgnoreCase(attr.getID())) { + javax.naming.NamingEnumeration vals = attr.getAll(); + if (vals.hasMore()) { + String mxVal = vals.next().toString(); + // MX формат: "10 mail.server.com." + String[] parts = mxVal.split("\\s+"); + if (parts.length >= 2) { + String mxHost = parts[1]; + if (mxHost.endsWith(".")) { + mxHost = mxHost.substring(0, mxHost.length() - 1); + } + log.debug("MX-запись для {}: {} (приоритет: {})", domain, mxHost, parts[0]); + return mxHost; + } + } + } + } + } + } + } catch (Exception e) { + log.warn("MX-поиск для {}: {}", domain, e.getMessage()); + } + + // Fallback — используем A-запись домена + try { + java.net.InetAddress[] addresses = java.net.InetAddress.getAllByName(domain); + if (addresses != null && addresses.length > 0) { + log.debug("DNS A-запись для {}: {}", domain, addresses[0].getHostAddress()); + return domain; + } + } catch (UnknownHostException e) { + log.warn("DNS: не найден адрес для {}", domain); + } + + // Последний fallback — используем домен напрямую + return domain; + }); + } + + /** + * Отправляет письмо на внешний SMTP-сервер через TcpClient. + * Поддерживает STARTTLS и AUTH LOGIN/PLAIN. + */ + private Mono sendToExternalServer(String from, String to, String mimeContent, String mxHost) { + TcpClient client = TcpClient.create() + .host(mxHost) + .port(relayPort) + .option(io.netty.channel.ChannelOption.SO_TIMEOUT, 30000) + .option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000); + + // Подготавливаем тело DATA (escaping строк, начинающихся с '.') + StringBuilder dataBody = new StringBuilder(); + String[] lines = mimeContent.split("\\r?\\n"); + for (String line : lines) { + if (line.startsWith(".")) { + dataBody.append(".").append(line); + } else { + dataBody.append(line); + } + dataBody.append("\r\n"); + } + dataBody.append(".\r\n"); + + return client.connect() + .flatMap(connection -> { + Sinks.Many responseSink = Sinks.many().replay().limit(1); + + // Подписываемся на inbound ОДИН раз + connection.inbound() + .receive() + .asString(StandardCharsets.UTF_8) + .doOnNext(line -> { + String trimmed = line.trim(); + log.debug("SMTP релей ответ от {}: {}", mxHost, trimmed); + responseSink.tryEmitNext(trimmed); + }) + .subscribe(); + + // 1. Ожидаем banner 220 + Mono send = sendAndAwait(connection, "", responseSink, "220"); + + // 2. EHLO + send = send.then(sendAndAwait(connection, "EHLO " + localHostname + "\r\n", responseSink, "250")); + + // 3. STARTTLS (если включено) + if (startTls) { + send = send.then(sendAndAwait(connection, "STARTTLS\r\n", responseSink, "220")); + send = send.then(Mono.fromRunnable(() -> { + try { + log.info("SMTP релей: переход к TLS с {}", mxHost); + // После STARTTLS нужно переключить соединение на TLS + // В Netty это делается через pipeline modification + io.netty.handler.ssl.SslContext sslCtx = io.netty.handler.ssl.SslContextBuilder.forClient() + .trustManager(createInsecureTrustManager()) + .build(); + javax.net.ssl.SSLEngine engine = sslCtx.newEngine(connection.channel().alloc(), mxHost, relayPort); + connection.channel().pipeline().addFirst( + new io.netty.handler.ssl.SslHandler(engine) + ); + } catch (Exception e) { + log.error("SMTP релей: ошибка STARTTLS: {}", e.getMessage()); + } + })); + // Повторяем EHLO после TLS + send = send.then(sendAndAwait(connection, "EHLO " + localHostname + "\r\n", responseSink, "250")); + } + + // 4. AUTH (если включено) + if (authEnabled && !username.isEmpty()) { + send = send.then(authenticate(connection, responseSink)); + } + + // 5. MAIL FROM + send = send.then(sendAndAwait(connection, "MAIL FROM:<" + from + ">\r\n", responseSink, "250")); + + // 6. RCPT TO + send = send.then(sendAndAwait(connection, "RCPT TO:<" + to + ">\r\n", responseSink, "250")); + + // 7. DATA + send = send.then(sendAndAwait(connection, "DATA\r\n", responseSink, "354")); + + // 8. Тело письма + send = send.then(sendAndAwait(connection, dataBody.toString(), responseSink, "250")); + + // 9. QUIT + send = send.then(sendAndAwait(connection, "QUIT\r\n", responseSink, "221")); + + return send.then(Mono.fromRunnable(() -> connection.dispose())); + }); + } + + /** + * AUTH LOGIN — аутентификация через Base64 логин/пароль. + */ + private Mono authenticate(Connection connection, Sinks.Many responseSink) { + log.info("SMTP релей: аутентификация как {}", username); + + // AUTH LOGIN + Mono send = sendAndAwait(connection, "AUTH LOGIN\r\n", responseSink, "334"); + + // Отправляем логин (Base64) + String encodedUsername = Base64.getEncoder().encodeToString(username.getBytes(StandardCharsets.UTF_8)); + send = send.then(sendAndAwait(connection, encodedUsername + "\r\n", responseSink, "334")); + + // Отправляем пароль (Base64) + String encodedPassword = Base64.getEncoder().encodeToString(password.getBytes(StandardCharsets.UTF_8)); + send = send.then(sendAndAwait(connection, encodedPassword + "\r\n", responseSink, "235")); + + log.info("SMTP релей: аутентификация успешна"); + return send; + } + + /** + * Создаёт TrustManager с доверием ко всем сертификатам (для разработки). + * Для продакшена используйте правильный TrustManager. + */ + private javax.net.ssl.X509TrustManager createInsecureTrustManager() { + return new javax.net.ssl.X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + }; + } + + /** + * Отправляет SMTP команду и ждёт ответ с указанным кодом. + */ + 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(); + + 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 relay response timeout", e))); + + return send.then(readResponse); + } +} diff --git a/backend/src/main/java/com/mailserver/service/SpamFilterService.java b/backend/src/main/java/com/mailserver/service/SpamFilterService.java new file mode 100644 index 0000000..e2c12a4 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/SpamFilterService.java @@ -0,0 +1,69 @@ +package com.mailserver.service; + +import com.mailserver.domain.BlacklistEntry; +import com.mailserver.domain.User; +import com.mailserver.repository.BlacklistRepository; +import com.mailserver.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Instant; + +/** + * Сервис для управления чёрным списком отправителей. + */ +@Service +public class SpamFilterService { + + private static final Logger log = LoggerFactory.getLogger(SpamFilterService.class); + + private final BlacklistRepository blacklistRepository; + private final UserRepository userRepository; + + public SpamFilterService(BlacklistRepository blacklistRepository, UserRepository userRepository) { + this.blacklistRepository = blacklistRepository; + this.userRepository = userRepository; + } + + public Mono isBlacklisted(Long userId, String senderEmail) { + return blacklistRepository.isBlacklisted(userId, senderEmail); + } + + public Mono addToBlacklist(String userEmail, String senderEmail, String reason) { + return userRepository.findByEmail(userEmail) + .switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + userEmail))) + .flatMap(user -> + blacklistRepository.isBlacklisted(user.getId(), senderEmail) + .flatMap(isBlacklisted -> { + if (isBlacklisted) { + return Mono.error(new IllegalArgumentException("Sender is already blacklisted: " + senderEmail)); + } + BlacklistEntry entry = new BlacklistEntry(); + entry.setUserId(user.getId()); + entry.setSenderEmail(senderEmail.toLowerCase().trim()); + entry.setReason(reason); + entry.setCreatedAt(Instant.now()); + return blacklistRepository.save(entry); + }) + ) + .doOnSuccess(entry -> log.info("Отправитель {} добавлен в чёрный список пользователя {}", senderEmail, userEmail)); + } + + public Mono removeFromBlacklist(String userEmail, String senderEmail) { + return userRepository.findByEmail(userEmail) + .switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + userEmail))) + .flatMap(user -> + blacklistRepository.deleteByUserIdAndSenderEmail(user.getId(), senderEmail) + .doOnSuccess(v -> log.info("Отправитель {} удалён из чёрного списка пользователя {}", senderEmail, userEmail)) + ); + } + + public Flux getBlacklist(String userEmail) { + return userRepository.findByEmail(userEmail) + .switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + userEmail))) + .flatMapMany(user -> blacklistRepository.findByUserId(user.getId())); + } +} diff --git a/backend/src/main/java/com/mailserver/service/SpfService.java b/backend/src/main/java/com/mailserver/service/SpfService.java new file mode 100644 index 0000000..bb4bf6c --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/SpfService.java @@ -0,0 +1,336 @@ +package com.mailserver.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.naming.Context; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Hashtable; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.net.InetAddress; + +/** + * Сервис для SPF (Sender Policy Framework) проверки отправителей. + * + * SPF позволяет владельцу домена указать, какие серверы имеют право + * отправлять письма от имени этого домена. При получении письма + * проверяющий сервер смотрит DNS-запись домена отправителя и сравнивает + * IP-адрес отправителя с разрешёнными в SPF-записи. + * + * Результаты проверки: + * - PASS: IP-адрес отправителя разрешён SPF-записью + * - FAIL: IP-адрес не разрешён + * - SOFTFAIL: IP-адрес, вероятно, не разрешён (-all vs ~all) + * - NEUTRAL: домен имеет нейтральную SPF-запись + * - NONE: SPF-запись не найдена + * - ERROR: ошибка при проверке (DNS-сбой и т.д.) + */ +@Service +public class SpfService { + + private static final Logger log = LoggerFactory.getLogger(SpfService.class); + + @Value("${mailserver.spf.enabled:true}") + private boolean enabled; + + @Value("${mailserver.spf.local-bypass:true}") + private boolean localBypass; + + /** + * Результат SPF-проверки. + */ + public enum SpfResult { + PASS("Проверка пройдена — IP-адрес разрешён"), + FAIL("Проверка не пройдена — IP-адрес не разрешён"), + SOFTFAIL("Возможная подделка — IP-адрес, вероятно, не разрешён"), + NEUTRAL("Нейтральная политика — домен не запрещает отправку"), + NONE("SPF-запись не найдена для домена"), + ERROR("Ошибка при проверке SPF"); + + private final String description; + + SpfResult(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + } + + /** + * Проверяет SPF для письма от указанного отправителя с указанного IP-адреса. + * + * @param senderEmail email отправителя + * @param senderIp IP-адрес SMTP-клиента (для локального сервера — "127.0.0.1") + * @return результат SPF-проверки + */ + public SpfResult checkSpf(String senderEmail, String senderIp) { + if (!enabled) { + log.debug("SPF проверка отключена"); + return SpfResult.PASS; + } + + if (senderEmail == null || senderEmail.isEmpty()) { + log.warn("SPF: email отправителя пуст"); + return SpfResult.ERROR; + } + + try { + // Извлекаем домен из email + String domain = extractDomain(senderEmail); + if (domain == null || domain.isEmpty()) { + log.warn("SPF: не удалось извлечь домен из {}", senderEmail); + return SpfResult.ERROR; + } + + // Локальные адреса всегда проходят проверку + if (localBypass && isLocalAddress(senderIp)) { + log.debug("SPF: локальный адрес {} пропущен для домена {}", senderIp, domain); + return SpfResult.PASS; + } + + // Получаем SPF-запись из DNS + String spfRecord = getSpfRecord(domain); + if (spfRecord == null || spfRecord.isEmpty()) { + log.info("SPF: запись не найдена для домена {}", domain); + return SpfResult.NONE; + } + + log.debug("SPF: запись для {} — {}", domain, spfRecord); + + // Парсим и проверяем SPF-запись + return evaluateSpfRecord(spfRecord, senderIp, domain); + + } catch (Exception e) { + log.error("SPF: ошибка проверки для {} с IP {}", senderEmail, senderIp, e); + return SpfResult.ERROR; + } + } + + /** + * Извлекает домен из email-адреса. + */ + private String extractDomain(String email) { + int atIndex = email.lastIndexOf('@'); + if (atIndex == -1 || atIndex == email.length() - 1) { + return null; + } + return email.substring(atIndex + 1).toLowerCase(); + } + + /** + * Проверяет, является ли IP-адрес локальным. + */ + private boolean isLocalAddress(String ip) { + if (ip == null) return false; + try { + InetAddress address = InetAddress.getByName(ip); + return address.isLoopbackAddress() + || address.isAnyLocalAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress(); + } catch (Exception e) { + return false; + } + } + + /** + * Получает SPF-запись из DNS. + * Сначала пытается получить TXT-запись домена, затем проверяет MX-запись. + */ + private String getSpfRecord(String domain) { + // Пробуем получить TXT-запись + String txtRecord = getTxtRecord(domain); + if (txtRecord != null && txtRecord.toLowerCase().startsWith("v=spf1")) { + return txtRecord; + } + + return null; + } + + /** + * Получает TXT-запись из DNS. + */ + private String getTxtRecord(String domain) { + try { + Hashtable env = new Hashtable<>(); + env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); + env.put(Context.PROVIDER_URL, "dns:"); + + DirContext ctx = new InitialDirContext(env); + Attributes attrs = ctx.getAttributes(domain, new String[]{"TXT"}); + Attribute txtAttr = attrs.get("TXT"); + + if (txtAttr != null && txtAttr.size() > 0) { + String txtValue = txtAttr.get().toString(); + // Убираем кавычки, если есть + if (txtValue.startsWith("\"") && txtValue.endsWith("\"")) { + txtValue = txtValue.substring(1, txtValue.length() - 1); + } + return txtValue; + } + } catch (Exception e) { + log.debug("SPF: ошибка DNS-запроса для {} — {}", domain, e.getMessage()); + } + return null; + } + + /** + * Оценивает SPF-запись для указанного IP-адреса. + */ + private SpfResult evaluateSpfRecord(String spfRecord, String senderIp, String domain) { + // Убираем версию + String record = spfRecord.replaceAll("^v=spf1\\s+", ""); + + // Разбираем механизмы SPF + String[] mechanisms = tokenizeSpf(record); + + // Проверяем каждый механизм + for (String mechanism : mechanisms) { + mechanism = mechanism.trim(); + if (mechanism.isEmpty()) continue; + + SpfResult result = evaluateMechanism(mechanism, senderIp, domain); + if (result != null) { + return result; + } + } + + // Если ни один механизм не совпал, проверяем модификатор all + // По умолчанию — FAIL + return SpfResult.FAIL; + } + + /** + * Разбивает SPF-запись на токены. + */ + private String[] tokenizeSpf(String record) { + // Разделяем по пробелам, но сохраняем модификаторы + return record.split("\\s+"); + } + + /** + * Оценивает один механизм SPF. + */ + private SpfResult evaluateMechanism(String mechanism, String senderIp, String domain) { + // Определяем тип совпадения + char qualifier = '+'; // по умолчанию — PASS + String mech = mechanism; + + if (mechanism.startsWith("+")) { + qualifier = '+'; + mech = mechanism.substring(1); + } else if (mechanism.startsWith("-")) { + qualifier = '-'; + mech = mechanism.substring(1); + } else if (mechanism.startsWith("~")) { + qualifier = '~'; + mech = mechanism.substring(1); + } else if (mechanism.startsWith("?")) { + qualifier = '?'; + mech = mechanism.substring(1); + } + + // Проверяем механизм + boolean match = false; + + if (mech.equals("all")) { + match = true; + } else if (mech.equals("ip4") || mech.startsWith("ip4:") || mech.equals("ip6") || mech.startsWith("ip6:")) { + // IP-механизмы игнорируем для локального сервера + // В продакшене здесь была бы проверка IP-адреса + match = false; + } else if (mech.equals("mx")) { + // Проверяем, есть ли MX-запись для домена + match = hasMxRecord(domain); + } else if (mech.equals("a")) { + // Проверяем, есть ли A-запись для домена + match = hasARecord(domain); + } else if (mech.startsWith("include:")) { + // Включаем SPF другого домена + String includedDomain = mech.substring("include:".length()); + String includedSpf = getSpfRecord(includedDomain); + if (includedSpf != null) { + SpfResult includedResult = evaluateSpfRecord(includedSpf, senderIp, includedDomain); + if (includedResult == SpfResult.PASS) { + match = true; + } + } + } + + if (!match) { + return null; // Продолжаем проверять следующие механизмы + } + + // Возвращаем результат в зависимости от квалификатора + switch (qualifier) { + case '+': + return SpfResult.PASS; + case '-': + return SpfResult.FAIL; + case '~': + return SpfResult.SOFTFAIL; + case '?': + return SpfResult.NEUTRAL; + default: + return SpfResult.PASS; + } + } + + /** + * Проверяет наличие MX-записи для домена. + */ + private boolean hasMxRecord(String domain) { + try { + Hashtable env = new Hashtable<>(); + env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); + env.put(Context.PROVIDER_URL, "dns:"); + + DirContext ctx = new InitialDirContext(env); + Attributes attrs = ctx.getAttributes(domain, new String[]{"MX"}); + Attribute mxAttr = attrs.get("MX"); + + return mxAttr != null && mxAttr.size() > 0; + } catch (Exception e) { + log.debug("SPF: ошибка проверки MX для {}", domain); + return false; + } + } + + /** + * Проверяет наличие A-записи для домена. + */ + private boolean hasARecord(String domain) { + try { + Hashtable env = new Hashtable<>(); + env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); + env.put(Context.PROVIDER_URL, "dns:"); + + DirContext ctx = new InitialDirContext(env); + Attributes attrs = ctx.getAttributes(domain, new String[]{"A"}); + Attribute aAttr = attrs.get("A"); + + return aAttr != null && aAttr.size() > 0; + } catch (Exception e) { + log.debug("SPF: ошибка проверки A для {}", domain); + return false; + } + } + + /** + * Проверяет, является ли результат SPF-проверки провалом. + */ + public boolean isSpfFail(SpfResult result) { + return result == SpfResult.FAIL || result == SpfResult.SOFTFAIL; + } +} diff --git a/backend/src/main/java/com/mailserver/service/UserService.java b/backend/src/main/java/com/mailserver/service/UserService.java new file mode 100644 index 0000000..b4c6bbb --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/UserService.java @@ -0,0 +1,80 @@ +package com.mailserver.service; + +import com.mailserver.domain.Folder; +import com.mailserver.domain.User; +import com.mailserver.dto.FolderResponse; +import com.mailserver.repository.FolderRepository; +import com.mailserver.repository.UserRepository; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Instant; + +@Service +public class UserService { + + private final UserRepository userRepository; + private final FolderRepository folderRepository; + private final BCryptPasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, FolderRepository folderRepository) { + this.userRepository = userRepository; + this.folderRepository = folderRepository; + this.passwordEncoder = new BCryptPasswordEncoder(); + } + + public Mono register(String email, String password, String displayName) { + return userRepository.existsByEmail(email) + .flatMap(exists -> { + if (exists) { + return Mono.error(new IllegalArgumentException("Email already registered: " + email)); + } + + User user = new User(); + user.setEmail(email); + user.setPassword(passwordEncoder.encode(password)); + user.setDisplayName(displayName); + user.setCreatedAt(Instant.now()); + return userRepository.save(user); + }) + .flatMap(user -> { + // Создаём стандартные папки + String[] defaultFolders = {"INBOX", "Sent", "Drafts", "Trash", "Spam"}; + return Flux.fromArray(defaultFolders) + .flatMap(name -> { + Folder folder = new Folder(); + folder.setUserId(user.getId()); + folder.setName(name); + folder.setCreatedAt(Instant.now()); + return folderRepository.save(folder); + }) + .then(Mono.just(user)); + }); + } + + public Mono findByEmail(String email) { + return userRepository.findByEmail(email) + .switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + email))); + } + + /** + * Проверяет, совпадает ли пароль с хэшем, хранящимся у пользователя. + */ + public boolean checkPassword(User user, String rawPassword) { + return passwordEncoder.matches(rawPassword, user.getPassword()); + } + + public Flux getFolders(Long userId) { + return folderRepository.findByUserId(userId) + .map(f -> { + FolderResponse resp = new FolderResponse(); + resp.setId(f.getId()); + resp.setName(f.getName()); + resp.setMessageCount(f.getMessageCount()); + resp.setUnreadCount(f.getUnreadCount()); + return resp; + }); + } +} diff --git a/backend/src/main/java/com/mailserver/util/JwtUtils.java b/backend/src/main/java/com/mailserver/util/JwtUtils.java new file mode 100644 index 0000000..89ae09a --- /dev/null +++ b/backend/src/main/java/com/mailserver/util/JwtUtils.java @@ -0,0 +1,52 @@ +package com.mailserver.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +public class JwtUtils { + + private static final long EXPIRATION_MS = 1000L * 60 * 60 * 24; // 24 часа + + private final SecretKey signingKey; + + public JwtUtils(String secret) { + this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(String email) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + EXPIRATION_MS); + + return Jwts.builder() + .subject(email) + .issuedAt(now) + .expiration(expiry) + .signWith(signingKey) + .compact(); + } + + public String getEmailFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(signingKey) + .build() + .parseSignedClaims(token) + .getPayload(); + return claims.getSubject(); + } + + public boolean isValidToken(String token) { + try { + Jwts.parser() + .verifyWith(signingKey) + .build() + .parseSignedClaims(token); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties new file mode 100644 index 0000000..bf979d9 --- /dev/null +++ b/backend/src/main/resources/application-prod.properties @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..6036e63 --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1,54 @@ +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=localhost + +# IMAP-сервер для доступа к письмам +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 + +# SPF — проверка отправителей через DNS +mailserver.spf.enabled=false +mailserver.spf.local-bypass=true + +# DMARC — политика обработки писем, не прошедших SPF/DKIM +mailserver.dmarc.enabled=false +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= + +logging.level.com.mailserver=DEBUG diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..5c90e15 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,73 @@ +-- Таблица пользователей +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица папок +CREATE TABLE folders ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + type VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица писем +CREATE TABLE messages ( + id BIGSERIAL PRIMARY KEY, + folder_id BIGINT NOT NULL REFERENCES folders(id) ON DELETE CASCADE, + sender_email VARCHAR(255) NOT NULL, + sender_name VARCHAR(255), + recipient_email VARCHAR(255) NOT NULL, + subject VARCHAR(1000), + body TEXT, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + is_spam BOOLEAN NOT NULL DEFAULT FALSE, + message_size BIGINT, + received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица вложений +CREATE TABLE attachments ( + id BIGSERIAL PRIMARY KEY, + message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(255), + size_bytes BIGINT, + file_path VARCHAR(500) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица чёрного списка +CREATE TABLE blacklist ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sender_email VARCHAR(255) NOT NULL, + reason VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, sender_email) +); + +-- Таблица контактов +CREATE TABLE contacts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, email) +); + +-- Индексы для ускорения поиска +CREATE INDEX idx_messages_folder_id ON messages(folder_id); +CREATE INDEX idx_messages_received_at ON messages(received_at DESC); +CREATE INDEX idx_messages_is_read ON messages(is_read); +CREATE INDEX idx_messages_is_spam ON messages(is_spam); +CREATE INDEX idx_folders_user_id ON folders(user_id); +CREATE INDEX idx_attachments_message_id ON attachments(message_id); +CREATE INDEX idx_blacklist_user_id ON blacklist(user_id); +CREATE INDEX idx_contacts_user_id ON contacts(user_id); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..14dc1a0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + mail-server: + build: ./backend + ports: + - "8080:8080" # REST API + - "3025:3025" # SMTP (приём писем) + - "143:143" # IMAP (доступ к письмам) + volumes: + - attachments:/app/attachments + - dkim-keys:/app/dkim-keys + environment: + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mailserver + - SPRING_DATASOURCE_USERNAME=mail + - SPRING_DATASOURCE_PASSWORD=secret + - SPRING_MAIL_HOST=mailpit + - SPRING_MAIL_PORT=1025 + depends_on: + - db + - mailpit + + db: + image: postgres:17 + environment: + POSTGRES_DB: mailserver + POSTGRES_USER: mail + POSTGRES_PASSWORD: secret + volumes: + - pg-data:/var/lib/postgresql/data + ports: + - "5432:5432" + + mailpit: + image: axllent/mailpit + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI + volumes: + - mailpit-data:/data + +volumes: + attachments: + pg-data: + mailpit-data: + dkim-keys: diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..854acd5 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,44 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..d6c16d7 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e79f119 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,59 @@ +# Frontend + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.2.4. + +## Development server + +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..ebb9006 --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,95 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "frontend": { + "projectType": "application", + "schematics": { + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:component": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true + }, + "@schematics/angular:resolver": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "frontend:build:production" + }, + "development": { + "buildTarget": "frontend:build:development" + } + }, + "defaultConfiguration": "development" + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e9be16d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "packageManager": "npm@11.17.0", + "dependencies": { + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/forms": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.2.4", + "@angular/cli": "^22.2.4", + "@angular/compiler-cli": "^22.2.0", + "@tailwindcss/postcss": "^4.3.4", + "prettier": "^3.8.1", + "tailwindcss": "^4.3.4", + "typescript": "~6.0.2" + } +} diff --git a/frontend/postcss.config.json b/frontend/postcss.config.json new file mode 100644 index 0000000..e092dc7 --- /dev/null +++ b/frontend/postcss.config.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..57614f9 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..4574655 --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -0,0 +1,13 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter, withHashLocation } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { routes } from './app.routes'; +import { authInterceptor } from './interceptors/auth.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])) + ] +}; diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html new file mode 100644 index 0000000..67e7bd4 --- /dev/null +++ b/frontend/src/app/app.html @@ -0,0 +1 @@ + diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..1157bfb --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -0,0 +1,39 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { + path: 'login', + loadComponent: () => import('./components/login/login.component'), + }, + { + path: 'register', + loadComponent: () => import('./components/register/register.component'), + }, + { + path: '', + loadComponent: () => import('./components/layout/layout.component'), + children: [ + { + path: 'inbox', + loadComponent: () => import('./components/inbox/inbox.component'), + }, + { + path: 'compose', + loadComponent: () => import('./components/compose/compose.component'), + }, + { + path: 'message/:id', + loadComponent: () => import('./components/message-detail/message-detail.component'), + }, + { + path: '', + redirectTo: 'inbox', + pathMatch: 'full', + }, + ], + }, + { + path: '**', + redirectTo: 'login', + }, +]; diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts new file mode 100644 index 0000000..f3b2652 --- /dev/null +++ b/frontend/src/app/app.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + imports: [RouterOutlet], + selector: 'app-root', + template: ``, + styles: `` +}) +export class App {} diff --git a/frontend/src/app/components/compose/compose.component.ts b/frontend/src/app/components/compose/compose.component.ts new file mode 100644 index 0000000..e6768d6 --- /dev/null +++ b/frontend/src/app/components/compose/compose.component.ts @@ -0,0 +1,85 @@ +import { Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { MailService } from '../../services/mail.service'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-compose', + standalone: true, + imports: [RouterLink, FormsModule], + template: ` +
+

+ ✉️ COMPOSE MESSAGE +

+ + @if (success) { +
+ Message sent successfully! +
+ } + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + CANCEL + +
+
+
+ `, + styles: `` +}) +export default class ComposeComponent { + private mailService = inject(MailService); + private router = inject(Router); + + to = ''; + subject = ''; + body = ''; + sending = false; + success = false; + + onSend(): void { + this.sending = true; + this.mailService.send({ to: this.to, subject: this.subject, body: this.body }).subscribe({ + next: () => { + this.success = true; + this.sending = false; + this.to = ''; + this.subject = ''; + this.body = ''; + setTimeout(() => this.router.navigate(['/inbox']), 1500); + }, + error: () => { + this.sending = false; + } + }); + } +} diff --git a/frontend/src/app/components/inbox/inbox.component.ts b/frontend/src/app/components/inbox/inbox.component.ts new file mode 100644 index 0000000..210ce79 --- /dev/null +++ b/frontend/src/app/components/inbox/inbox.component.ts @@ -0,0 +1,145 @@ +import { Component, inject, signal } from '@angular/core'; +import { RouterLink, ActivatedRoute } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { MailService } from '../../services/mail.service'; +import { Message, Folder } from '../../models/mail.models'; +import { DatePipe, SlicePipe } from '@angular/common'; +import { map, switchMap } from 'rxjs/operators'; +import { of } from 'rxjs'; + +@Component({ + selector: 'app-inbox', + standalone: true, + imports: [RouterLink, FormsModule, DatePipe, SlicePipe], + providers: [MailService], + template: ` + + `, + styles: `` +}) +export default class InboxComponent { + private route = inject(ActivatedRoute); + private mailService = inject(MailService); + messages = signal([]); + folderName = signal('INBOX'); + searchQuery = ''; + folderId = signal(1); + + constructor() { + // Подписываемся на изменения folderId в queryParams + this.route.queryParams.subscribe(params => { + const newFolderId = params['folderId'] ? Number(params['folderId']) : 1; + if (newFolderId !== this.folderId()) { + this.folderId.set(newFolderId); + this.loadFolderName(newFolderId); + this.loadMessages(); + } + }); + // Начальная загрузка + const initialFolderId = this.route.snapshot.queryParams['folderId'] ? Number(this.route.snapshot.queryParams['folderId']) : 1; + this.folderId.set(initialFolderId); + this.loadFolderName(initialFolderId); + this.loadMessages(); + } + + loadFolderName(folderId: number): void { + this.mailService.getFolders().subscribe({ + next: (folders) => { + const folder = folders.find(f => f.id === folderId); + this.folderName.set(folder ? folder.name : 'INBOX'); + }, + error: () => this.folderName.set('INBOX') + }); + } + + loadMessages(): void { + const currentFolderId = this.folderId(); + if (this.searchQuery.trim()) { + this.mailService.searchMessages(currentFolderId, this.searchQuery.trim()).subscribe({ + next: (msgs) => this.messages.set(msgs), + error: () => console.error('Search failed') + }); + } else { + this.mailService.getMessages(currentFolderId).subscribe({ + next: (msgs) => this.messages.set(msgs), + error: () => console.error('Failed to load messages') + }); + } + } +} diff --git a/frontend/src/app/components/layout/layout.component.ts b/frontend/src/app/components/layout/layout.component.ts new file mode 100644 index 0000000..709214f --- /dev/null +++ b/frontend/src/app/components/layout/layout.component.ts @@ -0,0 +1,136 @@ +import { Component, inject, signal } from '@angular/core'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { MailService } from '../../services/mail.service'; +import { Folder } from '../../models/mail.models'; + +@Component({ + selector: 'app-layout', + standalone: true, + imports: [RouterOutlet, RouterLink, RouterLinkActive], + template: ` +
+ + + + +
+ +
+
+
+
+ M +
+
+

+ MAILSERVER +

+

Synthwave Edition

+
+
+ +
+ + + + {{ email }} + +
+
+
+ + +
+ + + + +
+ +
+
+
+
+ `, + styles: `` +}) +export default class LayoutComponent { + authService = inject(AuthService); + mailService = inject(MailService); + + email = this.authService.getEmail() || ''; + folders = signal([]); + isLightTheme = signal(false); + + constructor() { + this.loadFolders(); + // Загружаем сохранённую тему + const saved = localStorage.getItem('theme'); + if (saved === 'light') { + this.isLightTheme.set(true); + document.documentElement.classList.add('light-theme'); + } + } + + toggleTheme(): void { + const isLight = !this.isLightTheme(); + this.isLightTheme.set(isLight); + if (isLight) { + document.documentElement.classList.add('light-theme'); + localStorage.setItem('theme', 'light'); + } else { + document.documentElement.classList.remove('light-theme'); + localStorage.setItem('theme', 'dark'); + } + } + + loadFolders(): void { + this.mailService.getFolders().subscribe({ + next: (folders) => this.folders.set(folders), + error: () => console.error('Failed to load folders') + }); + } + + onLogout(): void { + this.authService.logout(); + window.location.href = '/login'; + } +} diff --git a/frontend/src/app/components/login/login.component.ts b/frontend/src/app/components/login/login.component.ts new file mode 100644 index 0000000..7b02364 --- /dev/null +++ b/frontend/src/app/components/login/login.component.ts @@ -0,0 +1,93 @@ +import { Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { AuthService } from '../../services/auth.service'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [RouterLink, FormsModule], + template: ` +
+ + + + +
+
+
+
+ M +
+

+ MAILSERVER +

+

Sign in to your mailbox

+
+ + @if (error) { +
+ {{ error }} +
+ } + +
+
+ + +
+ +
+ + +
+ + +
+ +

+ No account? + + Register here + +

+
+
+
+ `, + styles: `` +}) +export default class LoginComponent { + private authService = inject(AuthService); + private router = inject(Router); + + email = ''; + password = ''; + error = '' as string | null; + loading = false as boolean; + + onLogin(): void { + this.loading = true; + this.error = null; + this.authService.login({ email: this.email, password: this.password }).subscribe({ + next: () => this.router.navigate(['/inbox']), + error: (err) => { + this.error = 'Invalid email or password'; + this.loading = false; + } + }); + } +} diff --git a/frontend/src/app/components/message-detail/message-detail.component.ts b/frontend/src/app/components/message-detail/message-detail.component.ts new file mode 100644 index 0000000..e344647 --- /dev/null +++ b/frontend/src/app/components/message-detail/message-detail.component.ts @@ -0,0 +1,112 @@ +import { Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { MailService } from '../../services/mail.service'; +import { ActivatedRoute } from '@angular/router'; +import { DatePipe } from '@angular/common'; +import { Message } from '../../models/mail.models'; + +@Component({ + selector: 'app-message-detail', + standalone: true, + imports: [RouterLink, DatePipe], + template: ` + @if (message) { +
+ +
+

+ {{ message.subject || '(No subject)' }} +

+ +
+
+ FROM: + {{ message.senderName || message.sender }} +
+
+ DATE: + {{ message.sentAt | date:'MMM d, y, HH:mm' }} +
+
+ + @if (message.spam) { +
+ ⚠️ MARKED AS SPAM +
+ } +
+ + +
+

+ {{ message.body }} +

+
+ + + @if (message.hasAttachments) { +
+

+ 📎 ATTACHMENTS ({{ message.attachmentCount }}) +

+

+ Attachments are available via the API +

+
+ } + + +
+ + ← BACK TO INBOX + + +
+
+ } @else { +
+

📭

+

Message not found

+ + ← BACK TO INBOX + +
+ } + `, + styles: `` +}) +export default class MessageDetailComponent { + private mailService = inject(MailService); + private route = inject(ActivatedRoute); + + message: Message | null = null; + messageId = 0; + + constructor() { + this.messageId = Number(this.route.snapshot.paramMap.get('id')); + this.loadMessage(); + } + + loadMessage(): void { + this.mailService.getMessage(this.messageId).subscribe({ + next: (msg) => { + this.message = msg; + if (!msg.read) { + this.mailService.markAsRead(this.messageId).subscribe(); + } + }, + error: () => console.error('Failed to load message') + }); + } + + onDelete(): void { + if (confirm('Delete this message?')) { + this.mailService.deleteMessage(this.messageId).subscribe({ + next: () => (window.location.href = '/inbox'), + error: () => console.error('Failed to delete message') + }); + } + } +} diff --git a/frontend/src/app/components/register/register.component.ts b/frontend/src/app/components/register/register.component.ts new file mode 100644 index 0000000..c8b9ea9 --- /dev/null +++ b/frontend/src/app/components/register/register.component.ts @@ -0,0 +1,116 @@ +import { Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { AuthService } from '../../services/auth.service'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-register', + standalone: true, + imports: [RouterLink, FormsModule], + template: ` +
+ + + + +
+
+
+
+ M +
+

+ MAILSERVER +

+

Create your mailbox

+
+ + @if (error) { +
+ {{ error }} +
+ } + @if (success) { +
+ Account created! You can now login. +
+ } + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account? + + Login here + +

+
+
+
+ `, + styles: `` +}) +export default class RegisterComponent { + private authService = inject(AuthService); + private router = inject(Router); + + displayName = ''; + email = ''; + password = ''; + error = '' as string | null; + success = false as boolean; + loading = false as boolean; + + onRegister(): void { + this.loading = true; + this.error = null; + this.success = false; + this.authService.register({ + email: this.email, + password: this.password, + displayName: this.displayName + }).subscribe({ + next: () => { + this.success = true; + this.loading = false; + setTimeout(() => this.router.navigate(['/login']), 1500); + }, + error: (err) => { + this.error = 'Registration failed. Email may already be taken.'; + this.loading = false; + } + }); + } +} diff --git a/frontend/src/app/interceptors/auth.interceptor.ts b/frontend/src/app/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..c7a2330 --- /dev/null +++ b/frontend/src/app/interceptors/auth.interceptor.ts @@ -0,0 +1,16 @@ +import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http'; + +export const authInterceptor: HttpInterceptorFn = (req: HttpRequest, next: HttpHandlerFn) => { + const token = localStorage.getItem('token'); + + if (token) { + const cloned = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}` + } + }); + return next(cloned); + } + + return next(req); +}; diff --git a/frontend/src/app/models/mail.models.ts b/frontend/src/app/models/mail.models.ts new file mode 100644 index 0000000..4aa08e5 --- /dev/null +++ b/frontend/src/app/models/mail.models.ts @@ -0,0 +1,64 @@ +export interface Message { + id: number; + subject: string; + body: string; + sender: string; + senderName?: string; + recipient: string; + sentAt: string; + read: boolean; + flagged: boolean; + spam: boolean; + folderId: number; + folderName: string; + hasAttachments: boolean; + attachmentCount: number; +} + +export interface Folder { + id: number; + name: string; + unreadCount: number; +} + +export interface Attachment { + id: number; + filename: string; + contentType: string; + sizeBytes: number; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + token: string; + email: string; +} + +export interface RegisterRequest { + email: string; + password: string; + displayName: string; +} + +export interface SendRequest { + to: string; + subject: string; + body: string; +} + +export interface BlacklistEntry { + senderEmail: string; + reason?: string; +} + +export interface PageResponse { + content: T[]; + number: number; + size: number; + totalElements: number; + totalPages: number; +} diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts new file mode 100644 index 0000000..6f9e4d8 --- /dev/null +++ b/frontend/src/app/services/auth.service.ts @@ -0,0 +1,57 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { tap, shareReplay } from 'rxjs/operators'; +import { LoginRequest, LoginResponse, RegisterRequest } from '../models/mail.models'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:8080/api'; + + private currentUserSubject = new BehaviorSubject( + localStorage.getItem('email') + ); + currentUser$ = this.currentUserSubject.asObservable(); + + private tokenSubject = new BehaviorSubject( + localStorage.getItem('token') + ); + token$ = this.tokenSubject.asObservable(); + + login({ email, password }: LoginRequest): Observable { + return this.http.post(`${this.apiUrl}/login`, { email, password }).pipe( + tap(({ token, email }) => { + localStorage.setItem('token', token); + localStorage.setItem('email', email); + this.tokenSubject.next(token); + this.currentUserSubject.next(email); + }) + ); + } + + register({ email, password, displayName }: RegisterRequest): Observable { + return this.http.post(`${this.apiUrl}/register`, { email, password, displayName }); + } + + logout(): void { + localStorage.removeItem('token'); + localStorage.removeItem('email'); + this.tokenSubject.next(null); + this.currentUserSubject.next(null); + } + + getToken(): string | null { + return localStorage.getItem('token'); + } + + isLoggedIn(): boolean { + return !!localStorage.getItem('token'); + } + + getEmail(): string | null { + return localStorage.getItem('email'); + } +} diff --git a/frontend/src/app/services/mail.service.ts b/frontend/src/app/services/mail.service.ts new file mode 100644 index 0000000..787435d --- /dev/null +++ b/frontend/src/app/services/mail.service.ts @@ -0,0 +1,96 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; +import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse } from '../models/mail.models'; + +@Injectable({ + providedIn: 'root' +}) +export class MailService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:8080/api'; + + // Папки + getFolders(): Observable { + return this.http.get(`${this.apiUrl}/folders`); + } + + // Письма + getMessages(folderId: number, page: number = 0, size: number = 20): Observable { + return this.http.get>(`${this.apiUrl}/messages`, { + params: { folderId, page, size } + }).pipe(map(response => response.content)); + } + + getMessage(id: number): Observable { + return this.http.get(`${this.apiUrl}/messages/${id}`); + } + + searchMessages( + folderId: number, + keyword?: string, + subject?: string, + body?: string, + sender?: string, + recipient?: string + ): Observable { + const params: { [key: string]: string } = { folderId: folderId.toString() }; + if (keyword) params['keyword'] = keyword; + if (subject) params['subject'] = subject; + if (body) params['body'] = body; + if (sender) params['sender'] = sender; + if (recipient) params['recipient'] = recipient; + return this.http.get>(`${this.apiUrl}/messages/search`, { params }) + .pipe(map(response => response.content)); + } + + // Отправка + send(sendRequest: SendRequest): Observable { + return this.http.post(`${this.apiUrl}/send`, sendRequest); + } + + // Статус + markAsRead(id: number): Observable { + return this.http.put(`${this.apiUrl}/messages/${id}/read`, null); + } + + deleteMessage(id: number): Observable { + return this.http.delete(`${this.apiUrl}/messages/${id}`); + } + + // Вложения + getAttachments(messageId: number): Observable { + return this.http.get(`${this.apiUrl}/messages/${messageId}/attachments`); + } + + uploadAttachment(messageId: number, file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + return this.http.post(`${this.apiUrl}/messages/${messageId}/attachments`, formData); + } + + downloadAttachment(attachmentId: number): Observable { + return this.http.get(`${this.apiUrl}/attachments/${attachmentId}/download`, { + responseType: 'blob' as 'json' + }); + } + + deleteAttachment(attachmentId: number): Observable { + return this.http.delete(`${this.apiUrl}/attachments/${attachmentId}`); + } + + // Чёрный список + getBlacklist(): Observable { + return this.http.get(`${this.apiUrl}/spam/blacklist`); + } + + addToBlacklist(entry: BlacklistEntry): Observable { + return this.http.post(`${this.apiUrl}/spam/blacklist`, entry); + } + + removeFromBlacklist(senderEmail: string): Observable { + return this.http.delete(`${this.apiUrl}/spam/blacklist`, { + params: { senderEmail } + }); + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..ce38570 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,16 @@ + + + + + MailServer — Synthwave Edition + + + + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..5df75f9 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..38ecc46 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,254 @@ +@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap'); + +@import "tailwindcss"; + +@theme { + --color-synth-dark: #0d0221; + --color-synth-darker: #0a0118; + --color-synth-pink: #ff007f; + --color-synth-cyan: #00f0ff; + --color-synth-purple: #7928ca; + --color-synth-yellow: #ffbe0b; + --color-synth-glass: rgba(13, 2, 33, 0.6); + + --font-family-orbitron: 'Orbitron', sans-serif; + --font-family-rajdhani: 'Rajdhani', sans-serif; + --font-family-fira-code: 'Fira Code', monospace; +} + +:root { + --color-synth-dark: #0d0221; + --color-synth-pink: #ff007f; + --color-synth-cyan: #00f0ff; + --color-synth-purple: #7928ca; + --color-synth-yellow: #ffbe0b; + --color-synth-glass: rgba(13, 2, 33, 0.6); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background-color: var(--color-synth-dark); + color: #f1f5f9; + font-family: 'Rajdhani', sans-serif; + overflow-x: hidden; +} + +/* Glassmorphism панель */ +.glass-panel { + background: var(--color-synth-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(0, 240, 255, 0.25); + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), inset 0 0 15px rgba(255, 255, 255, 0.03); + border-radius: 16px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glass-panel:hover { + border-color: var(--color-synth-cyan); + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.6), 0 0 15px rgba(0, 240, 255, 0.6); +} + +/* Неоновый текст */ +.neon-text-primary { + color: #ffffff; + text-shadow: 0 0 5px var(--color-synth-pink), 0 0 15px var(--color-synth-pink); +} + +.neon-text-secondary { + color: #ffffff; + text-shadow: 0 0 5px var(--color-synth-cyan), 0 0 15px var(--color-synth-cyan); +} + +/* Cyber кнопка */ +.cyber-btn { + position: relative; + background: linear-gradient(135deg, rgba(255, 0, 127, 0.2), rgba(0, 240, 255, 0.2)); + border: 1px solid var(--color-synth-cyan); + color: #ffffff; + font-family: 'Orbitron', sans-serif; + text-transform: uppercase; + letter-spacing: 1px; + transition: all 0.2s ease; + overflow: hidden; + cursor: pointer; +} + +.cyber-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + transition: 0.5s; +} + +.cyber-btn:hover::before { + left: 100%; +} + +.cyber-btn:hover { + background: var(--color-synth-cyan); + color: var(--color-synth-dark); + box-shadow: 0 0 20px var(--color-synth-cyan); + transform: translateY(-2px); +} + +/* Synthwave фон с сеткой */ +.synth-bg { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +.synth-sun { + position: absolute; + top: 10%; + left: 50%; + transform: translateX(-50%); + width: 200px; + height: 200px; + border-radius: 50%; + background: linear-gradient(to bottom, #ffbe0b, var(--color-synth-pink), var(--color-synth-purple)); + box-shadow: 0 0 60px var(--color-synth-pink), 0 0 100px var(--color-synth-purple); +} + +.synth-sun::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: repeating-linear-gradient( + to bottom, + transparent 0px, + transparent 4px, + var(--color-synth-dark) 4px, + var(--color-synth-dark) 8px + ); + mask-image: linear-gradient(to bottom, transparent 40%, black 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent 40%, black 100%); +} + +.synth-grid { + position: absolute; + bottom: 0; + left: -50%; + width: 200%; + height: 40%; + perspective: 400px; + mask-image: linear-gradient(to top, rgba(0,0,0,1) 60%, rgba(0,0,0,0) 100%); + -webkit-mask-image: linear-gradient(to top, rgba(0,0,0,1) 60%, rgba(0,0,0,0) 100%); +} + +.synth-grid-plane { + width: 100%; + height: 200%; + transform: rotateX(75deg); + background-size: 40px 40px; + background-image: + linear-gradient(to right, rgba(255, 0, 127, 0.3) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 0, 127, 0.3) 1px, transparent 1px); + animation: gridMove 2s linear infinite; +} + +@keyframes gridMove { + 0% { transform: rotateX(75deg) translateY(0); } + 100% { transform: rotateX(75deg) translateY(40px); } +} + +/* Кастомный скроллбар */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: rgba(13, 2, 33, 0.8); +} +::-webkit-scrollbar-thumb { + background: var(--color-synth-pink); + border-radius: 4px; + box-shadow: 0 0 10px var(--color-synth-pink); +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-synth-cyan); +} + +/* Неоновое свечение */ +.neon-glow-pink { + box-shadow: 0 0 10px var(--color-synth-pink), 0 0 20px var(--color-synth-pink); +} + +.neon-glow-cyan { + box-shadow: 0 0 10px var(--color-synth-cyan), 0 0 20px var(--color-synth-cyan); +} + +/* Светлая тема */ +.light-theme { + --color-synth-dark: #f0f0f5; + --color-synth-glass: rgba(255, 255, 255, 0.7); +} + +.light-theme body, +.light-theme { + background-color: #f0f0f5 !important; + color: #1a1a2e !important; +} + +.light-theme .glass-panel { + background: rgba(255, 255, 255, 0.8) !important; + border-color: rgba(121, 40, 202, 0.3) !important; + color: #1a1a2e !important; +} + +.light-theme .synth-bg { + display: none !important; +} + +.light-theme .neon-text-primary, +.light-theme .neon-text-secondary { + text-shadow: none !important; + color: #1a1a2e !important; +} + +.light-theme input, +.light-theme .cyber-btn { + background: rgba(255, 255, 255, 0.9) !important; + color: #1a1a2e !important; + border-color: rgba(121, 40, 202, 0.4) !important; +} + +.light-theme .cyber-btn:hover { + background: var(--color-synth-purple) !important; + color: #fff !important; +} + +.light-theme a, +.light-theme span, +.light-theme p, +.light-theme h1, +.light-theme h2, +.light-theme label { + color: #1a1a2e !important; +} + +.light-theme .text-slate-200, +.light-theme .text-slate-300, +.light-theme .text-slate-400 { + color: #333 !important; +} + +.light-theme .bg-synth-darker\/80 { + background-color: rgba(255, 255, 255, 0.9) !important; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..cb151e1 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..30d64ce --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,28 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..9c8efb9 --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..eb84db6 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..69fada4 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,5 @@ +include 'backend' +include 'frontend' + +project(':backend').projectDir = file('backend') +project(':frontend').projectDir = file('frontend')