Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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
@@ -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;
}
}