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