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
+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);