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

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-11-05 01:07:29 +01:00
co-authored by Junie
commit 0339413d6e
78 changed files with 6808 additions and 0 deletions
@@ -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);
}
}
}