Безопасность, 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:
@@ -7,7 +7,6 @@ import com.mailserver.domain.Message;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.AttachmentResponse;
|
||||
import com.mailserver.dto.MessageResponse;
|
||||
import com.mailserver.dto.SendRequest;
|
||||
import com.mailserver.repository.AttachmentRepository;
|
||||
import com.mailserver.repository.ContactRepository;
|
||||
import com.mailserver.repository.FolderRepository;
|
||||
@@ -15,16 +14,14 @@ import com.mailserver.repository.MessageRepository;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -36,8 +33,6 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import reactor.netty.Connection;
|
||||
import reactor.netty.tcp.TcpClient;
|
||||
|
||||
@Service
|
||||
public class MailService {
|
||||
@@ -50,64 +45,44 @@ public class MailService {
|
||||
private final AttachmentRepository attachmentRepository;
|
||||
private final ContactRepository contactRepository;
|
||||
private final SpamFilterService spamFilterService;
|
||||
private final DkimService dkimService;
|
||||
|
||||
@Value("${mailserver.attachments.dir:./attachments}")
|
||||
private String attachmentsDir;
|
||||
|
||||
@Value("${mailserver.smtp.port:3025}")
|
||||
private int smtpPort;
|
||||
|
||||
@Value("${mailserver.smtp.hostname:localhost}")
|
||||
private String smtpHostname;
|
||||
|
||||
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
|
||||
UserRepository userRepository, AttachmentRepository attachmentRepository,
|
||||
ContactRepository contactRepository,
|
||||
SpamFilterService spamFilterService,
|
||||
DkimService dkimService) {
|
||||
SpamFilterService spamFilterService) {
|
||||
this.messageRepository = messageRepository;
|
||||
this.folderRepository = folderRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.attachmentRepository = attachmentRepository;
|
||||
this.contactRepository = contactRepository;
|
||||
this.spamFilterService = spamFilterService;
|
||||
this.dkimService = dkimService;
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> sendMessage(String fromEmail, SendRequest request) {
|
||||
return userRepository.findByEmail(fromEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Sender not found: " + fromEmail)))
|
||||
.flatMap(fromUser ->
|
||||
folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
|
||||
.flatMap(sentFolder -> {
|
||||
Message message = new Message();
|
||||
message.setFolderId(sentFolder.getId());
|
||||
message.setSenderEmail(fromEmail);
|
||||
message.setSenderName(fromUser.getDisplayName());
|
||||
message.setRecipientEmail(request.getTo());
|
||||
message.setSubject(request.getSubject());
|
||||
message.setBody(request.getBody());
|
||||
message.setHtml(request.isHtml());
|
||||
message.setRead(true);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(UUID.randomUUID().toString());
|
||||
message.setMessageSize((long) (request.getSubject().length() + request.getBody().length()));
|
||||
return messageRepository.save(message);
|
||||
})
|
||||
.flatMap(message ->
|
||||
sendViaSmtp(fromEmail, request.getTo(), request.getSubject(), request.getBody(),
|
||||
request.isHtml(), message.getMessageId())
|
||||
.doOnSuccess(v -> log.info("Email sent via SMTP: {} -> {}", fromEmail, request.getTo()))
|
||||
.onErrorResume(e -> {
|
||||
log.error("SMTP send failed: {}", e.getMessage(), e);
|
||||
return Mono.empty();
|
||||
})
|
||||
.thenReturn(message)
|
||||
)
|
||||
.map(this::toResponse)
|
||||
);
|
||||
/** Copy of a message we sent, in the sender's "Sent" folder. */
|
||||
public Mono<MessageResponse> saveSent(User fromUser, String to, String subject, String body, boolean html, String messageId) {
|
||||
return folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
|
||||
.flatMap(sentFolder -> {
|
||||
Message message = new Message();
|
||||
message.setFolderId(sentFolder.getId());
|
||||
message.setSenderEmail(fromUser.getEmail());
|
||||
message.setSenderName(fromUser.getDisplayName());
|
||||
message.setRecipientEmail(to);
|
||||
message.setSubject(subject);
|
||||
message.setBody(body);
|
||||
message.setHtml(html);
|
||||
message.setRead(true);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(messageId);
|
||||
message.setMessageSize((long) (subject.length() + body.length()));
|
||||
return messageRepository.save(message);
|
||||
})
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
@@ -149,13 +124,15 @@ public class MailService {
|
||||
|
||||
public Flux<MessageResponse> getMessages(Long folderId, Long userId, int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.findByFolderId(folderId, pageable)
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.findByFolderId(folder.getId(), pageable))
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Flux<MessageResponse> searchMessages(Long folderId, Long userId, String keyword, int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.searchInFolder(folderId, keyword, pageable)
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable))
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
@@ -164,17 +141,17 @@ public class MailService {
|
||||
String sender, String recipient,
|
||||
int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.searchWithFilters(folderId, subject, body, sender, recipient, pageable)
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable))
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
.map(this::toResponse);
|
||||
return ownedMessage(messageId, userId).map(this::toResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> markAsRead(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
message.setRead(true);
|
||||
return messageRepository.save(message).then();
|
||||
@@ -182,7 +159,29 @@ public class MailService {
|
||||
}
|
||||
|
||||
public Mono<Void> deleteMessage(Long messageId, Long userId) {
|
||||
return messageRepository.deleteById(messageId);
|
||||
return ownedMessage(messageId, userId).flatMap(messageRepository::delete);
|
||||
}
|
||||
|
||||
/** The folder, when it belongs to the user; otherwise 404 — never someone else's mail. */
|
||||
private Mono<Folder> ownedFolder(Long folderId, Long userId) {
|
||||
return folderRepository.findById(folderId)
|
||||
.filter(folder -> folder.getUserId().equals(userId))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Folder not found")));
|
||||
}
|
||||
|
||||
private Mono<Message> ownedMessage(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
.filterWhen(message -> folderRepository.findById(message.getFolderId())
|
||||
.map(folder -> folder.getUserId().equals(userId))
|
||||
.defaultIfEmpty(false))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Message not found")));
|
||||
}
|
||||
|
||||
private Mono<Attachment> ownedAttachment(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.filterWhen(attachment -> ownedMessage(attachment.getMessageId(), userId).hasElement()
|
||||
.onErrorReturn(false))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found")));
|
||||
}
|
||||
|
||||
private MessageResponse toResponse(Message message) {
|
||||
@@ -206,7 +205,7 @@ public class MailService {
|
||||
public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
|
||||
String originalFilename, String contentType,
|
||||
long sizeBytes, String filePath) {
|
||||
return messageRepository.findById(messageId)
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
Attachment attachment = new Attachment();
|
||||
attachment.setMessageId(messageId);
|
||||
@@ -223,30 +222,24 @@ public class MailService {
|
||||
return attachmentsDir;
|
||||
}
|
||||
|
||||
public String getAttachmentFilePath(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.map(Attachment::getFilePath)
|
||||
.block();
|
||||
}
|
||||
|
||||
public AttachmentResponse getAttachmentInfo(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.map(this::toAttachmentResponse)
|
||||
.block();
|
||||
/** The attachment record (file path, name, type) — only the owner's. */
|
||||
public Mono<Attachment> getAttachment(Long attachmentId, Long userId) {
|
||||
return ownedAttachment(attachmentId, userId);
|
||||
}
|
||||
|
||||
public Flux<AttachmentResponse> getMessageAttachments(Long messageId, Long userId) {
|
||||
return attachmentRepository.findByMessageId(messageId)
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMapMany(message -> attachmentRepository.findByMessageId(message.getId()))
|
||||
.map(this::toAttachmentResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
return ownedAttachment(attachmentId, userId)
|
||||
.flatMap(attachment -> {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
|
||||
} catch (IOException e) {
|
||||
// Игнорируем
|
||||
log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage());
|
||||
}
|
||||
return attachmentRepository.delete(attachment);
|
||||
});
|
||||
@@ -261,132 +254,6 @@ public class MailService {
|
||||
return resp;
|
||||
}
|
||||
|
||||
private Mono<Void> sendViaSmtp(String from, String to, String subject, String body,
|
||||
boolean isHtml, String messageId) {
|
||||
TcpClient client = TcpClient.create()
|
||||
.host(smtpHostname)
|
||||
.port(smtpPort)
|
||||
.option(io.netty.channel.ChannelOption.SO_TIMEOUT, 30000)
|
||||
.option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
|
||||
|
||||
Properties props = new Properties();
|
||||
Session mailSession = Session.getDefaultInstance(props);
|
||||
MimeMessage mimeMessage = new MimeMessage(mailSession);
|
||||
try {
|
||||
mimeMessage.setFrom(from);
|
||||
mimeMessage.setRecipient(jakarta.mail.Message.RecipientType.TO, new jakarta.mail.internet.InternetAddress(to));
|
||||
mimeMessage.setSubject(subject);
|
||||
mimeMessage.setText(body, isHtml ? "text/html" : "text/plain", "UTF-8");
|
||||
mimeMessage.setHeader("Message-ID", "<" + messageId + "@" + smtpHostname + ">");
|
||||
mimeMessage.setSentDate(new java.util.Date());
|
||||
|
||||
dkimService.signMessage(mimeMessage);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
mimeMessage.writeTo(baos);
|
||||
String mimeContent = baos.toString(StandardCharsets.UTF_8);
|
||||
|
||||
StringBuilder dataBody = new StringBuilder();
|
||||
String[] lines = mimeContent.split("\\r?\\n");
|
||||
for (String mimeLine : lines) {
|
||||
if (mimeLine.startsWith(".")) {
|
||||
dataBody.append(".").append(mimeLine);
|
||||
} else {
|
||||
dataBody.append(mimeLine);
|
||||
}
|
||||
dataBody.append("\r\n");
|
||||
}
|
||||
dataBody.append(".\r\n");
|
||||
|
||||
// SMTP — последовательный протокол: команда -> ответ -> команда -> ответ
|
||||
// connection.inbound().receive() можно подписать только ОДИН раз,
|
||||
// поэтому используем Sinks.Many с directBestEffort для шаринга ответов
|
||||
return client.connect()
|
||||
.flatMap(connection -> {
|
||||
// Sinks.Many — один поток чтения, много подписчиков через multicast
|
||||
Sinks.Many<String> responseSink = Sinks.many().replay().limit(1);
|
||||
|
||||
// Подписываемся на inbound ОДИН раз и пишем все ответы в sink
|
||||
connection.inbound()
|
||||
.receive()
|
||||
.asString(StandardCharsets.UTF_8)
|
||||
.doOnNext(line -> {
|
||||
String trimmed = line.trim();
|
||||
log.debug("SMTP клиент ответ: {}", trimmed);
|
||||
responseSink.tryEmitNext(trimmed);
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
// Собираем все команды в список для последовательной отправки
|
||||
List<String> commands = new ArrayList<>();
|
||||
commands.add("EHLO " + smtpHostname + "\r\n");
|
||||
commands.add("MAIL FROM:<" + from + ">\r\n");
|
||||
commands.add("RCPT TO:<" + to + ">\r\n");
|
||||
commands.add("DATA\r\n");
|
||||
commands.add(dataBody.toString());
|
||||
commands.add("QUIT\r\n");
|
||||
|
||||
// Ожидаемые коды ответов для каждой команды (включая banner)
|
||||
List<String> expectedCodes = new ArrayList<>();
|
||||
expectedCodes.add("220"); // banner
|
||||
expectedCodes.add("250"); // EHLO
|
||||
expectedCodes.add("250"); // MAIL FROM
|
||||
expectedCodes.add("250"); // RCPT TO
|
||||
expectedCodes.add("354"); // DATA
|
||||
expectedCodes.add("250"); // тело DATA (завершение)
|
||||
expectedCodes.add("221"); // QUIT
|
||||
|
||||
// Последовательно отправляем команды и ждём ответы
|
||||
Mono<Void> send = Mono.empty();
|
||||
for (int i = 0; i < commands.size(); i++) {
|
||||
final String cmd = commands.get(i);
|
||||
final String expectedCode = expectedCodes.get(i);
|
||||
|
||||
send = send.then(sendAndAwait(connection, cmd, responseSink, expectedCode));
|
||||
}
|
||||
|
||||
return send.then(Mono.fromRunnable(() -> connection.dispose()));
|
||||
});
|
||||
|
||||
} catch (MessagingException | IOException e) {
|
||||
return Mono.error(new RuntimeException("Failed to prepare SMTP message", e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет SMTP команду и ждёт ответ с указанным кодом.
|
||||
* Для многострочных ответов (EHLO) читает до строки без '-' в конце кода.
|
||||
*/
|
||||
private Mono<Void> sendAndAwait(Connection connection, String command,
|
||||
Sinks.Many<String> responseSink,
|
||||
String expectedCode) {
|
||||
log.debug("SMTP клиент команда: {}", command.trim());
|
||||
|
||||
// Отправляем команду
|
||||
Mono<Void> send = connection.outbound()
|
||||
.sendString(Mono.just(command))
|
||||
.then();
|
||||
|
||||
// Ждём ответ из sink — каждый вызов получает свой Flux подписчика
|
||||
Mono<Void> readResponse = responseSink.asFlux()
|
||||
.takeWhile(line -> {
|
||||
if (expectedCode != null && line.startsWith(expectedCode + "-")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.doOnNext(line -> {
|
||||
if (expectedCode != null && !line.startsWith(expectedCode)) {
|
||||
log.warn("SMTP неожиданный ответ (ожидалось {}): {}", expectedCode, line);
|
||||
}
|
||||
})
|
||||
.then()
|
||||
.timeout(java.time.Duration.ofSeconds(30))
|
||||
.onErrorResume(e -> Mono.error(new RuntimeException("SMTP response timeout", e)));
|
||||
|
||||
return send.then(readResponse);
|
||||
}
|
||||
|
||||
// --- Contacts ---
|
||||
|
||||
public Flux<Contact> getContacts(Long userId) {
|
||||
|
||||
Reference in New Issue
Block a user