Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user