package com.mailserver.config; import com.mailserver.service.DkimService; import com.mailserver.service.DmarcService; import com.mailserver.service.MailService; import com.mailserver.service.SmtpRelayService; import com.mailserver.service.SpfService; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.mail.MessagingException; import jakarta.mail.Session; import jakarta.mail.internet.AddressException; import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; import reactor.netty.DisposableServer; import reactor.netty.tcp.TcpServer; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.*; /** * Встроенный SMTP-сервер на Spring WebFlux (TcpServer). * Поддерживает команды: HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT, RSET, NOOP. * Обрабатывает входящие письма и сохраняет их через MailService. */ @Configuration public class SmtpServerConfig { private static final Logger log = LoggerFactory.getLogger(SmtpServerConfig.class); private final MailService mailService; private final SmtpRelayService smtpRelayService; private final DkimService dkimService; private final SpfService spfService; private final DmarcService dmarcService; @Value("${mailserver.smtp.port:3025}") private int smtpPort; @Value("${mailserver.smtp.hostname:localhost}") private String smtpHostname; @Value("${mailserver.smtp.enabled:true}") private boolean enabled; private DisposableServer disposableServer; private final Map capabilities = new HashMap<>(); public SmtpServerConfig(MailService mailService, SmtpRelayService smtpRelayService, DkimService dkimService, SpfService spfService, DmarcService dmarcService) { this.mailService = mailService; this.smtpRelayService = smtpRelayService; this.dkimService = dkimService; this.spfService = spfService; this.dmarcService = dmarcService; capabilities.put("SIZE", "10485760"); capabilities.put("8BITMIME", ""); capabilities.put("ENHANCEDSTATUSCODES", ""); capabilities.put("PIPELINING", ""); } @PostConstruct public void start() { if (!enabled) { log.info("SMTP-сервер отключён (mailserver.smtp.enabled=false)"); return; } try { disposableServer = TcpServer.create() .port(smtpPort) .handle((inbound, outbound) -> outbound.sendString(Mono.just("220 " + smtpHostname + " ESMTP ready\r\n")) .then(handleSmtpSession(inbound, outbound)) ) .bind() .block(java.time.Duration.ofSeconds(10)); log.info("SMTP-сервер запущен на порту {} (хост: {})", smtpPort, smtpHostname); } catch (Exception e) { log.error("Не удалось запустить SMTP-сервер на порту {}: {}", smtpPort, e.getMessage()); // Не бросаем исключение, чтобы приложение могло стартовать без SMTP } } @PreDestroy public void stop() { if (disposableServer != null) { try { disposableServer.dispose(); } catch (Exception e) { log.warn("Ошибка при остановке SMTP-сервера: {}", e.getMessage()); } log.info("SMTP-сервер остановлен"); } } private Mono handleSmtpSession(reactor.netty.NettyInbound inbound, reactor.netty.NettyOutbound outbound) { SmtpSession session = new SmtpSession(); return inbound.receive() .asString(StandardCharsets.UTF_8) .doOnNext(line -> log.debug("SMTP: команда: {}", line.trim())) .concatMap(line -> { line = line.trim(); if (line.isEmpty()) { return Mono.empty(); } String[] parts = line.split("\\s+", 2); String command = parts[0].toUpperCase(); String argument = parts.length > 1 ? parts[1] : ""; switch (command) { case "HELO": { session.helo = true; session.ehlo = false; session.hostname = argument; return Mono.just("250 " + smtpHostname + " Hello " + argument + "\r\n"); } case "EHLO": { session.helo = true; session.ehlo = true; session.hostname = argument; return Mono.just(buildEhloResponse(argument)); } case "MAIL": { if (!session.helo) { return Mono.just("503 Hello required first\r\n"); } String mailFrom = extractEmail(argument); if (mailFrom == null) { return Mono.just("501 Syntax: MAIL FROM:
\r\n"); } session.mailFrom = mailFrom; return Mono.just("250 OK\r\n"); } case "RCPT": { if (!session.helo) { return Mono.just("503 Hello required first\r\n"); } if (session.mailFrom == null) { return Mono.just("503 MAIL required first\r\n"); } String rcptTo = extractEmail(argument); if (rcptTo == null) { return Mono.just("501 Syntax: RCPT TO:
\r\n"); } session.recipients.add(rcptTo); return Mono.just("250 OK\r\n"); } case "DATA": { if (!session.helo) { return Mono.just("503 Hello required first\r\n"); } if (session.mailFrom == null) { return Mono.just("503 MAIL required first\r\n"); } if (session.recipients.isEmpty()) { return Mono.just("503 RCPT required first\r\n"); } session.dataReady = true; return Mono.just("354 Start mail input; end with .\r\n"); } case "QUIT": { session.quit = true; return Mono.just("221 " + smtpHostname + " Bye\r\n"); } case "RSET": { session.reset(); return Mono.just("250 OK\r\n"); } case "NOOP": return Mono.just("250 OK\r\n"); case "VRFY": return Mono.just("252 Cannot VRFY user but will accept message\r\n"); case "EXPN": return Mono.just("252 Cannot EXPN user but will accept message\r\n"); default: { // Если в режиме DATA, накапливаем тело if (session.dataReady) { if (line.equals(".")) { session.dataReady = false; String body = session.dataBody.toString(); for (String recipient : session.recipients) { try { processIncomingMail(session.mailFrom, recipient, body); } catch (Exception e) { log.error("SMTP: ошибка обработки письма для {}: {}", recipient, e.getMessage()); } } session.reset(); session.dataBody.setLength(0); return Mono.just("250 OK: message accepted\r\n"); } else { if (line.startsWith(".")) { session.dataBody.append(line.substring(1)); } else { session.dataBody.append(line); } session.dataBody.append("\n"); return Mono.empty(); } } else { return Mono.just("502 Command not implemented\r\n"); } } } }) .filter(s -> !s.isEmpty()) .transform(outbound::sendString) .then(); } private String buildEhloResponse(String hostname) { StringBuilder sb = new StringBuilder(); sb.append("250-").append(smtpHostname).append(" Hello ").append(hostname).append("\r\n"); for (Map.Entry cap : capabilities.entrySet()) { if (cap.getValue() != null && !cap.getValue().isEmpty()) { sb.append("250-").append(cap.getKey()).append(" ").append(cap.getValue()).append("\r\n"); } else { sb.append("250-").append(cap.getKey()).append("\r\n"); } } sb.append("250 AUTH LOGIN PLAIN\r\n"); return sb.toString(); } private void processIncomingMail(String from, String recipient, String body) { try { Properties props = new Properties(); Session mailSession = Session.getDefaultInstance(props); ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); MimeMessage mimeMessage = new MimeMessage(mailSession, bais); String senderEmail = from; String senderName = null; InternetAddress[] fromAddresses = (InternetAddress[]) mimeMessage.getFrom(); if (fromAddresses != null && fromAddresses.length > 0) { InternetAddress fromAddr = fromAddresses[0]; senderEmail = fromAddr.getAddress(); senderName = fromAddr.getPersonal(); } String subject = mimeMessage.getSubject(); if (subject == null) subject = ""; Object content = mimeMessage.getContent(); String messageBody; boolean isHtml = false; if (content instanceof String) { messageBody = (String) content; } else if (content instanceof jakarta.mail.internet.MimeMultipart) { messageBody = extractTextFromMultipart((jakarta.mail.internet.MimeMultipart) content); isHtml = messageBody != null && (messageBody.contains(" 0 && !dkimHeaders[0].isEmpty()) { dkimDomain = extractDkimDomain(dkimHeaders[0]); } } SpfService.SpfResult spfResult = spfService.checkSpf(senderEmail, "127.0.0.1"); boolean spfPassed = spfResult == SpfService.SpfResult.PASS; log.info("SPF проверка для {}: {} ({})", senderEmail, spfResult, spfResult.getDescription()); DmarcService.DmarcResult dmarcResult = dmarcService.checkDmarc( senderEmail, "127.0.0.1", spfPassed, dkimPassed, dkimDomain); log.info("DMARC проверка для {}: {} ({})", senderEmail, dmarcResult, dmarcResult.getDescription()); boolean isSpam = dmarcService.shouldMarkAsSpam(dmarcResult) || spfResult == SpfService.SpfResult.FAIL || spfResult == SpfService.SpfResult.SOFTFAIL; if (isSpam) { log.info("Письмо от {} помечено как спам (SPF: {}, DKIM: {}, DMARC: {})", senderEmail, spfResult, dkimPassed, dmarcResult); } mailService.receiveMessage(senderEmail, senderName, recipient, subject, messageBody, isHtml, isSpam).block(); log.info("SMTP: письмо сохранено от {} для {} (тема: {})", senderEmail, recipient, subject); } catch (AddressException e) { log.error("SMTP: ошибка адреса: {}", e.getMessage()); } catch (MessagingException e) { log.error("SMTP: ошибка парсинга MIME: {}", e.getMessage()); } catch (IllegalArgumentException e) { // Получатель не найден в системе — пытаемся доставить через SMTP релей log.info("SMTP: получатель {} не найден в системе, пытаемся доставить через релей", recipient); try { // Формируем MIME-контент для релея Properties props = new Properties(); Session mailSession = Session.getDefaultInstance(props); ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); MimeMessage mimeMessage = new MimeMessage(mailSession, bais); ByteArrayOutputStream baos = new ByteArrayOutputStream(); mimeMessage.writeTo(baos); String mimeContent = baos.toString(StandardCharsets.UTF_8); smtpRelayService.relayMessage(from, recipient, mimeContent).block(); } catch (Exception relayEx) { log.error("SMTP: релей не смог доставить письмо для {}: {}", recipient, relayEx.getMessage()); } } catch (Exception e) { log.error("SMTP: ошибка обработки письма: {}", e.getMessage(), e); } } private String extractTextFromMultipart(jakarta.mail.internet.MimeMultipart multipart) throws MessagingException, IOException { StringBuilder text = new StringBuilder(); for (int i = 0; i < multipart.getCount(); i++) { var bodyPart = multipart.getBodyPart(i); String contentType = bodyPart.getContentType(); if (contentType != null && contentType.toLowerCase().contains("text")) { Object partContent = bodyPart.getContent(); if (partContent instanceof String) { text.append(partContent); } else if (partContent instanceof InputStream) { try (InputStream is = (InputStream) partContent) { text.append(is.toString()); } } } } return text.toString(); } private String extractDkimDomain(String dkimHeader) { int index = dkimHeader.indexOf("d="); if (index == -1) return null; int start = index + 2; int end = dkimHeader.indexOf(";", start); if (end == -1) end = dkimHeader.length(); return dkimHeader.substring(start, end).trim(); } private String extractEmail(String argument) { int start = argument.indexOf('<'); int end = argument.indexOf('>'); if (start == -1 || end == -1) return null; return argument.substring(start + 1, end).trim().toLowerCase(); } private static class SmtpSession { boolean helo = false; boolean ehlo = false; boolean quit = false; String hostname; String mailFrom; List recipients = new ArrayList<>(); boolean dataReady = false; StringBuilder dataBody = new StringBuilder(); void reset() { mailFrom = null; recipients.clear(); dataReady = false; dataBody.setLength(0); } } }