Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма (прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия, вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных — в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой. Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html. α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
7.4 KiB
Java
169 lines
7.4 KiB
Java
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 (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;
|
|
private final String hostname;
|
|
|
|
public SubmissionService(UserRepository users, MailService mailService, MailRouter router,
|
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
|
this.users = users;
|
|
this.mailService = mailService;
|
|
this.router = router;
|
|
this.hostname = hostname;
|
|
}
|
|
|
|
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
|
|
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 -> (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;
|
|
}
|
|
|
|
}
|