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:
@@ -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 + "\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user