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 send(String fromEmail, SendRequest request) { List to = addresses(request.getTo()); List 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.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.empty() : mailService.finishDraft(draftId, sent.getId())) .then(mailService.getMessage(sent.getId(), user.getId())))); } private String submit(User user, List to, List cc, SendRequest request, List attachments) throws Exception { List 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 to, List cc, SendRequest request, List 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 addresses(String raw) { Set 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 concat(List a, List b) { List all = new ArrayList<>(a); all.addAll(b); return all; } }