Старый 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>
130 lines
5.4 KiB
Java
130 lines
5.4 KiB
Java
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 + "\"";
|
|
}
|
|
}
|
|
}
|