Почтовый кабинет: регистрация, папки, черновики, вложения, грид @jambotron/aria-grid, интерфейс по synthwave-шаблону

Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма
(прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия,
вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных —
в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой.
Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html.

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 16:41:29 +01:00
parent f605fbfd85
commit 29b1beabe0
70 changed files with 3468 additions and 1737 deletions
@@ -6,6 +6,9 @@ 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;
@@ -30,6 +33,8 @@ 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;
@@ -63,16 +68,114 @@ public class MailService {
this.spamFilterService = spamFilterService;
}
/** System folders in the order the web client shows them; the rest — own folders, by name. */
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 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<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")))
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);
@@ -81,59 +184,111 @@ public class MailService {
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,
/** 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);
})
.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(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<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
public Mono<Message> 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();
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag, null, null);
}
public Flux<MessageResponse> getMessages(Long folderId, Long userId, int page, int size) {
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
/**
* 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 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;
return systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
.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.findByFolderId(folder.getId(), pageable))
.map(this::toResponse);
.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))
.map(this::toResponse);
.take(LIST_LIMIT)
.concatMap(this::withAttachmentCount)
.map(MailService::preview);
}
public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId,
@@ -143,23 +298,74 @@ public class MailService {
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);
.take(LIST_LIMIT)
.concatMap(this::withAttachmentCount)
.map(MailService::preview);
}
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
return ownedMessage(messageId, userId).map(this::toResponse);
return ownedMessage(messageId, userId).flatMap(this::withAttachmentCount);
}
public Mono<Void> markAsRead(Long messageId, Long userId) {
return ownedMessage(messageId, userId)
.flatMap(message -> {
message.setRead(true);
return messageRepository.save(message).then();
});
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(messageRepository::delete);
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. */
@@ -184,12 +390,40 @@ public class MailService {
.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("&nbsp;", " ").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());
@@ -202,10 +436,30 @@ public class MailService {
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 ownedMessage(messageId, userId)
return ownedDraft(messageId, userId)
.flatMap(message -> {
Attachment attachment = new Attachment();
attachment.setMessageId(messageId);
@@ -236,11 +490,7 @@ public class MailService {
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());
}
deleteFile(attachment.getFilePath());
return attachmentRepository.delete(attachment);
});
}