Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-11-05 01:07:29 +01:00
co-authored by Junie
commit 0339413d6e
78 changed files with 6808 additions and 0 deletions
+48
View File
@@ -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
+394
View File
@@ -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 <token>`.
### Получить папки
```bash
curl http://localhost:8080/api/folders \
-H "Authorization: Bearer <token>"
```
### Получить письма из папки
```bash
curl "http://localhost:8080/api/messages?folderId=1&page=0&size=20" \
-H "Authorization: Bearer <token>"
```
### Отправить письмо
```bash
curl -X POST http://localhost:8080/api/send \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"to":"recipient@example.com","subject":"Hello","body":"Test message"}'
```
### Симулировать входящее письмо (для тестов)
```bash
curl -X POST http://localhost:8080/api/simulate/receive \
-H "Content-Type: application/json" \
-d '{
"senderEmail":"sender@example.com",
"senderName":"Sender",
"recipientEmail":"test@example.com",
"subject":"Incoming test",
"body":"This is a test message"
}'
```
### Отметить как прочитанное
```bash
curl -X PUT http://localhost:8080/api/messages/1/read \
-H "Authorization: Bearer <token>"
```
### Удалить письмо
```bash
curl -X DELETE http://localhost:8080/api/messages/1 \
-H "Authorization: Bearer <token>"
```
### Загрузить вложение к письму
```bash
curl -X POST http://localhost:8080/api/messages/1/attachments \
-H "Authorization: Bearer <token>" \
-F "file=@/path/to/file.pdf"
```
### Получить список вложений письма
```bash
curl http://localhost:8080/api/messages/1/attachments \
-H "Authorization: Bearer <token>"
```
### Скачать вложение
```bash
curl http://localhost:8080/api/attachments/1/download \
-H "Authorization: Bearer <token>" \
-o file.pdf
```
### Удалить вложение
```bash
curl -X DELETE http://localhost:8080/api/attachments/1 \
-H "Authorization: Bearer <token>"
```
### Поиск по письмам
Поиск поддерживает два режима: простой поиск по ключевому слову и расширенный поиск с отдельными фильтрами.
**Простой поиск** — ищет по ключевому слову в теме, теле, отправителе и получателе:
```bash
curl "http://localhost:8080/api/messages/search?folderId=1&keyword=hello" \
-H "Authorization: Bearer <token>"
```
**Расширенный поиск** — используйте отдельные параметры для точной фильтрации:
```bash
# Поиск по теме
curl "http://localhost:8080/api/messages/search?folderId=1&subject=report" \
-H "Authorization: Bearer <token>"
# Поиск по тексту письма
curl "http://localhost:8080/api/messages/search?folderId=1&body=invoice" \
-H "Authorization: Bearer <token>"
# Поиск по отправителю
curl "http://localhost:8080/api/messages/search?folderId=1&sender=company.com" \
-H "Authorization: Bearer <token>"
# Комбинированный поиск (все фильтры применяются одновременно)
curl "http://localhost:8080/api/messages/search?folderId=1&subject=report&sender=company.com" \
-H "Authorization: Bearer <token>"
```
Все параметры поиска (`keyword`, `subject`, `body`, `sender`, `recipient`) необязательны. Если ни один параметр не указан, возвращаются все письма папки.
### Отправить письмо через SMTP-сервер
Встроенный SMTP-сервер слушает порт **3025**. Любое письмо, отправленное на этот порт, будет сохранено в базе данных, если получатель зарегистрирован в системе.
```bash
# Отправить письмо через telnet
telnet localhost 3025
```
Или с помощью curl:
```bash
curl --url 'smtp://localhost:3025' \
--mail-from 'sender@example.com' \
--mail-rcpt 'test@example.com' \
-T <(echo -e 'From: sender@example.com\nTo: test@example.com\nSubject: SMTP Test\n\nHello from SMTP!')
```
Письмо появится во входящих (INBOX) пользователя `test@example.com`.
### Управление чёрным списком
Добавьте отправителя в чёрный список — письма от него будут автоматически помечаться как спам.
**Получить список заблокированных отправителей:**
```bash
curl http://localhost:8080/api/spam/blacklist \
-H "Authorization: Bearer <token>"
```
**Добавить отправителя в чёрный список:**
```bash
curl -X POST http://localhost:8080/api/spam/blacklist \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"senderEmail":"spammer@example.com","reason":"Спам"}'
```
**Удалить отправителя из чёрного списка:**
```bash
curl -X DELETE "http://localhost:8080/api/spam/blacklist?senderEmail=spammer@example.com" \
-H "Authorization: Bearer <token>"
```
Письма от заблокированных отправителей автоматически получают флаг `spam: true` и могут быть отфильтрованы на стороне клиента.
### Подключение через IMAP-сервер
Встроенный IMAP-сервер слушает порт **143**. Вы можете подключиться к нему из любого почтового клиента (Thunderbird, Outlook, Apple Mail) или через telnet для тестирования.
```bash
# Подключение через telnet
telnet localhost 143
```
Пример сессии IMAP:
```
a LOGIN test@example.com secret123
a OK LOGIN completed
a LIST "" "*"
* LIST (\HasNoChildren) "." "INBOX"
* LIST (\HasNoChildren) "." "Sent"
a OK LIST completed
a SELECT "INBOX"
* 5 EXISTS
* 0 RECENT
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
a OK [READ-WRITE] SELECT completed
a FETCH 1 (BODY[])
* 1 FETCH (UID 1 FLAGS (\Seen) BODY[] {123
Hello, this is a test message.
))
a OK FETCH completed
a SEARCH ALL
* SEARCH 1 2 3 4 5
a OK SEARCH completed
a LOGOUT
* BYE IMAP4rev1 server terminating
a OK LOGOUT completed
```
Поддерживаемые IMAP-команды:
- **LOGIN** — аутентификация по email и паролю
- **SELECT / EXAMINE** — выбор папки для работы
- **FETCH** — получение заголовков и тела писем
- **SEARCH** — поиск по письмам
- **STORE** — изменение флагов (прочитано, помечено, удалено)
- **EXPUNGE** — удаление помеченных писем
- **LIST / LSUB** — список папок
- **STATUS** — информация о папке (количество писем, непрочитанных)
- **CAPABILITY** — список поддерживаемых возможностей
- **LOGOUT** — выход из сессии
Порт IMAP настраивается через `mailserver.imap.port` в `application.properties` (по умолчанию 143).
### DKIM / SPF / DMARC — защита от подделки писем
Почтовый сервер поддерживает три стандарта защиты электронной почты:
**DKIM (DomainKeys Identified Mail)** — криптографическая подпись писем.
- При отправке письма автоматически подписываются RSA-ключом (2048 бит)
- При получении входящих писем проверяется DKIM-подпись отправителя
- Ключи хранятся в директории `./dkim-keys/` (генерируются автоматически при первом запуске)
- Публичный ключ публикуется в DNS TXT-записи: `_default._domainkey.<domain>`
**SPF (Sender Policy Framework)** — проверка разрешённых серверов отправителя.
- При получении письма проверяется SPF DNS-запись домена отправителя
- Поддерживаемые механизмы: `all`, `ip4`, `ip6`, `mx`, `a`, `include`
- Локальные адреса (127.0.0.1, 192.168.x.x) пропускаются без проверки
**DMARC (Domain-based Message Authentication, Reporting & Conformance)** — политика обработки.
- Объединяет результаты SPF и DKIM с проверкой alignment (совпадение доменов)
- Поддерживаемые политики: `none` (мониторинг), `quarantine` (спам), `reject` (отклонить)
- Если SPF и DKIM не пройдены, применяется политика DMARC из DNS
**Результат проверки:**
- Письма, не прошедшие SPF/DKIM/DMARC, автоматически помечаются флагом `spam: true`
- Логируются результаты всех трёх проверок для каждого входящего письма
**Настройки в `application.properties`:**
```
mailserver.dkim.enabled=true
mailserver.dkim.domain=localhost
mailserver.dkim.keys-dir=./dkim-keys
mailserver.spf.enabled=true
mailserver.spf.local-bypass=true
mailserver.dmarc.enabled=true
mailserver.dmarc.local-bypass=true
```
**Публикация DKIM-ключа в DNS:**
При первом запуске сервер выводит в лог публичный ключ DKIM. Добавьте его как TXT-запись:
```
_name: _default._domainkey.yourdomain.com
type: TXT
value: v=DKIM1; k=rsa; p=MIIBojANBgkqhkiG9w0BAQ...
```
## Модели данных
| Сущность | Описание |
|---|---|
| **User** | Почтовый ящик, пароль, квота |
| **Folder** | INBOX, Sent, Drafts, Trash, Spam |
| **Message** | Заголовки, тело, флаги (read, flagged, spam) |
| **BlacklistEntry** | Заблокированный отправитель, привязка к пользователю |
| **Attachment** | Имя, MIME-тип, размер, путь к файлу |
| **Contact** | Имя, email, телефон, компания |
## 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
+5
View File
@@ -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"]
+52
View File
@@ -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()
}
@@ -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);
}
}
@@ -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");
}
};
}
}
@@ -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<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
java.util.List<com.mailserver.domain.Message> 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<Long> 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<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
java.util.List<com.mailserver.domain.Message> 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<com.mailserver.domain.Folder> 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<com.mailserver.domain.Folder> 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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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<String, String> capabilities = new HashMap<>();
public SmtpServerConfig(MailService mailService, SmtpRelayService smtpRelayService,
DkimService dkimService,
SpfService spfService, DmarcService dmarcService) {
this.mailService = mailService;
this.smtpRelayService = smtpRelayService;
this.dkimService = dkimService;
this.spfService = spfService;
this.dmarcService = dmarcService;
capabilities.put("SIZE", "10485760");
capabilities.put("8BITMIME", "");
capabilities.put("ENHANCEDSTATUSCODES", "");
capabilities.put("PIPELINING", "");
}
@PostConstruct
public void start() {
if (!enabled) {
log.info("SMTP-сервер отключён (mailserver.smtp.enabled=false)");
return;
}
try {
disposableServer = TcpServer.create()
.port(smtpPort)
.handle((inbound, outbound) ->
outbound.sendString(Mono.just("220 " + smtpHostname + " ESMTP ready\r\n"))
.then(handleSmtpSession(inbound, outbound))
)
.bind()
.block(java.time.Duration.ofSeconds(10));
log.info("SMTP-сервер запущен на порту {} (хост: {})", smtpPort, smtpHostname);
} catch (Exception e) {
log.error("Не удалось запустить SMTP-сервер на порту {}: {}", smtpPort, e.getMessage());
// Не бросаем исключение, чтобы приложение могло стартовать без SMTP
}
}
@PreDestroy
public void stop() {
if (disposableServer != null) {
try {
disposableServer.dispose();
} catch (Exception e) {
log.warn("Ошибка при остановке SMTP-сервера: {}", e.getMessage());
}
log.info("SMTP-сервер остановлен");
}
}
private Mono<Void> handleSmtpSession(reactor.netty.NettyInbound inbound,
reactor.netty.NettyOutbound outbound) {
SmtpSession session = new SmtpSession();
return inbound.receive()
.asString(StandardCharsets.UTF_8)
.doOnNext(line -> log.debug("SMTP: команда: {}", line.trim()))
.concatMap(line -> {
line = line.trim();
if (line.isEmpty()) {
return Mono.empty();
}
String[] parts = line.split("\\s+", 2);
String command = parts[0].toUpperCase();
String argument = parts.length > 1 ? parts[1] : "";
switch (command) {
case "HELO": {
session.helo = true;
session.ehlo = false;
session.hostname = argument;
return Mono.just("250 " + smtpHostname + " Hello " + argument + "\r\n");
}
case "EHLO": {
session.helo = true;
session.ehlo = true;
session.hostname = argument;
return Mono.just(buildEhloResponse(argument));
}
case "MAIL": {
if (!session.helo) {
return Mono.just("503 Hello required first\r\n");
}
String mailFrom = extractEmail(argument);
if (mailFrom == null) {
return Mono.just("501 Syntax: MAIL FROM:<address>\r\n");
}
session.mailFrom = mailFrom;
return Mono.just("250 OK\r\n");
}
case "RCPT": {
if (!session.helo) {
return Mono.just("503 Hello required first\r\n");
}
if (session.mailFrom == null) {
return Mono.just("503 MAIL required first\r\n");
}
String rcptTo = extractEmail(argument);
if (rcptTo == null) {
return Mono.just("501 Syntax: RCPT TO:<address>\r\n");
}
session.recipients.add(rcptTo);
return Mono.just("250 OK\r\n");
}
case "DATA": {
if (!session.helo) {
return Mono.just("503 Hello required first\r\n");
}
if (session.mailFrom == null) {
return Mono.just("503 MAIL required first\r\n");
}
if (session.recipients.isEmpty()) {
return Mono.just("503 RCPT required first\r\n");
}
session.dataReady = true;
return Mono.just("354 Start mail input; end with <CRLF>.<CRLF>\r\n");
}
case "QUIT": {
session.quit = true;
return Mono.just("221 " + smtpHostname + " Bye\r\n");
}
case "RSET": {
session.reset();
return Mono.just("250 OK\r\n");
}
case "NOOP":
return Mono.just("250 OK\r\n");
case "VRFY":
return Mono.just("252 Cannot VRFY user but will accept message\r\n");
case "EXPN":
return Mono.just("252 Cannot EXPN user but will accept message\r\n");
default: {
// Если в режиме DATA, накапливаем тело
if (session.dataReady) {
if (line.equals(".")) {
session.dataReady = false;
String body = session.dataBody.toString();
for (String recipient : session.recipients) {
try {
processIncomingMail(session.mailFrom, recipient, body);
} catch (Exception e) {
log.error("SMTP: ошибка обработки письма для {}: {}", recipient, e.getMessage());
}
}
session.reset();
session.dataBody.setLength(0);
return Mono.just("250 OK: message accepted\r\n");
} else {
if (line.startsWith(".")) {
session.dataBody.append(line.substring(1));
} else {
session.dataBody.append(line);
}
session.dataBody.append("\n");
return Mono.empty();
}
} else {
return Mono.just("502 Command not implemented\r\n");
}
}
}
})
.filter(s -> !s.isEmpty())
.transform(outbound::sendString)
.then();
}
private String buildEhloResponse(String hostname) {
StringBuilder sb = new StringBuilder();
sb.append("250-").append(smtpHostname).append(" Hello ").append(hostname).append("\r\n");
for (Map.Entry<String, String> cap : capabilities.entrySet()) {
if (cap.getValue() != null && !cap.getValue().isEmpty()) {
sb.append("250-").append(cap.getKey()).append(" ").append(cap.getValue()).append("\r\n");
} else {
sb.append("250-").append(cap.getKey()).append("\r\n");
}
}
sb.append("250 AUTH LOGIN PLAIN\r\n");
return sb.toString();
}
private void processIncomingMail(String from, String recipient, String body) {
try {
Properties props = new Properties();
Session mailSession = Session.getDefaultInstance(props);
ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
MimeMessage mimeMessage = new MimeMessage(mailSession, bais);
String senderEmail = from;
String senderName = null;
InternetAddress[] fromAddresses = (InternetAddress[]) mimeMessage.getFrom();
if (fromAddresses != null && fromAddresses.length > 0) {
InternetAddress fromAddr = fromAddresses[0];
senderEmail = fromAddr.getAddress();
senderName = fromAddr.getPersonal();
}
String subject = mimeMessage.getSubject();
if (subject == null) subject = "";
Object content = mimeMessage.getContent();
String messageBody;
boolean isHtml = false;
if (content instanceof String) {
messageBody = (String) content;
} else if (content instanceof jakarta.mail.internet.MimeMultipart) {
messageBody = extractTextFromMultipart((jakarta.mail.internet.MimeMultipart) content);
isHtml = messageBody != null && (messageBody.contains("<html") || messageBody.contains("<HTML"));
} else {
messageBody = content != null ? content.toString() : "";
}
if (messageBody == null) messageBody = "";
boolean dkimPassed = dkimService.verifyMessage(mimeMessage);
String dkimDomain = null;
if (dkimPassed) {
String[] dkimHeaders = mimeMessage.getHeader("DKIM-Signature");
if (dkimHeaders != null && dkimHeaders.length > 0 && !dkimHeaders[0].isEmpty()) {
dkimDomain = extractDkimDomain(dkimHeaders[0]);
}
}
SpfService.SpfResult spfResult = spfService.checkSpf(senderEmail, "127.0.0.1");
boolean spfPassed = spfResult == SpfService.SpfResult.PASS;
log.info("SPF проверка для {}: {} ({})", senderEmail, spfResult, spfResult.getDescription());
DmarcService.DmarcResult dmarcResult = dmarcService.checkDmarc(
senderEmail, "127.0.0.1", spfPassed, dkimPassed, dkimDomain);
log.info("DMARC проверка для {}: {} ({})", senderEmail, dmarcResult, dmarcResult.getDescription());
boolean isSpam = dmarcService.shouldMarkAsSpam(dmarcResult)
|| spfResult == SpfService.SpfResult.FAIL
|| spfResult == SpfService.SpfResult.SOFTFAIL;
if (isSpam) {
log.info("Письмо от {} помечено как спам (SPF: {}, DKIM: {}, DMARC: {})",
senderEmail, spfResult, dkimPassed, dmarcResult);
}
mailService.receiveMessage(senderEmail, senderName, recipient, subject, messageBody, isHtml, isSpam).block();
log.info("SMTP: письмо сохранено от {} для {} (тема: {})", senderEmail, recipient, subject);
} catch (AddressException e) {
log.error("SMTP: ошибка адреса: {}", e.getMessage());
} catch (MessagingException e) {
log.error("SMTP: ошибка парсинга MIME: {}", e.getMessage());
} catch (IllegalArgumentException e) {
// Получатель не найден в системе — пытаемся доставить через SMTP релей
log.info("SMTP: получатель {} не найден в системе, пытаемся доставить через релей", recipient);
try {
// Формируем MIME-контент для релея
Properties props = new Properties();
Session mailSession = Session.getDefaultInstance(props);
ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
MimeMessage mimeMessage = new MimeMessage(mailSession, bais);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mimeMessage.writeTo(baos);
String mimeContent = baos.toString(StandardCharsets.UTF_8);
smtpRelayService.relayMessage(from, recipient, mimeContent).block();
} catch (Exception relayEx) {
log.error("SMTP: релей не смог доставить письмо для {}: {}", recipient, relayEx.getMessage());
}
} catch (Exception e) {
log.error("SMTP: ошибка обработки письма: {}", e.getMessage(), e);
}
}
private String extractTextFromMultipart(jakarta.mail.internet.MimeMultipart multipart)
throws MessagingException, IOException {
StringBuilder text = new StringBuilder();
for (int i = 0; i < multipart.getCount(); i++) {
var bodyPart = multipart.getBodyPart(i);
String contentType = bodyPart.getContentType();
if (contentType != null && contentType.toLowerCase().contains("text")) {
Object partContent = bodyPart.getContent();
if (partContent instanceof String) {
text.append(partContent);
} else if (partContent instanceof InputStream) {
try (InputStream is = (InputStream) partContent) {
text.append(is.toString());
}
}
}
}
return text.toString();
}
private String extractDkimDomain(String dkimHeader) {
int index = dkimHeader.indexOf("d=");
if (index == -1) return null;
int start = index + 2;
int end = dkimHeader.indexOf(";", start);
if (end == -1) end = dkimHeader.length();
return dkimHeader.substring(start, end).trim();
}
private String extractEmail(String argument) {
int start = argument.indexOf('<');
int end = argument.indexOf('>');
if (start == -1 || end == -1) return null;
return argument.substring(start + 1, end).trim().toLowerCase();
}
private static class SmtpSession {
boolean helo = false;
boolean ehlo = false;
boolean quit = false;
String hostname;
String mailFrom;
List<String> recipients = new ArrayList<>();
boolean dataReady = false;
StringBuilder dataBody = new StringBuilder();
void reset() {
mailFrom = null;
recipients.clear();
dataReady = false;
dataBody.setLength(0);
}
}
}
@@ -0,0 +1,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<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> 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")))
)
);
}
}
@@ -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<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
return authenticationMono.map(Authentication::getName);
}
// --- Users ---
@PostMapping("/register")
public Mono<?> register(@Valid @RequestBody Mono<RegisterRequest> 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<FolderResponse> getFolders(Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> userService.getFolders(user.getId()))
);
}
// --- Messages ---
@GetMapping("/messages")
public Flux<MessageResponse> getMessages(
Mono<Authentication> 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<MessageResponse> searchMessages(
Mono<Authentication> 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<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.getMessage(id, user.getId()))
);
}
@PostMapping("/send")
public Mono<Object> sendMessage(
Mono<Authentication> authenticationMono,
@Valid @RequestBody Mono<SendRequest> 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<Authentication> 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<Authentication> 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<Map<String, String>> bodyMono) {
return bodyMono.flatMap(body ->
mailService.receiveMessage(
body.get("senderEmail"),
body.get("senderName"),
body.get("recipientEmail"),
body.get("subject"),
body.get("body"),
"true".equals(body.get("html"))
)
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message received"))))
.onErrorResume(e ->
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
);
}
// --- Attachments ---
@PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Mono<?> uploadAttachment(
@PathVariable Long messageId,
Mono<Authentication> authenticationMono,
@RequestBody Mono<Flux<FilePart>> 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<AttachmentResponse> getMessageAttachments(
@PathVariable Long messageId,
Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> mailService.getMessageAttachments(messageId, user.getId()))
);
}
@GetMapping("/attachments/{attachmentId}/download")
public Mono<Object> downloadAttachment(
@PathVariable Long attachmentId,
Mono<Authentication> 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<Authentication> 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()))))
);
}
}
@@ -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<BlacklistEntry> getBlacklist(Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
spamFilterService.getBlacklist(getCurrentUserEmail(auth))
);
}
@PostMapping("/blacklist")
public Mono<BlacklistEntry> addToBlacklist(Mono<Authentication> authenticationMono, @RequestBody Mono<Map<String, String>> 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<Void> removeFromBlacklist(Mono<Authentication> authenticationMono, @RequestParam String senderEmail) {
return authenticationMono.flatMap(auth ->
spamFilterService.removeFromBlacklist(getCurrentUserEmail(auth), senderEmail)
);
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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<Attachment, Long> {
Flux<Attachment> findByMessageId(Long messageId);
}
@@ -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<BlacklistEntry, Long> {
Flux<BlacklistEntry> findByUserId(Long userId);
Mono<BlacklistEntry> findByUserIdAndSenderEmail(Long userId, String senderEmail);
@Query("SELECT COUNT(*) > 0 FROM blacklist WHERE user_id = :#{#userId} AND LOWER(sender_email) = LOWER(:#{#senderEmail})")
Mono<Boolean> isBlacklisted(Long userId, String senderEmail);
Mono<Void> deleteByUserIdAndSenderEmail(Long userId, String senderEmail);
}
@@ -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<Contact, Long> {
Flux<Contact> findByUserId(Long userId);
Mono<Contact> findByUserIdAndEmail(Long userId, String email);
}
@@ -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<Folder, Long> {
Flux<Folder> findByUserId(Long userId);
Mono<Folder> findByUserIdAndName(Long userId, String name);
}
@@ -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<Message, Long> {
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC")
Flux<Message> findByFolderId(Long folderId, Pageable pageable);
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId} AND is_read = false")
Mono<Long> countUnreadByFolderId(Long folderId);
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND is_read = false ORDER BY received_at DESC")
Flux<Message> 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<Message> 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<Message> searchWithFilters(Long folderId, String subject, String body,
String sender, String recipient, Pageable pageable);
}
@@ -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<User, Long> {
Mono<User> findByEmail(String email);
Mono<Boolean> existsByEmail(String email);
}
@@ -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<Void> 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);
}
}
@@ -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.<domain>
// 2. Получили публичный ключ
// 3. Проверили подпись
//
// Для локального сервера мы проверяем, что подпись присутствует
// и имеет корректный формат
String version = extractDkimValue(dkimHeader, "v=");
String algorithm = extractDkimValue(dkimHeader, "a=");
String selector = extractDkimValue(dkimHeader, "s=");
String signature = extractDkimValue(dkimHeader, "b=");
if (version == null || !version.equals("1")) {
log.warn("DKIM: неверная версия подписи");
return false;
}
if (algorithm == null || !algorithm.startsWith("rsa-")) {
log.warn("DKIM: неподдерживаемый алгоритм: {}", algorithm);
return false;
}
if (signature == null || signature.isEmpty()) {
log.warn("DKIM: подпись пуста");
return false;
}
// Проверяем, что подпись — валидный Base64
try {
Base64.getDecoder().decode(signature);
} catch (IllegalArgumentException e) {
log.warn("DKIM: подпись не является валидным Base64");
return false;
}
log.info("DKIM: подпись найдена для домена {} (селектор: {})", domain, selector);
// Если письмо подписано нашим собственным ключом, проверяем полностью
if (domain.equals(this.domain) && privateKey != null) {
return verifyLocalSignature(mimeMessage, dkimHeader);
}
// Для внешних доменов проверяем только формат
return true;
} catch (Exception e) {
log.error("DKIM: ошибка верификации", e);
return false;
}
}
/**
* Верифицирует подпись локального домена.
*/
private boolean verifyLocalSignature(MimeMessage mimeMessage, String dkimHeader) {
try {
String from = extractHeader(mimeMessage, "From");
String subject = extractHeader(mimeMessage, "Subject");
String date = extractHeader(mimeMessage, "Date");
String to = extractHeader(mimeMessage, "To");
String messageId = extractHeader(mimeMessage, "Message-ID");
String body = extractBody(mimeMessage);
String canonicalBody = canonicalizeBody(body);
StringBuilder headersToSign = new StringBuilder();
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
String[] headerValues = {from, subject, date, to, messageId};
for (int i = 0; i < headerNames.length; i++) {
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
}
byte[] bodyHash = hashBody(canonicalBody);
String b64BodyHash = extractDkimValue(dkimHeader, "bh=");
String b64Signature = extractDkimValue(dkimHeader, "b=");
// Проверяем хэш тела
if (b64BodyHash != null) {
byte[] claimedBodyHash = Base64.getDecoder().decode(b64BodyHash);
if (!MessageDigest.isEqual(bodyHash, claimedBodyHash)) {
log.warn("DKIM: хэш тела не совпадает");
return false;
}
}
// Проверяем подпись
String headerCanon = headersToSign.toString();
byte[] signingInput = (headerCanon + "bh=" + b64BodyHash + "\rbh=" + b64BodyHash).getBytes();
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
signature.initVerify(publicKey);
signature.update(signingInput);
byte[] signatureBytes = Base64.getDecoder().decode(b64Signature);
boolean valid = signature.verify(signatureBytes);
if (valid) {
log.info("DKIM: подпись локального домена верифицирована");
} else {
log.warn("DKIM: подпись локального домена не верифицирована");
}
return valid;
} catch (Exception e) {
log.error("DKIM: ошибка верификации локальной подписи", e);
return false;
}
}
/**
* Извлекает значение параметра из DKIM-заголовка.
*/
private String extractDkimValue(String dkimHeader, String param) {
int index = dkimHeader.indexOf(param);
if (index == -1) return null;
int start = index + param.length();
int end = dkimHeader.indexOf(";", start);
if (end == -1) end = dkimHeader.length();
return dkimHeader.substring(start, end).trim();
}
/**
* Извлекает заголовок из MIME-сообщения.
*/
private String extractHeader(MimeMessage mimeMessage, String name) throws MessagingException {
String[] headers = mimeMessage.getHeader(name);
if (headers == null || headers.length == 0) return "";
return headers[0];
}
/**
* Извлекает тело MIME-сообщения.
*/
private String extractBody(MimeMessage mimeMessage) throws MessagingException, IOException {
Object content = mimeMessage.getContent();
if (content instanceof String) {
return (String) content;
} else if (content instanceof jakarta.mail.internet.MimeMultipart) {
jakarta.mail.internet.MimeMultipart multipart = (jakarta.mail.internet.MimeMultipart) content;
StringBuilder body = new StringBuilder();
for (int i = 0; i < multipart.getCount(); i++) {
var part = multipart.getBodyPart(i);
String contentType = part.getContentType();
if (contentType != null && contentType.toLowerCase().contains("text")) {
Object partContent = part.getContent();
if (partContent instanceof String) {
body.append(partContent);
}
}
}
return body.toString();
}
return "";
}
/**
* Канонизация тела сообщения (relaxed канонизация).
* Убирает лишние пробелы и переводы строк.
*/
private String canonicalizeBody(String body) {
if (body == null) return "";
// Relaxed: нормализуем CRLF в LF, убираем пробелы в конце строк
return body.replaceAll("\\r\\n", "\n")
.replaceAll("\\r", "\n")
.replaceAll(" +\n", "\n")
.replaceAll("\n+", "\n")
.trim();
}
/**
* Вычисляет SHA-256 хэш тела сообщения.
*/
private byte[] hashBody(String body) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return digest.digest(body.getBytes());
}
/**
* Возвращает публичный ключ в формате DNS TXT-записи.
* Используется для публикации в DNS.
*/
public String getPublicKeyForDns() {
if (publicKeyBase64 == null) return null;
return "v=DKIM1; k=rsa; p=" + publicKeyBase64;
}
/**
* Возвращает домен DKIM.
*/
public String getDomain() {
return domain;
}
/**
* Возвращает селектор DKIM.
*/
public String getSelector() {
return DKIM_SELECTOR;
}
}
@@ -0,0 +1,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.<domain>
String dmarcDomain = "_dmarc." + domain;
Hashtable<String, String> 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;
}
}
@@ -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<MessageResponse> 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<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
String subject, String body, boolean isHtml) {
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false);
}
public Mono<Void> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> getMessage(Long messageId, Long userId) {
return messageRepository.findById(messageId)
.map(this::toResponse);
}
public Mono<Void> markAsRead(Long messageId, Long userId) {
return messageRepository.findById(messageId)
.flatMap(message -> {
message.setRead(true);
return messageRepository.save(message).then();
});
}
public Mono<Void> 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<AttachmentResponse> 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<AttachmentResponse> getMessageAttachments(Long messageId, Long userId) {
return attachmentRepository.findByMessageId(messageId)
.map(this::toAttachmentResponse);
}
public Mono<Void> 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<Void> sendViaSmtp(String from, String to, String subject, String body,
boolean isHtml, String messageId) {
TcpClient client = TcpClient.create()
.host(smtpHostname)
.port(smtpPort)
.option(io.netty.channel.ChannelOption.SO_TIMEOUT, 30000)
.option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
Properties props = new Properties();
Session mailSession = Session.getDefaultInstance(props);
MimeMessage mimeMessage = new MimeMessage(mailSession);
try {
mimeMessage.setFrom(from);
mimeMessage.setRecipient(jakarta.mail.Message.RecipientType.TO, new jakarta.mail.internet.InternetAddress(to));
mimeMessage.setSubject(subject);
mimeMessage.setText(body, isHtml ? "text/html" : "text/plain", "UTF-8");
mimeMessage.setHeader("Message-ID", "<" + messageId + "@" + smtpHostname + ">");
mimeMessage.setSentDate(new java.util.Date());
dkimService.signMessage(mimeMessage);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mimeMessage.writeTo(baos);
String mimeContent = baos.toString(StandardCharsets.UTF_8);
StringBuilder dataBody = new StringBuilder();
String[] lines = mimeContent.split("\\r?\\n");
for (String mimeLine : lines) {
if (mimeLine.startsWith(".")) {
dataBody.append(".").append(mimeLine);
} else {
dataBody.append(mimeLine);
}
dataBody.append("\r\n");
}
dataBody.append(".\r\n");
// SMTP — последовательный протокол: команда -> ответ -> команда -> ответ
// connection.inbound().receive() можно подписать только ОДИН раз,
// поэтому используем Sinks.Many с directBestEffort для шаринга ответов
return client.connect()
.flatMap(connection -> {
// Sinks.Many — один поток чтения, много подписчиков через multicast
Sinks.Many<String> responseSink = Sinks.many().replay().limit(1);
// Подписываемся на inbound ОДИН раз и пишем все ответы в sink
connection.inbound()
.receive()
.asString(StandardCharsets.UTF_8)
.doOnNext(line -> {
String trimmed = line.trim();
log.debug("SMTP клиент ответ: {}", trimmed);
responseSink.tryEmitNext(trimmed);
})
.subscribe();
// Собираем все команды в список для последовательной отправки
List<String> commands = new ArrayList<>();
commands.add("EHLO " + smtpHostname + "\r\n");
commands.add("MAIL FROM:<" + from + ">\r\n");
commands.add("RCPT TO:<" + to + ">\r\n");
commands.add("DATA\r\n");
commands.add(dataBody.toString());
commands.add("QUIT\r\n");
// Ожидаемые коды ответов для каждой команды (включая banner)
List<String> expectedCodes = new ArrayList<>();
expectedCodes.add("220"); // banner
expectedCodes.add("250"); // EHLO
expectedCodes.add("250"); // MAIL FROM
expectedCodes.add("250"); // RCPT TO
expectedCodes.add("354"); // DATA
expectedCodes.add("250"); // тело DATA (завершение)
expectedCodes.add("221"); // QUIT
// Последовательно отправляем команды и ждём ответы
Mono<Void> send = Mono.empty();
for (int i = 0; i < commands.size(); i++) {
final String cmd = commands.get(i);
final String expectedCode = expectedCodes.get(i);
send = send.then(sendAndAwait(connection, cmd, responseSink, expectedCode));
}
return send.then(Mono.fromRunnable(() -> connection.dispose()));
});
} catch (MessagingException | IOException e) {
return Mono.error(new RuntimeException("Failed to prepare SMTP message", e));
}
}
/**
* Отправляет SMTP команду и ждёт ответ с указанным кодом.
* Для многострочных ответов (EHLO) читает до строки без '-' в конце кода.
*/
private Mono<Void> sendAndAwait(Connection connection, String command,
Sinks.Many<String> responseSink,
String expectedCode) {
log.debug("SMTP клиент команда: {}", command.trim());
// Отправляем команду
Mono<Void> send = connection.outbound()
.sendString(Mono.just(command))
.then();
// Ждём ответ из sink — каждый вызов получает свой Flux подписчика
Mono<Void> readResponse = responseSink.asFlux()
.takeWhile(line -> {
if (expectedCode != null && line.startsWith(expectedCode + "-")) {
return true;
}
return false;
})
.doOnNext(line -> {
if (expectedCode != null && !line.startsWith(expectedCode)) {
log.warn("SMTP неожиданный ответ (ожидалось {}): {}", expectedCode, line);
}
})
.then()
.timeout(java.time.Duration.ofSeconds(30))
.onErrorResume(e -> Mono.error(new RuntimeException("SMTP response timeout", e)));
return send.then(readResponse);
}
}
@@ -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<Void> 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<String> 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<? extends javax.naming.directory.SearchResult> 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<? extends javax.naming.directory.Attribute> 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<Void> 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<String> 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<Void> 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<Void> authenticate(Connection connection, Sinks.Many<String> responseSink) {
log.info("SMTP релей: аутентификация как {}", username);
// AUTH LOGIN
Mono<Void> 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<Void> sendAndAwait(Connection connection, String command,
Sinks.Many<String> responseSink,
String expectedCode) {
log.debug("SMTP релей команда: {}", command.trim());
Mono<Void> send = connection.outbound()
.sendString(Mono.just(command))
.then();
Mono<Void> readResponse = responseSink.asFlux()
.takeWhile(line -> {
if (expectedCode != null && line.startsWith(expectedCode + "-")) {
return true;
}
return false;
})
.doOnNext(line -> {
if (expectedCode != null && !line.startsWith(expectedCode)) {
log.warn("SMTP релей неожиданный ответ (ожидалось {}): {}", expectedCode, line);
}
})
.then()
.timeout(java.time.Duration.ofSeconds(30))
.onErrorResume(e -> Mono.error(new RuntimeException("SMTP relay response timeout", e)));
return send.then(readResponse);
}
}
@@ -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<Boolean> isBlacklisted(Long userId, String senderEmail) {
return blacklistRepository.isBlacklisted(userId, senderEmail);
}
public Mono<BlacklistEntry> 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<Void> 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<BlacklistEntry> getBlacklist(String userEmail) {
return userRepository.findByEmail(userEmail)
.switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + userEmail)))
.flatMapMany(user -> blacklistRepository.findByUserId(user.getId()));
}
}
@@ -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<String, String> 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<String, String> 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<String, String> 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;
}
}
@@ -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<User> 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<User> 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<FolderResponse> 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;
});
}
}
@@ -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;
}
}
}
@@ -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
@@ -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
@@ -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);
+46
View File
@@ -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:
+17
View File
@@ -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
+44
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+59
View File
@@ -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.
+95
View File
@@ -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"
}
}
}
}
}
+32
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+13
View File
@@ -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]))
]
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+39
View File
@@ -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',
},
];
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
imports: [RouterOutlet],
selector: 'app-root',
template: `<router-outlet />`,
styles: ``
})
export class App {}
@@ -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: `
<div class="glass-panel p-6">
<h2 class="text-lg font-orbitron font-bold text-synth-cyan mb-6 flex items-center gap-2">
<span class="text-synth-pink">✉️</span> COMPOSE MESSAGE
</h2>
@if (success) {
<div class="mb-4 p-3 rounded-lg bg-synth-cyan/20 border border-synth-cyan/40 text-synth-cyan text-sm font-mono">
Message sent successfully!
</div>
}
<form (ngSubmit)="onSend()" class="space-y-4">
<div>
<label for="to" class="block text-xs font-mono text-synth-cyan mb-1">TO</label>
<input id="to" type="email" [(ngModel)]="to" name="to" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="recipient@example.com">
</div>
<div>
<label for="subject" class="block text-xs font-mono text-synth-cyan mb-1">SUBJECT</label>
<input id="subject" type="text" [(ngModel)]="subject" name="subject" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="Enter subject...">
</div>
<div>
<label for="body" class="block text-xs font-mono text-synth-cyan mb-1">BODY</label>
<textarea id="body" [(ngModel)]="body" name="body" rows="10" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all resize-none"
placeholder="Write your message..."></textarea>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" [disabled]="sending"
class="cyber-btn px-8 py-3 rounded-xl text-sm font-bold">
{{ sending ? 'SENDING...' : 'SEND' }}
</button>
<a routerLink="/inbox" class="px-6 py-3 rounded-xl text-xs font-mono text-slate-400 border border-slate-600 hover:text-slate-200 hover:border-slate-400 transition-all text-center">
CANCEL
</a>
</div>
</form>
</div>
`,
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;
}
});
}
}
@@ -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: `
<div class="space-y-4">
<!-- Поиск -->
<div class="glass-panel p-4">
<div class="flex gap-3">
<input [(ngModel)]="searchQuery" (keyup.enter)="loadMessages()"
class="flex-1 bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-2 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="🔍 Search messages...">
<button (click)="loadMessages()" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
SEARCH
</button>
</div>
</div>
<!-- Список писем -->
<div class="glass-panel p-4">
<h2 class="text-lg font-orbitron font-bold text-synth-cyan mb-4 flex items-center gap-2">
<span class="text-synth-pink">📬</span> {{ folderName() }}
<span class="text-sm font-mono text-slate-400 ml-auto">{{ messages()?.length || 0 }} messages</span>
</h2>
@if (!messages() || messages().length === 0) {
<div class="text-center py-12 text-slate-400 font-mono">
<p class="text-4xl mb-4">📭</p>
<p>No messages yet</p>
</div>
} @else {
<div class="space-y-2">
@for (msg of messages() || []; track msg.id) {
<a routerLink="/message/{{ msg.id }}"
class="block p-4 rounded-xl border border-synth-cyan/10 hover:border-synth-cyan/40 hover:bg-synth-cyan/5 transition-all cursor-pointer group">
<div class="flex items-start gap-3">
<!-- Индикатор прочитанности -->
<div class="mt-1">
@if (!msg.read) {
<span class="w-3 h-3 rounded-full bg-synth-pink inline-block shadow-[0_0_8px_rgba(255,0,127,0.8)]"></span>
} @else {
<span class="w-3 h-3 rounded-full border border-slate-500 inline-block"></span>
}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-sm text-slate-200 font-rajdhani truncate">
{{ msg.senderName || msg.sender }}
</span>
@if (msg.spam) {
<span class="px-2 py-0.5 text-xs rounded-full bg-synth-yellow/20 text-synth-yellow font-mono">
SPAM
</span>
}
@if (msg.flagged) {
<span class="text-synth-yellow text-xs">★</span>
}
</div>
<div class="text-sm text-synth-cyan font-rajdhani truncate group-hover:text-synth-pink transition-colors">
{{ msg.subject || '(No subject)' }}
</div>
<div class="text-xs text-slate-400 font-mono truncate">
{{ msg.body || '' | slice:0:100 }}{{ (msg.body || '').length > 100 ? '...' : '' }}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-xs text-slate-400 font-mono">
{{ msg.sentAt | date:'MMM d, HH:mm' }}
</div>
@if (msg.hasAttachments) {
<span class="text-xs text-synth-purple font-mono">📎 {{ msg.attachmentCount }}</span>
}
</div>
</div>
</a>
}
</div>
}
</div>
</div>
`,
styles: ``
})
export default class InboxComponent {
private route = inject(ActivatedRoute);
private mailService = inject(MailService);
messages = signal<Message[]>([]);
folderName = signal<string>('INBOX');
searchQuery = '';
folderId = signal<number>(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')
});
}
}
}
@@ -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: `
<div class="min-h-screen relative" [class.light-theme]="isLightTheme()">
<!-- Synthwave фон -->
<div class="synth-bg" aria-hidden="true">
<div class="synth-sun"></div>
<div class="synth-grid">
<div class="synth-grid-plane"></div>
</div>
</div>
<!-- Контент -->
<div class="relative z-10 flex flex-col min-h-screen">
<!-- Шапка -->
<header class="glass-panel rounded-none border-t-0 border-x-0 p-4 sticky top-0 z-40 backdrop-blur-xl">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-xl font-orbitron shadow-[0_0_15px_rgba(255,0,127,0.8)]">
M
</div>
<div>
<h1 class="text-2xl font-orbitron font-extrabold tracking-wider neon-text-primary">
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
</h1>
<p class="text-xs text-synth-cyan/70 font-mono tracking-widest uppercase">Synthwave Edition</p>
</div>
</div>
<div class="flex items-center gap-4">
<!-- Переключатель темы -->
<button (click)="toggleTheme()" class="cyber-btn px-3 py-2 rounded-lg text-xs font-bold" [title]="isLightTheme() ? 'Переключить на тёмную тему' : 'Переключить на светлую тему'">
{{ isLightTheme() ? '🌙 Dark' : '☀️ Light' }}
</button>
<span class="text-sm font-mono text-slate-400">{{ email }}</span>
<button (click)="onLogout()" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold">
<i class="mr-1">⏻</i> Logout
</button>
</div>
</div>
</header>
<!-- Основная область -->
<div class="flex flex-1 max-w-7xl mx-auto w-full p-4 gap-4">
<!-- Боковая панель с папками -->
<aside class="w-64 flex-shrink-0">
<div class="glass-panel p-4">
<h2 class="text-sm font-orbitron font-bold text-synth-cyan mb-3 flex items-center gap-2">
<span class="text-synth-pink">📁</span> FOLDERS
</h2>
<nav class="space-y-1">
@for (folder of folders(); track folder.id) {
<a routerLink="/inbox" [queryParams]="{folderId: folder.id}"
routerLinkActive="bg-synth-pink/20 text-synth-pink border-synth-pink"
[queryParamsHandling]=" 'merge'"
class="block px-3 py-2 rounded-lg text-sm font-rajdhani text-slate-300 hover:bg-synth-cyan/10 hover:text-synth-cyan border border-transparent transition-all">
{{ folder.name }}
@if (folder.unreadCount > 0) {
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-synth-pink/30 text-synth-pink font-mono">
{{ folder.unreadCount }}
</span>
}
</a>
}
</nav>
</div>
<!-- Кнопка написать -->
<div class="glass-panel p-4 mt-4">
<a routerLink="/compose" class="cyber-btn w-full py-3 rounded-xl text-xs font-bold block text-center">
✉️ NEW MESSAGE
</a>
</div>
</aside>
<!-- Контент маршрутов -->
<main class="flex-1 min-w-0">
<router-outlet />
</main>
</div>
</div>
</div>
`,
styles: ``
})
export default class LayoutComponent {
authService = inject(AuthService);
mailService = inject(MailService);
email = this.authService.getEmail() || '';
folders = signal<Folder[]>([]);
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';
}
}
@@ -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: `
<div class="min-h-screen relative flex items-center justify-center">
<!-- Synthwave фон -->
<div class="synth-bg" aria-hidden="true">
<div class="synth-sun"></div>
<div class="synth-grid">
<div class="synth-grid-plane"></div>
</div>
</div>
<!-- Форма логина -->
<div class="relative z-10 w-full max-w-md px-4">
<div class="glass-panel p-8">
<div class="text-center mb-8">
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
M
</div>
<h1 class="text-3xl font-orbitron font-extrabold tracking-wider neon-text-primary mb-2">
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
</h1>
<p class="text-sm text-slate-400 font-mono">Sign in to your mailbox</p>
</div>
@if (error) {
<div class="mb-4 p-3 rounded-lg bg-synth-pink/20 border border-synth-pink/40 text-synth-pink text-sm font-mono">
{{ error }}
</div>
}
<form (ngSubmit)="onLogin()" class="space-y-4">
<div>
<label for="email" class="block text-xs font-mono text-synth-cyan mb-1">EMAIL</label>
<input id="email" type="email" [(ngModel)]="email" name="email" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="user@example.com">
</div>
<div>
<label for="password" class="block text-xs font-mono text-synth-cyan mb-1">PASSWORD</label>
<input id="password" type="password" [(ngModel)]="password" name="password" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="••••••••">
</div>
<button type="submit" [disabled]="loading"
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
{{ loading ? 'CONNECTING...' : 'LOGIN' }}
</button>
</form>
<p class="text-center text-sm text-slate-400 mt-6">
No account?
<a routerLink="/register" class="text-synth-cyan hover:text-synth-pink font-mono transition-colors">
Register here
</a>
</p>
</div>
</div>
</div>
`,
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;
}
});
}
}
@@ -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) {
<div class="glass-panel p-6">
<!-- Заголовок письма -->
<div class="mb-6 pb-4 border-b border-synth-cyan/20">
<h1 class="text-xl font-orbitron font-bold neon-text-secondary mb-3">
{{ message.subject || '(No subject)' }}
</h1>
<div class="flex items-center gap-4 text-sm font-mono">
<div class="flex items-center gap-2">
<span class="text-synth-cyan">FROM:</span>
<span class="text-slate-200">{{ message.senderName || message.sender }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-synth-cyan">DATE:</span>
<span class="text-slate-200">{{ message.sentAt | date:'MMM d, y, HH:mm' }}</span>
</div>
</div>
@if (message.spam) {
<div class="mt-3 px-3 py-1.5 inline-block rounded-full bg-synth-yellow/20 border border-synth-yellow/40 text-synth-yellow text-xs font-mono">
⚠️ MARKED AS SPAM
</div>
}
</div>
<!-- Тело письма -->
<div class="prose prose-invert max-w-none mb-6">
<p class="text-slate-300 font-rajdhani text-lg whitespace-pre-wrap leading-relaxed">
{{ message.body }}
</p>
</div>
<!-- Вложения -->
@if (message.hasAttachments) {
<div class="mb-6 p-4 rounded-xl bg-synth-darker/60 border border-synth-purple/30">
<h3 class="text-sm font-orbitron font-bold text-synth-purple mb-3">
📎 ATTACHMENTS ({{ message.attachmentCount }})
</h3>
<p class="text-xs text-slate-400 font-mono">
Attachments are available via the API
</p>
</div>
}
<!-- Действия -->
<div class="flex gap-3 pt-4 border-t border-synth-cyan/20">
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
← BACK TO INBOX
</a>
<button (click)="onDelete()" class="px-6 py-2 rounded-xl text-xs font-mono text-synth-pink border border-synth-pink/40 hover:bg-synth-pink/20 transition-all">
🗑️ DELETE
</button>
</div>
</div>
} @else {
<div class="glass-panel p-6 text-center py-12">
<p class="text-4xl mb-4">📭</p>
<p class="text-slate-400 font-mono">Message not found</p>
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold mt-4 inline-block">
← BACK TO INBOX
</a>
</div>
}
`,
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')
});
}
}
}
@@ -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: `
<div class="min-h-screen relative flex items-center justify-center">
<!-- Synthwave фон -->
<div class="synth-bg" aria-hidden="true">
<div class="synth-sun"></div>
<div class="synth-grid">
<div class="synth-grid-plane"></div>
</div>
</div>
<!-- Форма регистрации -->
<div class="relative z-10 w-full max-w-md px-4">
<div class="glass-panel p-8">
<div class="text-center mb-8">
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
M
</div>
<h1 class="text-3xl font-orbitron font-extrabold tracking-wider neon-text-primary mb-2">
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
</h1>
<p class="text-sm text-slate-400 font-mono">Create your mailbox</p>
</div>
@if (error) {
<div class="mb-4 p-3 rounded-lg bg-synth-pink/20 border border-synth-pink/40 text-synth-pink text-sm font-mono">
{{ error }}
</div>
}
@if (success) {
<div class="mb-4 p-3 rounded-lg bg-synth-cyan/20 border border-synth-cyan/40 text-synth-cyan text-sm font-mono">
Account created! You can now login.
</div>
}
<form (ngSubmit)="onRegister()" class="space-y-4">
<div>
<label for="displayName" class="block text-xs font-mono text-synth-cyan mb-1">DISPLAY NAME</label>
<input id="displayName" type="text" [(ngModel)]="displayName" name="displayName" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="John Doe">
</div>
<div>
<label for="email" class="block text-xs font-mono text-synth-cyan mb-1">EMAIL</label>
<input id="email" type="email" [(ngModel)]="email" name="email" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="user@example.com">
</div>
<div>
<label for="password" class="block text-xs font-mono text-synth-cyan mb-1">PASSWORD</label>
<input id="password" type="password" [(ngModel)]="password" name="password" required
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
placeholder="••••••••">
</div>
<button type="submit" [disabled]="loading"
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
{{ loading ? 'CREATING...' : 'REGISTER' }}
</button>
</form>
<p class="text-center text-sm text-slate-400 mt-6">
Already have an account?
<a routerLink="/login" class="text-synth-cyan hover:text-synth-pink font-mono transition-colors">
Login here
</a>
</p>
</div>
</div>
</div>
`,
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;
}
});
}
}
@@ -0,0 +1,16 @@
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
const token = localStorage.getItem('token');
if (token) {
const cloned = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
return next(cloned);
}
return next(req);
};
+64
View File
@@ -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<T> {
content: T[];
number: number;
size: number;
totalElements: number;
totalPages: number;
}
+57
View File
@@ -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<string | null>(
localStorage.getItem('email')
);
currentUser$ = this.currentUserSubject.asObservable();
private tokenSubject = new BehaviorSubject<string | null>(
localStorage.getItem('token')
);
token$ = this.tokenSubject.asObservable();
login({ email, password }: LoginRequest): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${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<void> {
return this.http.post<void>(`${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');
}
}
+96
View File
@@ -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<Folder[]> {
return this.http.get<Folder[]>(`${this.apiUrl}/folders`);
}
// Письма
getMessages(folderId: number, page: number = 0, size: number = 20): Observable<Message[]> {
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages`, {
params: { folderId, page, size }
}).pipe(map(response => response.content));
}
getMessage(id: number): Observable<Message> {
return this.http.get<Message>(`${this.apiUrl}/messages/${id}`);
}
searchMessages(
folderId: number,
keyword?: string,
subject?: string,
body?: string,
sender?: string,
recipient?: string
): Observable<Message[]> {
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<PageResponse<Message>>(`${this.apiUrl}/messages/search`, { params })
.pipe(map(response => response.content));
}
// Отправка
send(sendRequest: SendRequest): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/send`, sendRequest);
}
// Статус
markAsRead(id: number): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/messages/${id}/read`, null);
}
deleteMessage(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/messages/${id}`);
}
// Вложения
getAttachments(messageId: number): Observable<Attachment[]> {
return this.http.get<Attachment[]>(`${this.apiUrl}/messages/${messageId}/attachments`);
}
uploadAttachment(messageId: number, file: File): Observable<void> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<void>(`${this.apiUrl}/messages/${messageId}/attachments`, formData);
}
downloadAttachment(attachmentId: number): Observable<Blob> {
return this.http.get<Blob>(`${this.apiUrl}/attachments/${attachmentId}/download`, {
responseType: 'blob' as 'json'
});
}
deleteAttachment(attachmentId: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/attachments/${attachmentId}`);
}
// Чёрный список
getBlacklist(): Observable<string[]> {
return this.http.get<string[]>(`${this.apiUrl}/spam/blacklist`);
}
addToBlacklist(entry: BlacklistEntry): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/spam/blacklist`, entry);
}
removeFromBlacklist(senderEmail: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/spam/blacklist`, {
params: { senderEmail }
});
}
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MailServer — Synthwave Edition</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="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" rel="stylesheet">
</head>
<body class="bg-synth-dark">
<app-root></app-root>
</body>
</html>
+6
View File
@@ -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));
+254
View File
@@ -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;
}
+14
View File
@@ -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"
]
}
+28
View File
@@ -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"
}
]
}
+14
View File
@@ -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"
]
}
+9
View File
@@ -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
Vendored
+248
View File
@@ -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" "$@"
Vendored
+82
View File
@@ -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%
+5
View File
@@ -0,0 +1,5 @@
include 'backend'
include 'frontend'
project(':backend').projectDir = file('backend')
project(':frontend').projectDir = file('frontend')