Безопасность, SMTP, очередь отправки, DKIM, деплой на Jambotron (Jambotron v0.2.3 75.1–75.3)

- SMTP-сервер переписан: разбор по строкам, AUTH PLAIN/LOGIN, нет открытого релея,
  MAIL FROM только свой после входа, 250 только после сохранения (иначе 451), лимит размера
- отправка наружу: очередь outbound_queue (V2) с повторами ~22 ч и уведомлением о недоставке;
  Jakarta Mail, режимы off / mx / smarthost, байты письма не меняются (DKIM)
- DKIM: подпись utils-mail-dkim, проверка Apache jDKIM; ключ PKCS#8, старый PKCS#1 читается
- права: письма, папки, вложения — только владельцу (было: любой по номеру); имена вложений без ../
- секреты без значений по умолчанию (DB_PASSWORD, JWT_SECRET), регистрация закрыта,
  admin123 и /api/simulate/receive — только профиль dev
- веб-клиент внутри jar (относительный /api), Dockerfile в 3 этапа, не root, health;
  docker-compose.jambotron.yml: секреты создаются при первом запуске, наружу только 8080
- gradle-wrapper.jar был в .gitignore — сборка не запускалась
- тесты: 26 (SMTP реальным клиентом и сокетом, GreenMail, DKIM подпись+проверка, очередь)

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jambotron α
2026-11-05 03:37:43 +01:00
parent b6f6d41813
commit 165edf1a1e
44 changed files with 2626 additions and 1715 deletions
@@ -4,24 +4,46 @@ 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.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.security.SecureRandom;
import java.util.Base64;
/**
* First mailbox on an empty database: {@code mailserver.admin.email} (default admin@&lt;first domain&gt;) with
* {@code mailserver.admin.password}; no password given — a random one, printed to the log once.
*/
@Configuration
public class DataInitializer {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
@Bean
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository) {
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains,
@Value("${mailserver.admin.email:}") String email,
@Value("${mailserver.admin.password:}") String password) {
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");
if (userRepository.count().block() > 0) {
return;
}
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase();
boolean generated = password.isBlank();
String secret = generated ? randomPassword() : password;
userService.register(address, secret, "Administrator").block();
if (generated) {
log.warn("First mailbox created: {} — password: {} (shown once, change it)", address, secret);
} else {
log.info("First mailbox created: {}", address);
}
};
}
private static String randomPassword() {
byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -5,14 +5,17 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.charset.StandardCharsets;
@Configuration
public class JwtConfig {
@Value("${jwt.secret:MailServerSecretKeyForJWTTokenGenerationMustBeLongEnough2024!}")
private String jwtSecret;
/** JWT_SECRET from the environment, at least 32 bytes; no default — a known key would let anyone log in. */
@Bean
public JwtUtils jwtUtils() {
public JwtUtils jwtUtils(@Value("${jwt.secret:}") String jwtSecret) {
if (jwtSecret.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException("jwt.secret (JWT_SECRET) must be set, at least 32 bytes");
}
return new JwtUtils(jwtSecret);
}
}
@@ -0,0 +1,51 @@
package com.mailserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Domains whose mailboxes live on this server ({@code mailserver.domains}), and the optional catch-all mailbox
* ({@code mailserver.catch-all}): when set, mail to ANY address is stored there and nothing is delivered outside —
* a mail trap for dev/staging.
*/
@Component
public class MailDomains {
private final Set<String> domains;
private final String catchAll;
public MailDomains(@Value("${mailserver.domains:localhost}") String domains,
@Value("${mailserver.catch-all:}") String catchAll) {
this.domains = Arrays.stream(domains.split(","))
.map(d -> d.trim().toLowerCase(Locale.ROOT))
.filter(d -> !d.isEmpty())
.collect(Collectors.toUnmodifiableSet());
this.catchAll = catchAll.trim().toLowerCase(Locale.ROOT);
if (this.domains.isEmpty()) {
throw new IllegalStateException("mailserver.domains is empty");
}
}
public boolean isLocal(String address) {
return domains.contains(domainOf(address));
}
/** Mailbox that receives everything, or null. */
public String catchAll() {
return catchAll.isEmpty() ? null : catchAll;
}
public Set<String> domains() {
return domains;
}
public static String domainOf(String address) {
int at = address == null ? -1 : address.lastIndexOf('@');
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,52 @@
package com.mailserver.config;
import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpServer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.time.Duration;
import java.util.Locale;
/** SMTP server, outbound delivery client and the queue scheduler. */
@Configuration
@EnableScheduling
public class MailServerConfig {
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
public SmtpServer smtpServer(MailRouter router,
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtp.port:3025}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout), router);
}
/**
* mailserver.outbound.mode: off | mx | smarthost. For smarthost — host, port, username, password,
* tls (starttls | ssl | none).
*/
@Bean
public SmtpDeliveryClient smtpDeliveryClient(@Value("${mailserver.outbound.mode:off}") String mode,
@Value("${mailserver.outbound.host:}") String host,
@Value("${mailserver.outbound.port:587}") int port,
@Value("${mailserver.outbound.username:}") String username,
@Value("${mailserver.outbound.password:}") String password,
@Value("${mailserver.outbound.tls:starttls}") String tls,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.outbound.timeout-ms:30000}") int timeoutMs) {
SmtpDeliveryClient.Mode m = SmtpDeliveryClient.Mode.valueOf(mode.trim().toUpperCase(Locale.ROOT));
if (m == SmtpDeliveryClient.Mode.SMARTHOST && host.isBlank()) {
throw new IllegalStateException("mailserver.outbound.mode=smarthost needs mailserver.outbound.host");
}
return new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(m, host, port, username, password,
SmtpDeliveryClient.Tls.valueOf(tls.trim().toUpperCase(Locale.ROOT)), hostname, timeoutMs, 25));
}
}
@@ -16,15 +16,19 @@ import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final String corsOrigins;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
@org.springframework.beans.factory.annotation.Value("${mailserver.cors.origins:}") String corsOrigins) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.corsOrigins = corsOrigins;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(java.util.Arrays.asList("http://localhost:4200", "http://localhost:4201"));
// The web client is served from the same origin; other origins only for development (ng serve)
configuration.setAllowedOrigins(java.util.Arrays.stream(corsOrigins.split(",")).map(String::trim).filter(o -> !o.isEmpty()).toList());
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);
@@ -40,8 +44,10 @@ public class SecurityConfig {
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchange -> exchange
.pathMatchers("/api/login", "/api/register").permitAll()
.anyExchange().authenticated()
.pathMatchers("/api/login", "/api/register", "/actuator/health").permitAll()
.pathMatchers("/api/**", "/actuator/**").authenticated()
// the web client (static files, SPA routes)
.anyExchange().permitAll()
)
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION);
@@ -1,379 +0,0 @@
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,26 @@
package com.mailserver.config;
import org.springframework.core.annotation.Order;
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;
/** Angular routes (/inbox, /message/5, …) are pages of the web client: serve index.html for them. */
@Component
@Order(-100)
public class SpaWebFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getPath().pathWithinApplication().value();
boolean page = exchange.getRequest().getMethod().matches("GET")
&& !path.startsWith("/api") && !path.startsWith("/actuator")
&& !path.equals("/") && !path.substring(path.lastIndexOf('/') + 1).contains(".");
if (page) {
return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build());
}
return chain.filter(exchange);
}
}