Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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:
@@ -9,6 +9,7 @@ import jakarta.mail.Part;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.internet.MimeUtility;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -18,6 +19,8 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
@@ -71,7 +74,61 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
|
||||
dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : "");
|
||||
}
|
||||
|
||||
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam).block();
|
||||
com.mailserver.domain.Message stored =
|
||||
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam,
|
||||
addresses(mime, jakarta.mail.Message.RecipientType.TO),
|
||||
addresses(mime, jakarta.mail.Message.RecipientType.CC)).block();
|
||||
if (stored != null) {
|
||||
for (FilePart file : attachments(mime)) {
|
||||
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */
|
||||
static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) {
|
||||
try {
|
||||
jakarta.mail.Address[] list = mime.getRecipients(type);
|
||||
if (list == null || list.length == 0) {
|
||||
return null;
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for (jakarta.mail.Address address : list) {
|
||||
result.add(address instanceof InternetAddress internet ? internet.getAddress() : address.toString());
|
||||
}
|
||||
String joined = String.join(", ", result);
|
||||
return joined.length() > 5000 ? joined.substring(0, 5000) : joined;
|
||||
} catch (MessagingException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
record FilePart(String name, String contentType, byte[] bytes) {
|
||||
}
|
||||
|
||||
/** Parts with a file name (attachments and inline images), nested multiparts walked. */
|
||||
static List<FilePart> attachments(Part part) throws MessagingException, IOException {
|
||||
List<FilePart> files = new ArrayList<>();
|
||||
collect(part, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
private static void collect(Part part, List<FilePart> files) throws MessagingException, IOException {
|
||||
if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) {
|
||||
for (int i = 0; i < multipart.getCount(); i++) {
|
||||
collect(multipart.getBodyPart(i), files);
|
||||
}
|
||||
return;
|
||||
}
|
||||
String name = part.getFileName();
|
||||
if (name == null && !Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
|
||||
return;
|
||||
}
|
||||
String decoded = name == null ? "file" : MimeUtility.decodeText(name);
|
||||
String type = part.getContentType() == null ? "application/octet-stream" : part.getContentType().split(";")[0].trim();
|
||||
try (InputStream in = part.getInputStream()) {
|
||||
files.add(new FilePart(decoded, type.toLowerCase(java.util.Locale.ROOT), in.readAllBytes()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-delivery report into the sender's inbox — only when the sender is our mailbox. */
|
||||
@@ -101,7 +158,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
|
||||
}
|
||||
|
||||
private static String find(Part part, String type) throws MessagingException, IOException {
|
||||
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
|
||||
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) {
|
||||
return null;
|
||||
}
|
||||
if (part.isMimeType(type)) {
|
||||
|
||||
@@ -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(" ", " ").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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,32 +1,51 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import com.mailserver.domain.Attachment;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.MessageResponse;
|
||||
import com.mailserver.dto.SendRequest;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import com.mailserver.smtp.MailRouter;
|
||||
import com.mailserver.smtp.SmtpBackend;
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.internet.MimeMultipart;
|
||||
import jakarta.mail.internet.MimeUtility;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.scheduler.Schedulers;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** "Send" in the web client: builds the MIME message and routes it like an authenticated SMTP submission. */
|
||||
/**
|
||||
* "Send" in the web client: builds the MIME message (with the draft's attachments) and routes it like an
|
||||
* authenticated SMTP submission — to every To and Cc address.
|
||||
*/
|
||||
@Service
|
||||
public class SubmissionService {
|
||||
|
||||
/** One message, at most this many addresses (To + Cc). */
|
||||
static final int MAX_RECIPIENTS = 50;
|
||||
|
||||
private final UserRepository users;
|
||||
private final MailService mailService;
|
||||
private final MailRouter router;
|
||||
@@ -41,34 +60,109 @@ public class SubmissionService {
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
|
||||
String to = request.getTo().trim().toLowerCase(Locale.ROOT);
|
||||
List<String> to = addresses(request.getTo());
|
||||
List<String> cc = addresses(request.getCc());
|
||||
cc.removeAll(to);
|
||||
if (to.isEmpty()) {
|
||||
return Mono.error(MailService.badRequest("No recipient"));
|
||||
}
|
||||
if (to.size() + cc.size() > MAX_RECIPIENTS) {
|
||||
return Mono.error(MailService.badRequest("At most " + MAX_RECIPIENTS + " recipients"));
|
||||
}
|
||||
Long draftId = request.getDraftId();
|
||||
return users.findByEmail(fromEmail)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||
.flatMap(user -> Mono.fromCallable(() -> {
|
||||
SmtpBackend.Route route = router.route(to, user.getEmail());
|
||||
switch (route) {
|
||||
case LOCAL, OUTBOUND -> { }
|
||||
case UNKNOWN_USER -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No such mailbox: " + to);
|
||||
case OUTBOUND_DISABLED -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Sending outside is disabled on this server");
|
||||
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
|
||||
}
|
||||
String messageId = UUID.randomUUID() + "@" + hostname;
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
|
||||
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
|
||||
mime.setRecipient(Message.RecipientType.TO, new InternetAddress(to));
|
||||
mime.setSubject(request.getSubject(), "UTF-8");
|
||||
mime.setText(request.getBody(), "UTF-8", request.isHtml() ? "html" : "plain");
|
||||
mime.setSentDate(new Date());
|
||||
mime.saveChanges();
|
||||
mime.setHeader("Message-ID", "<" + messageId + ">");
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
mime.writeTo(bytes);
|
||||
router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(),
|
||||
List.of(new SmtpBackend.Recipient(to, route))), bytes.toByteArray());
|
||||
return messageId;
|
||||
}).subscribeOn(Schedulers.boundedElastic())
|
||||
.flatMap(messageId -> mailService.saveSent(user, to, request.getSubject(), request.getBody(),
|
||||
request.isHtml(), messageId)));
|
||||
.flatMap(user -> (draftId == null ? Flux.<Attachment>empty() : mailService.draftAttachments(draftId, user.getId()))
|
||||
.collectList()
|
||||
.flatMap(attachments -> Mono.fromCallable(() -> submit(user, to, cc, request, attachments))
|
||||
.subscribeOn(Schedulers.boundedElastic()))
|
||||
.flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc),
|
||||
request.getSubject(), request.getBody(), request.isHtml(), messageId))
|
||||
.flatMap(sent -> (draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
|
||||
.then(mailService.getMessage(sent.getId(), user.getId()))));
|
||||
}
|
||||
|
||||
private String submit(User user, List<String> to, List<String> cc, SendRequest request, List<Attachment> attachments)
|
||||
throws Exception {
|
||||
List<SmtpBackend.Recipient> recipients = new ArrayList<>();
|
||||
for (String address : concat(to, cc)) {
|
||||
SmtpBackend.Route route = router.route(address, user.getEmail());
|
||||
switch (route) {
|
||||
case LOCAL, OUTBOUND -> recipients.add(new SmtpBackend.Recipient(address, route));
|
||||
case UNKNOWN_USER -> throw MailService.badRequest("No such mailbox: " + address);
|
||||
case OUTBOUND_DISABLED -> throw MailService.badRequest("Sending outside is disabled on this server: " + address);
|
||||
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
|
||||
}
|
||||
}
|
||||
String messageId = UUID.randomUUID() + "@" + hostname;
|
||||
MimeMessage mime = mime(user, to, cc, request, attachments);
|
||||
mime.setHeader("Message-ID", "<" + messageId + ">");
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
mime.writeTo(bytes);
|
||||
router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(), recipients),
|
||||
bytes.toByteArray());
|
||||
return messageId;
|
||||
}
|
||||
|
||||
static MimeMessage mime(User user, List<String> to, List<String> cc, SendRequest request, List<Attachment> attachments)
|
||||
throws MessagingException, IOException {
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
|
||||
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
|
||||
mime.setRecipients(Message.RecipientType.TO, InternetAddress.parse(String.join(",", to)));
|
||||
if (!cc.isEmpty()) {
|
||||
mime.setRecipients(Message.RecipientType.CC, InternetAddress.parse(String.join(",", cc)));
|
||||
}
|
||||
mime.setSubject(request.getSubject(), "UTF-8");
|
||||
String subtype = request.isHtml() ? "html" : "plain";
|
||||
if (attachments.isEmpty()) {
|
||||
mime.setText(request.getBody(), "UTF-8", subtype);
|
||||
} else {
|
||||
MimeMultipart mixed = new MimeMultipart("mixed");
|
||||
MimeBodyPart text = new MimeBodyPart();
|
||||
text.setText(request.getBody(), "UTF-8", subtype);
|
||||
mixed.addBodyPart(text);
|
||||
for (Attachment attachment : attachments) {
|
||||
MimeBodyPart file = new MimeBodyPart();
|
||||
file.attachFile(new File(attachment.getFilePath()),
|
||||
attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType(), null);
|
||||
file.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null));
|
||||
mixed.addBodyPart(file);
|
||||
}
|
||||
mime.setContent(mixed);
|
||||
}
|
||||
mime.setSentDate(new Date());
|
||||
mime.saveChanges();
|
||||
return mime;
|
||||
}
|
||||
|
||||
/** "a@x, b@y; c@z" → lower-case addresses without repeats; a malformed one → 400. */
|
||||
static List<String> addresses(String raw) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
if (raw == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
for (String part : raw.split("[,;\\s]+")) {
|
||||
String address = part.trim().toLowerCase(Locale.ROOT);
|
||||
if (address.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
new InternetAddress(address, true).validate();
|
||||
} catch (AddressException e) {
|
||||
throw MailService.badRequest("Invalid address: " + address);
|
||||
}
|
||||
if (!address.contains("@")) {
|
||||
throw MailService.badRequest("Invalid address: " + address);
|
||||
}
|
||||
result.add(address);
|
||||
}
|
||||
return new ArrayList<>(result);
|
||||
}
|
||||
|
||||
private static List<String> concat(List<String> a, List<String> b) {
|
||||
List<String> all = new ArrayList<>(a);
|
||||
all.addAll(b);
|
||||
return all;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.mailserver.service;
|
||||
|
||||
import com.mailserver.domain.Folder;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.FolderResponse;
|
||||
import com.mailserver.repository.FolderRepository;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
@@ -35,18 +34,18 @@ public class UserService {
|
||||
User user = new User();
|
||||
user.setEmail(email);
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
user.setDisplayName(displayName);
|
||||
user.setDisplayName(displayName == null || displayName.isBlank() ? email.substring(0, email.indexOf('@')) : displayName.trim());
|
||||
user.setCreatedAt(Instant.now());
|
||||
return userRepository.save(user);
|
||||
})
|
||||
.flatMap(user -> {
|
||||
// Создаём стандартные папки
|
||||
String[] defaultFolders = {"INBOX", "Sent", "Drafts", "Trash", "Spam"};
|
||||
return Flux.fromArray(defaultFolders)
|
||||
.flatMap(name -> {
|
||||
return Flux.fromIterable(MailService.SYSTEM_FOLDERS)
|
||||
.concatMap(name -> {
|
||||
Folder folder = new Folder();
|
||||
folder.setUserId(user.getId());
|
||||
folder.setName(name);
|
||||
folder.setSystem(true);
|
||||
folder.setCreatedAt(Instant.now());
|
||||
return folderRepository.save(folder);
|
||||
})
|
||||
@@ -66,15 +65,17 @@ public class UserService {
|
||||
return passwordEncoder.matches(rawPassword, user.getPassword());
|
||||
}
|
||||
|
||||
public Flux<FolderResponse> getFolders(Long userId) {
|
||||
return folderRepository.findByUserId(userId)
|
||||
.map(f -> {
|
||||
FolderResponse resp = new FolderResponse();
|
||||
resp.setId(f.getId());
|
||||
resp.setName(f.getName());
|
||||
resp.setMessageCount(f.getMessageCount());
|
||||
resp.setUnreadCount(f.getUnreadCount());
|
||||
return resp;
|
||||
});
|
||||
/** New password, only with the current one. */
|
||||
public Mono<Void> changePassword(User user, String currentPassword, String newPassword) {
|
||||
if (!checkPassword(user, currentPassword)) {
|
||||
return Mono.error(MailService.badRequest("Current password is wrong"));
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
return userRepository.save(user).then();
|
||||
}
|
||||
|
||||
public Mono<User> updateDisplayName(User user, String displayName) {
|
||||
user.setDisplayName(displayName.trim());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user