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.DraftRequest; import com.mailserver.dto.FolderResponse; import com.mailserver.dto.MessageUpdateRequest; 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.Comparator; import java.util.Locale; 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, MessageSources sources) { this.sources = sources; this.messageRepository = messageRepository; this.folderRepository = folderRepository; this.userRepository = userRepository; this.attachmentRepository = attachmentRepository; this.contactRepository = contactRepository; this.spamFilterService = spamFilterService; } /** System folders in the order the web client shows them; the rest — own folders, by name. */ private final MessageSources sources; public static final List SYSTEM_FOLDERS = List.of("INBOX", "Drafts", "Sent", "Spam", "Trash"); /** Messages per folder in one list — the web client sorts and pages them itself. */ public static final int LIST_LIMIT = 1000; private static final int PREVIEW_CHARS = 200; // --- Folders --- public Flux folders(Long userId) { return folderRepository.findByUserId(userId) .flatMap(folder -> Mono.zip(messageRepository.countByFolderId(folder.getId()), messageRepository.countUnreadByFolderId(folder.getId())) .map(counts -> new FolderResponse(folder.getId(), folder.getName(), folder.isSystem(), counts.getT1(), counts.getT2()))) .sort(Comparator.comparingInt(MailService::folderOrder) .thenComparing(folder -> folder.name().toLowerCase(Locale.ROOT))); } private static int folderOrder(FolderResponse folder) { int index = SYSTEM_FOLDERS.indexOf(folder.name()); return folder.system() && index >= 0 ? index : SYSTEM_FOLDERS.size(); } public Mono createFolder(Long userId, String rawName) { String name = folderName(rawName); return checkFolderNameFree(userId, name, null) .then(Mono.defer(() -> { Folder folder = new Folder(); folder.setUserId(userId); folder.setName(name); folder.setCreatedAt(Instant.now()); return folderRepository.save(folder); })) .map(folder -> new FolderResponse(folder.getId(), folder.getName(), false, 0, 0)); } public Mono renameFolder(Long folderId, Long userId, String rawName) { String name = folderName(rawName); return ownFolder(folderId, userId) .flatMap(folder -> checkFolderNameFree(userId, name, folder.getId()) .then(Mono.defer(() -> { folder.setName(name); return folderRepository.save(folder); }))) .then(); } /** An own folder: its messages go to the Trash, then the folder is removed. */ public Mono deleteFolder(Long folderId, Long userId) { return ownFolder(folderId, userId) .flatMap(folder -> systemFolder(userId, "Trash") .flatMap(trash -> messageRepository.moveAll(folder.getId(), trash.getId())) .then(folderRepository.delete(folder))); } /** Trash and Spam only: removes every message for good. */ public Mono emptyFolder(Long folderId, Long userId) { return ownedFolder(folderId, userId) .filter(folder -> folder.isSystem() && ("Trash".equals(folder.getName()) || "Spam".equals(folder.getName()))) .switchIfEmpty(Mono.error(badRequest("Only Trash and Spam can be emptied"))) .flatMapMany(folder -> messageRepository.findByFolderId(folder.getId())) .concatMap(message -> purge(message).thenReturn(1)) .reduce(0, Integer::sum); } private static String folderName(String rawName) { String name = rawName == null ? "" : rawName.trim().replaceAll("\\s+", " "); if (name.isEmpty() || name.length() > 50) { throw badRequest("Folder name: 1 to 50 characters"); } if (SYSTEM_FOLDERS.stream().anyMatch(name::equalsIgnoreCase)) { throw badRequest("This name belongs to a system folder"); } return name; } private Mono checkFolderNameFree(Long userId, String name, Long exceptId) { return folderRepository.findByUserId(userId) .filter(folder -> folder.getName().equalsIgnoreCase(name) && !folder.getId().equals(exceptId)) .hasElements() .flatMap(taken -> taken ? Mono.error(badRequest("A folder with this name already exists")) : Mono.empty()); } private Mono ownFolder(Long folderId, Long userId) { return ownedFolder(folderId, userId) .filter(folder -> !folder.isSystem()) .switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted"))); } private static final java.util.regex.Pattern DELIVERY_FOLDER = java.util.regex.Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}"); /** * A "+tag" / list folder that may be used as a folder name, or null (→ INBOX). A sender never puts mail into * a system folder (Spam, Trash, Sent …) this way. */ static String deliveryFolderName(String raw) { if (raw == null || !DELIVERY_FOLDER.matcher(raw).matches() || SYSTEM_FOLDERS.stream().anyMatch(raw::equalsIgnoreCase)) { return null; } return raw; } /** The folder with this name (any case), created as an own folder when missing; two at once — one wins. */ private Mono deliveryFolder(Long userId, String name) { Mono existing = folderRepository.findByUserId(userId) .filter(folder -> folder.getName().equalsIgnoreCase(name)) .next(); return existing.switchIfEmpty(Mono.defer(() -> { Folder folder = new Folder(); folder.setUserId(userId); folder.setName(name); folder.setCreatedAt(Instant.now()); return folderRepository.save(folder) .onErrorResume(org.springframework.dao.DataIntegrityViolationException.class, e -> existing); })); } private Mono systemFolder(Long userId, String name) { return folderRepository.findByUserIdAndName(userId, name) .switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found"))); } // --- Messages --- /** Copy of a message we sent, in the sender's "Sent" folder. */ public Mono saveSent(User fromUser, String to, String cc, String subject, String body, boolean html, String messageId) { return systemFolder(fromUser.getId(), "Sent") .flatMap(sentFolder -> { Message message = new Message(); message.setFolderId(sentFolder.getId()); message.setSenderEmail(fromUser.getEmail()); message.setSenderName(fromUser.getDisplayName()); message.setRecipientEmail(to); message.setCcEmail(cc == null || cc.isBlank() ? null : cc); 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); }); } /** Creates or updates a draft of the user (only a message in their Drafts folder can be updated). */ public Mono saveDraft(User user, Long draftId, DraftRequest request) { Mono target = draftId == null ? systemFolder(user.getId(), "Drafts").map(drafts -> { Message message = new Message(); message.setFolderId(drafts.getId()); message.setRead(true); message.setMessageId(UUID.randomUUID().toString()); return message; }) : ownedDraft(draftId, user.getId()); return target.flatMap(message -> { String subject = request.subject() == null ? "" : request.subject(); String body = request.body() == null ? "" : request.body(); message.setSenderEmail(user.getEmail()); message.setSenderName(user.getDisplayName()); message.setRecipientEmail(request.to() == null ? "" : request.to().trim()); message.setCcEmail(request.cc() == null || request.cc().isBlank() ? null : request.cc().trim()); message.setSubject(subject); message.setBody(body); message.setHtml(request.html()); message.setReceivedAt(Instant.now()); message.setMessageSize((long) (subject.length() + body.length())); return messageRepository.save(message); }) // IMAP: the draft changed — its MIME text is built anew .flatMap(saved -> sources.remove(saved.getId()).thenReturn(saved)) .flatMap(this::withAttachmentCount); } /** The user's draft, or 404. */ public Mono ownedDraft(Long draftId, Long userId) { return ownedMessage(draftId, userId) .filterWhen(message -> folderRepository.findById(message.getFolderId()) .map(folder -> folder.isSystem() && "Drafts".equals(folder.getName()))) .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Draft not found"))); } /** After sending: the draft's attachments move to the copy in Sent, the draft itself is removed. */ public Mono finishDraft(Long draftId, Long sentMessageId) { return attachmentRepository.moveAll(draftId, sentMessageId) .then(sources.remove(sentMessageId)) .then(messageRepository.deleteById(draftId)); } public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, String subject, String body, boolean isHtml) { return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false); } public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, String subject, String body, boolean isHtml, boolean externalSpamFlag) { return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag, null, null); } /** * Stores an incoming message: into INBOX, or into Spam when it failed the checks or the sender is blacklisted. * {@code toHeader}/{@code ccHeader}: addresses from the message headers (shown, used by "reply all"); * without them — the mailbox itself. */ public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, String subject, String body, boolean isHtml, boolean externalSpamFlag, String toHeader, String ccHeader) { return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag, toHeader, ccHeader, null); } /** * @param folder own folder for the message ("+tag" of the address, folder of a mailing list), created when * missing; null or not a valid folder name — INBOX. Spam still goes to Spam. */ public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, String subject, String body, boolean isHtml, boolean externalSpamFlag, String toHeader, String ccHeader, String targetFolder) { return userRepository.findByEmail(recipientEmail) .switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail))) .flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail) .flatMap(blacklisted -> { boolean isSpam = blacklisted || externalSpamFlag; String own = isSpam ? null : deliveryFolderName(targetFolder); return (own == null ? systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX") : deliveryFolder(recipient.getId(), own)) .flatMap(folder -> { Message message = new Message(); message.setFolderId(folder.getId()); message.setSenderEmail(senderEmail); message.setSenderName(senderName); message.setRecipientEmail(toHeader == null || toHeader.isBlank() ? recipientEmail : toHeader); message.setCcEmail(ccHeader == null || ccHeader.isBlank() ? null : ccHeader); 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); }); })); } /** Newest first; bodies cut to a preview — the full message comes from {@link #getMessage}. */ public Flux listMessages(Long folderId, Long userId, int limit) { int safeLimit = Math.max(1, Math.min(limit, LIST_LIMIT)); return ownedFolder(folderId, userId) .flatMapMany(folder -> messageRepository.findLatest(folder.getId(), safeLimit)) .concatMap(this::withAttachmentCount) .map(MailService::preview); } public Flux 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)) .take(LIST_LIMIT) .concatMap(this::withAttachmentCount) .map(MailService::preview); } public Flux 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)) .take(LIST_LIMIT) .concatMap(this::withAttachmentCount) .map(MailService::preview); } public Mono getMessage(Long messageId, Long userId) { return ownedMessage(messageId, userId).flatMap(this::withAttachmentCount); } public Mono markAsRead(Long messageId, Long userId) { return updateMessage(messageId, userId, new MessageUpdateRequest(true, null, null)).then(); } /** Read/unread, flag, move to another folder of the user (moving into Spam marks it as spam, out of it — not). */ public Mono updateMessage(Long messageId, Long userId, MessageUpdateRequest update) { return ownedMessage(messageId, userId) .flatMap(message -> { if (update.read() != null) { message.setRead(update.read()); } if (update.flagged() != null) { message.setFlagged(update.flagged()); } if (update.folderId() == null || update.folderId().equals(message.getFolderId())) { return messageRepository.save(message); } return ownedFolder(update.folderId(), userId).flatMap(target -> { message.setFolderId(target.getId()); message.setSpam(target.isSystem() && "Spam".equals(target.getName())); return messageRepository.save(message); }); }) .flatMap(this::withAttachmentCount); } /** Into the Trash; a message already in the Trash is removed for good (with its attachment files). */ public Mono deleteMessage(Long messageId, Long userId) { return ownedMessage(messageId, userId) .flatMap(message -> folderRepository.findById(message.getFolderId()) .flatMap(folder -> { if (folder.isSystem() && "Trash".equals(folder.getName())) { return purge(message); } return systemFolder(userId, "Trash").flatMap(trash -> { message.setFolderId(trash.getId()); return messageRepository.save(message).then(); }); })); } private Mono purge(Message message) { return attachmentRepository.findByMessageId(message.getId()) .doOnNext(attachment -> deleteFile(attachment.getFilePath())) .then(messageRepository.delete(message)); } private static void deleteFile(String path) { try { Files.deleteIfExists(Paths.get(path)); } catch (IOException e) { log.warn("Attachment file {} not deleted: {}", path, e.getMessage()); } } /** Bytes used by the mailbox: message texts plus attachments. */ public Mono usedBytes(Long userId) { return Mono.zip(messageRepository.sumSizeByUserId(userId), attachmentRepository.sumSizeByUserId(userId)) .map(sizes -> sizes.getT1() + sizes.getT2()); } /** The folder, when it belongs to the user; otherwise 404 — never someone else's mail. */ private Mono 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 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 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 Mono withAttachmentCount(Message message) { return attachmentRepository.countByMessageId(message.getId()) .defaultIfEmpty(0L) .map(count -> { MessageResponse response = toResponse(message); response.setAttachmentCount(count.intValue()); response.setHasAttachments(count > 0); return response; }); } private static MessageResponse preview(MessageResponse response) { String body = response.getBody(); if (body == null) { return response; } String text = response.isHtml() ? body.replaceAll("(?s)|<[^>]+>", " ") : body; text = text.replace(" ", " ").replaceAll("\\s+", " ").trim(); response.setBody(text.length() > PREVIEW_CHARS ? text.substring(0, PREVIEW_CHARS) + "…" : text); response.setHtml(false); return response; } static ResponseStatusException badRequest(String reason) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason); } 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.setCcEmail(message.getCcEmail()); 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()); resp.setAnsweredBy(message.getAnsweredBy()); return resp; } /** Stores a file of an incoming message (blocking: called from the SMTP worker thread). */ public void storeIncomingAttachment(Long messageId, String filename, String contentType, byte[] bytes) throws IOException { String safeName = com.mailserver.controller.MailController.safeFilename(filename); Path path = Paths.get(attachmentsDir, UUID.randomUUID() + "_" + safeName); Files.createDirectories(path.getParent()); Files.write(path, bytes); Attachment attachment = new Attachment(); attachment.setMessageId(messageId); attachment.setFilename(safeName); attachment.setContentType(contentType); attachment.setSizeBytes((long) bytes.length); attachment.setFilePath(path.toString()); attachmentRepository.save(attachment).block(); } /** Attachments of the user's draft, for building the message to send. */ public Flux draftAttachments(Long draftId, Long userId) { return ownedDraft(draftId, userId).flatMapMany(draft -> attachmentRepository.findByMessageId(draft.getId())); } public Mono saveAttachmentReactive(Long messageId, Long userId, String originalFilename, String contentType, long sizeBytes, String filePath) { return ownedDraft(messageId, userId) .flatMap(message -> { Attachment attachment = new Attachment(); attachment.setMessageId(messageId); attachment.setFilename(originalFilename); attachment.setContentType(contentType); attachment.setSizeBytes(sizeBytes); attachment.setFilePath(filePath); return sources.remove(messageId).then(attachmentRepository.save(attachment)); }) .map(this::toAttachmentResponse); } public String getAttachmentsDir() { return attachmentsDir; } /** The attachment record (file path, name, type) — only the owner's. */ public Mono getAttachment(Long attachmentId, Long userId) { return ownedAttachment(attachmentId, userId); } public Flux getMessageAttachments(Long messageId, Long userId) { return ownedMessage(messageId, userId) .flatMapMany(message -> attachmentRepository.findByMessageId(message.getId())) .map(this::toAttachmentResponse); } public Mono deleteAttachment(Long attachmentId, Long userId) { return ownedAttachment(attachmentId, userId) .flatMap(attachment -> { deleteFile(attachment.getFilePath()); return sources.remove(attachment.getMessageId()).then(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 getContacts(Long userId) { return contactRepository.findByUserId(userId); } public Mono 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 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 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); }); } }