380 lines
17 KiB
Java
380 lines
17 KiB
Java
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);
|
|
}
|
|
}
|
|
}
|