75.2 IMAP: новый сервер на Reactor Netty (STARTTLS, 993, IDLE, UIDPLUS, MOVE)

Старый IMAP (выключен, .block() на сетевом потоке) удалён, написан заново:
- ImapServer: декодер целых команд с литералами ({n} после "+ Ready", {n+}),
  команды по одной на рабочем потоке; STARTTLS (без выполнения открытого текста
  после него) и неявный TLS; IDLE с опросом раз в 5 с до DONE
- ImapSession: IMAP4rev1 + LITERAL+, SASL-IR, IDLE, UIDPLUS, MOVE, UNSELECT,
  NAMESPACE, SPECIAL-USE, ID; FETCH (ENVELOPE, BODYSTRUCTURE, BODY[section]<partial>),
  SEARCH, STORE, COPY/MOVE с COPYUID, APPEND с APPENDUID, EXPUNGE/UID EXPUNGE
- MimeTree: части письма по байтам — размеры BODYSTRUCTURE совпадают с BODY[]
- V4: UID на папку выдаёт триггер (перенос из веба тоже получает новый UID),
  флаги \Answered/\Deleted, message_sources — письмо как пришло (SMTP, APPEND)
  или собранное один раз (MimeSource); правка черновика сбрасывает его
- тесты: настоящий клиент Angus IMAP, сырые литералы/IDLE/подмешивание (47 всего)
- живая проверка: обновление V3 -> V4 на PostgreSQL с письмами, Python imaplib:
  STARTTLS, 993, папки, вложения, поиск, APPEND/COPY/MOVE/EXPUNGE, IDLE — 0 ошибок

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 20:45:06 +01:00
parent 73c03b1d6d
commit b72be91a8b
20 changed files with 3408 additions and 599 deletions
@@ -0,0 +1,129 @@
package com.mailserver.imap;
import com.mailserver.domain.Attachment;
import com.mailserver.domain.Message;
import jakarta.activation.DataHandler;
import jakarta.activation.FileDataSource;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
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 java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
/**
* MIME text of a message that has no original (written in the web client): built from the stored fields and
* attachment files. The boundary and Message-ID come from the message id, so the result is the same every time.
*/
public final class MimeSource {
private MimeSource() {
}
public static byte[] build(Message message, List<Attachment> attachments, String hostname)
throws MessagingException, IOException {
Properties props = new Properties();
props.put("mail.mime.splitlongparameters", "true");
Session session = Session.getInstance(props);
String messageId = message.getMessageId() == null || message.getMessageId().isBlank()
? message.getId() + "@" + hostname
: message.getMessageId().replaceAll("^<|>$", "");
if (!messageId.contains("@")) {
messageId = messageId + "@" + hostname;
}
String finalMessageId = "<" + messageId + ">";
MimeMessage mime = new MimeMessage(session) {
@Override
protected void updateMessageID() throws MessagingException {
setHeader("Message-ID", finalMessageId);
}
};
String from = message.getSenderEmail() == null ? "unknown@" + hostname : message.getSenderEmail();
mime.setFrom(new InternetAddress(from, message.getSenderName(), "UTF-8"));
List<InternetAddress> to = addresses(message.getRecipientEmail());
if (!to.isEmpty()) {
mime.setRecipients(jakarta.mail.Message.RecipientType.TO, to.toArray(new InternetAddress[0]));
}
List<InternetAddress> cc = addresses(message.getCcEmail());
if (!cc.isEmpty()) {
mime.setRecipients(jakarta.mail.Message.RecipientType.CC, cc.toArray(new InternetAddress[0]));
}
mime.setSubject(message.getSubject() == null ? "" : message.getSubject(), "UTF-8");
if (message.getReceivedAt() != null) {
mime.setSentDate(Date.from(message.getReceivedAt()));
}
String body = message.getBody() == null ? "" : message.getBody();
String subtype = message.isHtml() ? "html" : "plain";
if (attachments.isEmpty()) {
mime.setText(body, "UTF-8", subtype);
} else {
MimeMultipart mixed = new FixedBoundaryMultipart("mixed", "=_rms_" + message.getId());
MimeBodyPart text = new MimeBodyPart();
text.setText(body, "UTF-8", subtype);
mixed.addBodyPart(text);
for (Attachment attachment : attachments) {
Path file = Path.of(attachment.getFilePath());
if (!Files.isRegularFile(file)) {
continue;
}
MimeBodyPart part = new MimeBodyPart();
FileDataSource source = new FileDataSource(file.toFile()) {
@Override
public String getContentType() {
return attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType();
}
};
part.setDataHandler(new DataHandler(source));
part.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null));
part.setDisposition(attachment.getContentId() != null ? MimeBodyPart.INLINE : MimeBodyPart.ATTACHMENT);
if (attachment.getContentId() != null) {
part.setContentID("<" + attachment.getContentId().replaceAll("^<|>$", "") + ">");
}
mixed.addBodyPart(part);
}
mime.setContent(mixed);
}
mime.saveChanges();
ByteArrayOutputStream out = new ByteArrayOutputStream();
mime.writeTo(out);
return out.toByteArray();
}
private static List<InternetAddress> addresses(String list) {
List<InternetAddress> result = new ArrayList<>();
if (list == null || list.isBlank()) {
return result;
}
for (String one : list.split("[,;]")) {
String address = one.trim();
if (!address.isEmpty()) {
try {
result.add(new InternetAddress(address));
} catch (MessagingException ignored) {
// a broken address is left out, the message is still served
}
}
}
return result;
}
/** MimeMultipart with a given boundary instead of a random one. */
private static final class FixedBoundaryMultipart extends MimeMultipart {
FixedBoundaryMultipart(String subtype, String boundary) {
super(subtype);
this.contentType = "multipart/" + subtype + "; boundary=\"" + boundary + "\"";
}
}
}