continuous-integration/drone/pr Build is passing
- V7: users.is_shared, mailbox_access (MEMBER от админа/Jambotron, ATTACHED по паролю ящика), messages.answered_by
- /api/mailboxes: мои ящики с непрочитанными, сессия другого ящика (JWT с claim by), подключить по паролю
(LoginGuard), убрать из меню
- общий ящик: без своего входа (веб/SMTP/IMAP), участники открывают; «прочитано» общее; ответ из веба → answered_by
- SMTP/IMAP: ящик*логин + пароль логина (как master user в Dovecot)
- открытый чужой ящик (общий, подключённый, OPEN админа): профиль и пароль не меняются (403)
- админ: создать shared, участники ящика (PUT /{id}/members); служебное API: PUT /shared/{address};
выключение аккаунта убирает и из общих ящиков
- UI: MAILBOXES в левом меню (переключение, ⊕ подключить, × убрать), shared/участники в настройках,
«Answered by» у письма
- 88 тестов; живой прогон на Postgres 17 (апгрейд V6→V7 на данных), скриншоты UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
600 lines
29 KiB
Java
600 lines
29 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.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<String> 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<FolderResponse> 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<FolderResponse> 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<Void> 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<Void> 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<Integer> 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<Void> 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<Folder> 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<Folder> deliveryFolder(Long userId, String name) {
|
|
Mono<Folder> 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<Folder> 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<Message> 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<MessageResponse> saveDraft(User user, Long draftId, DraftRequest request) {
|
|
Mono<Message> 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<Message> 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<Void> finishDraft(Long draftId, Long sentMessageId) {
|
|
return attachmentRepository.moveAll(draftId, sentMessageId)
|
|
.then(sources.remove(sentMessageId))
|
|
.then(messageRepository.deleteById(draftId));
|
|
}
|
|
|
|
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
|
String subject, String body, boolean isHtml) {
|
|
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false);
|
|
}
|
|
|
|
public Mono<Message> 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<Message> 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<Message> 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<MessageResponse> 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<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))
|
|
.take(LIST_LIMIT)
|
|
.concatMap(this::withAttachmentCount)
|
|
.map(MailService::preview);
|
|
}
|
|
|
|
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))
|
|
.take(LIST_LIMIT)
|
|
.concatMap(this::withAttachmentCount)
|
|
.map(MailService::preview);
|
|
}
|
|
|
|
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
|
|
return ownedMessage(messageId, userId).flatMap(this::withAttachmentCount);
|
|
}
|
|
|
|
public Mono<Void> 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<MessageResponse> 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<Void> 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<Void> 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<Long> 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<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 Mono<MessageResponse> 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)<style.*?</style>|<[^>]+>", " ") : 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<Attachment> draftAttachments(Long draftId, Long userId) {
|
|
return ownedDraft(draftId, userId).flatMapMany(draft -> attachmentRepository.findByMessageId(draft.getId()));
|
|
}
|
|
|
|
public Mono<AttachmentResponse> 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<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 -> {
|
|
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<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);
|
|
});
|
|
}
|
|
}
|