Безопасность, 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
@@ -0,0 +1,74 @@
package com.mailserver.service;
import com.mailserver.dto.MessageResponse;
import com.mailserver.dto.SendRequest;
import com.mailserver.repository.UserRepository;
import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpBackend;
import jakarta.mail.Message;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.UUID;
/** "Send" in the web client: builds the MIME message and routes it like an authenticated SMTP submission. */
@Service
public class SubmissionService {
private final UserRepository users;
private final MailService mailService;
private final MailRouter router;
private final String hostname;
public SubmissionService(UserRepository users, MailService mailService, MailRouter router,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
this.users = users;
this.mailService = mailService;
this.router = router;
this.hostname = hostname;
}
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
String to = request.getTo().trim().toLowerCase(Locale.ROOT);
return users.findByEmail(fromEmail)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(user -> Mono.fromCallable(() -> {
SmtpBackend.Route route = router.route(to, user.getEmail());
switch (route) {
case LOCAL, OUTBOUND -> { }
case UNKNOWN_USER -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No such mailbox: " + to);
case OUTBOUND_DISABLED -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Sending outside is disabled on this server");
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
}
String messageId = UUID.randomUUID() + "@" + hostname;
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
mime.setRecipient(Message.RecipientType.TO, new InternetAddress(to));
mime.setSubject(request.getSubject(), "UTF-8");
mime.setText(request.getBody(), "UTF-8", request.isHtml() ? "html" : "plain");
mime.setSentDate(new Date());
mime.saveChanges();
mime.setHeader("Message-ID", "<" + messageId + ">");
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
mime.writeTo(bytes);
router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(),
List.of(new SmtpBackend.Recipient(to, route))), bytes.toByteArray());
return messageId;
}).subscribeOn(Schedulers.boundedElastic())
.flatMap(messageId -> mailService.saveSent(user, to, request.getSubject(), request.getBody(),
request.isHtml(), messageId)));
}
}