- 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>
302 lines
14 KiB
Java
302 lines
14 KiB
Java
package com.mailserver.service;
|
|
|
|
import com.mailserver.domain.Attachment;
|
|
import com.mailserver.domain.Contact;
|
|
import com.mailserver.domain.Folder;
|
|
import com.mailserver.domain.Message;
|
|
import com.mailserver.domain.User;
|
|
import com.mailserver.dto.AttachmentResponse;
|
|
import com.mailserver.dto.MessageResponse;
|
|
import com.mailserver.repository.AttachmentRepository;
|
|
import com.mailserver.repository.ContactRepository;
|
|
import com.mailserver.repository.FolderRepository;
|
|
import com.mailserver.repository.MessageRepository;
|
|
import com.mailserver.repository.UserRepository;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
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 java.io.*;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.nio.file.Paths;
|
|
import java.time.Instant;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Properties;
|
|
import java.util.UUID;
|
|
|
|
@Service
|
|
public class MailService {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(MailService.class);
|
|
|
|
private final MessageRepository messageRepository;
|
|
private final FolderRepository folderRepository;
|
|
private final UserRepository userRepository;
|
|
private final AttachmentRepository attachmentRepository;
|
|
private final ContactRepository contactRepository;
|
|
private final SpamFilterService spamFilterService;
|
|
|
|
@Value("${mailserver.attachments.dir:./attachments}")
|
|
private String attachmentsDir;
|
|
|
|
|
|
|
|
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
|
|
UserRepository userRepository, AttachmentRepository attachmentRepository,
|
|
ContactRepository contactRepository,
|
|
SpamFilterService spamFilterService) {
|
|
this.messageRepository = messageRepository;
|
|
this.folderRepository = folderRepository;
|
|
this.userRepository = userRepository;
|
|
this.attachmentRepository = attachmentRepository;
|
|
this.contactRepository = contactRepository;
|
|
this.spamFilterService = spamFilterService;
|
|
}
|
|
|
|
/** 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,
|
|
String subject, String body, boolean isHtml) {
|
|
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false);
|
|
}
|
|
|
|
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
|
String subject, String body, boolean isHtml, boolean externalSpamFlag) {
|
|
return userRepository.findByEmail(recipientEmail)
|
|
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
|
|
.flatMap(recipient ->
|
|
folderRepository.findByUserIdAndName(recipient.getId(), "INBOX")
|
|
.switchIfEmpty(Mono.error(new IllegalStateException("INBOX not found")))
|
|
.flatMap(inbox ->
|
|
spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
|
|
.flatMap(isBlacklisted -> {
|
|
boolean isSpam = isBlacklisted || externalSpamFlag;
|
|
Message message = new Message();
|
|
message.setFolderId(inbox.getId());
|
|
message.setSenderEmail(senderEmail);
|
|
message.setSenderName(senderName);
|
|
message.setRecipientEmail(recipientEmail);
|
|
message.setSubject(subject);
|
|
message.setBody(body);
|
|
message.setHtml(isHtml);
|
|
message.setRead(false);
|
|
message.setSpam(isSpam);
|
|
message.setReceivedAt(Instant.now());
|
|
message.setMessageId(UUID.randomUUID().toString());
|
|
message.setMessageSize((long) (subject.length() + body.length()));
|
|
return messageRepository.save(message)
|
|
.then(folderRepository.save(inbox));
|
|
})
|
|
)
|
|
)
|
|
.then();
|
|
}
|
|
|
|
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 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 ownedFolder(folderId, userId)
|
|
.flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable))
|
|
.map(this::toResponse);
|
|
}
|
|
|
|
public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId,
|
|
String subject, String body,
|
|
String sender, String recipient,
|
|
int page, int size) {
|
|
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
|
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 ownedMessage(messageId, userId).map(this::toResponse);
|
|
}
|
|
|
|
public Mono<Void> markAsRead(Long messageId, Long userId) {
|
|
return ownedMessage(messageId, userId)
|
|
.flatMap(message -> {
|
|
message.setRead(true);
|
|
return messageRepository.save(message).then();
|
|
});
|
|
}
|
|
|
|
public Mono<Void> deleteMessage(Long messageId, Long userId) {
|
|
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) {
|
|
MessageResponse resp = new MessageResponse();
|
|
resp.setId(message.getId());
|
|
resp.setSenderEmail(message.getSenderEmail());
|
|
resp.setSenderName(message.getSenderName());
|
|
resp.setRecipientEmail(message.getRecipientEmail());
|
|
resp.setSubject(message.getSubject());
|
|
resp.setBody(message.getBody());
|
|
resp.setHtml(message.isHtml());
|
|
resp.setRead(message.isRead());
|
|
resp.setFlagged(message.isFlagged());
|
|
resp.setSpam(message.isSpam());
|
|
resp.setMessageSize(message.getMessageSize());
|
|
resp.setReceivedAt(message.getReceivedAt());
|
|
resp.setFolderId(message.getFolderId());
|
|
return resp;
|
|
}
|
|
|
|
public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
|
|
String originalFilename, String contentType,
|
|
long sizeBytes, String filePath) {
|
|
return ownedMessage(messageId, userId)
|
|
.flatMap(message -> {
|
|
Attachment attachment = new Attachment();
|
|
attachment.setMessageId(messageId);
|
|
attachment.setFilename(originalFilename);
|
|
attachment.setContentType(contentType);
|
|
attachment.setSizeBytes(sizeBytes);
|
|
attachment.setFilePath(filePath);
|
|
return attachmentRepository.save(attachment);
|
|
})
|
|
.map(this::toAttachmentResponse);
|
|
}
|
|
|
|
public String getAttachmentsDir() {
|
|
return attachmentsDir;
|
|
}
|
|
|
|
/** 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 ownedMessage(messageId, userId)
|
|
.flatMapMany(message -> attachmentRepository.findByMessageId(message.getId()))
|
|
.map(this::toAttachmentResponse);
|
|
}
|
|
|
|
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
|
|
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);
|
|
});
|
|
}
|
|
|
|
private AttachmentResponse toAttachmentResponse(Attachment attachment) {
|
|
AttachmentResponse resp = new AttachmentResponse();
|
|
resp.setId(attachment.getId());
|
|
resp.setFilename(attachment.getFilename());
|
|
resp.setContentType(attachment.getContentType());
|
|
resp.setSizeBytes(attachment.getSizeBytes());
|
|
return resp;
|
|
}
|
|
|
|
// --- Contacts ---
|
|
|
|
public Flux<Contact> getContacts(Long userId) {
|
|
return contactRepository.findByUserId(userId);
|
|
}
|
|
|
|
public Mono<Contact> addContact(Long userId, String name, String email, String phone, String company) {
|
|
if (email == null || email.trim().isEmpty()) {
|
|
return Mono.error(new IllegalArgumentException("Email is required"));
|
|
}
|
|
Contact contact = new Contact();
|
|
contact.setUserId(userId);
|
|
contact.setName(name != null ? name : "");
|
|
contact.setEmail(email.trim());
|
|
contact.setPhone(phone != null ? phone : "");
|
|
contact.setCompany(company != null ? company : "");
|
|
return contactRepository.save(contact);
|
|
}
|
|
|
|
public Mono<Contact> updateContact(Long id, Long userId, String name, String email, String phone, String company) {
|
|
return contactRepository.findById(id)
|
|
.switchIfEmpty(Mono.error(new IllegalArgumentException("Contact not found")))
|
|
.flatMap(contact -> {
|
|
if (!contact.getUserId().equals(userId)) {
|
|
return Mono.error(new IllegalArgumentException("Not your contact"));
|
|
}
|
|
if (name != null) contact.setName(name);
|
|
if (email != null) contact.setEmail(email.trim());
|
|
if (phone != null) contact.setPhone(phone);
|
|
if (company != null) contact.setCompany(company);
|
|
return contactRepository.save(contact);
|
|
});
|
|
}
|
|
|
|
public Mono<Void> deleteContact(Long id, Long userId) {
|
|
return contactRepository.findById(id)
|
|
.switchIfEmpty(Mono.error(new IllegalArgumentException("Contact not found")))
|
|
.flatMap(contact -> {
|
|
if (!contact.getUserId().equals(userId)) {
|
|
return Mono.error(new IllegalArgumentException("Not your contact"));
|
|
}
|
|
return contactRepository.delete(contact);
|
|
});
|
|
}
|
|
}
|