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
@@ -38,9 +38,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
private final SpfService spfService;
private final DmarcService dmarcService;
private final String hostname;
private final MessageSources sources;
public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService,
DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname) {
DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
MessageSources sources) {
this.sources = sources;
this.mailService = mailService;
this.dkimService = dkimService;
this.spfService = spfService;
@@ -82,11 +85,13 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
for (FilePart file : attachments(mime)) {
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
}
// IMAP serves the message exactly as it arrived
sources.put(stored.getId(), raw).block();
}
}
/** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */
static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) {
public static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) {
try {
jakarta.mail.Address[] list = mime.getRecipients(type);
if (list == null || list.length == 0) {
@@ -103,11 +108,11 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
}
}
record FilePart(String name, String contentType, byte[] bytes) {
public record FilePart(String name, String contentType, byte[] bytes) {
}
/** Parts with a file name (attachments and inline images), nested multiparts walked. */
static List<FilePart> attachments(Part part) throws MessagingException, IOException {
public static List<FilePart> attachments(Part part) throws MessagingException, IOException {
List<FilePart> files = new ArrayList<>();
collect(part, files);
return files;
@@ -144,11 +149,11 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
}
}
record Body(String text, boolean html) {
public record Body(String text, boolean html) {
}
/** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */
static Body body(Part part) throws MessagingException, IOException {
public static Body body(Part part) throws MessagingException, IOException {
String html = find(part, "text/html");
if (html != null) {
return new Body(html, true);
@@ -59,7 +59,8 @@ public class MailService {
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
UserRepository userRepository, AttachmentRepository attachmentRepository,
ContactRepository contactRepository,
SpamFilterService spamFilterService) {
SpamFilterService spamFilterService, MessageSources sources) {
this.sources = sources;
this.messageRepository = messageRepository;
this.folderRepository = folderRepository;
this.userRepository = userRepository;
@@ -69,6 +70,8 @@ public class MailService {
}
/** System folders in the order the web client shows them; the rest — own folders, by name. */
private final MessageSources sources;
public static final List<String> SYSTEM_FOLDERS = List.of("INBOX", "Drafts", "Sent", "Spam", "Trash");
/** Messages per folder in one list — the web client sorts and pages them itself. */
@@ -212,6 +215,8 @@ public class MailService {
message.setMessageSize((long) (subject.length() + body.length()));
return messageRepository.save(message);
})
// IMAP: the draft changed — its MIME text is built anew
.flatMap(saved -> sources.remove(saved.getId()).thenReturn(saved))
.flatMap(this::withAttachmentCount);
}
@@ -226,6 +231,7 @@ public class MailService {
/** After sending: the draft's attachments move to the copy in Sent, the draft itself is removed. */
public Mono<Void> finishDraft(Long draftId, Long sentMessageId) {
return attachmentRepository.moveAll(draftId, sentMessageId)
.then(sources.remove(sentMessageId))
.then(messageRepository.deleteById(draftId));
}
@@ -467,7 +473,7 @@ public class MailService {
attachment.setContentType(contentType);
attachment.setSizeBytes(sizeBytes);
attachment.setFilePath(filePath);
return attachmentRepository.save(attachment);
return sources.remove(messageId).then(attachmentRepository.save(attachment));
})
.map(this::toAttachmentResponse);
}
@@ -491,7 +497,7 @@ public class MailService {
return ownedAttachment(attachmentId, userId)
.flatMap(attachment -> {
deleteFile(attachment.getFilePath());
return attachmentRepository.delete(attachment);
return sources.remove(attachment.getMessageId()).then(attachmentRepository.delete(attachment));
});
}
@@ -0,0 +1,38 @@
package com.mailserver.service;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
/**
* The message exactly as received or appended (table message_sources). IMAP serves it as BODY[]; a message without
* one gets its MIME text built once ({@link com.mailserver.imap.MimeSource}). A changed draft loses it.
*/
@Component
public class MessageSources {
private final DatabaseClient db;
public MessageSources(DatabaseClient db) {
this.db = db;
}
public Mono<byte[]> get(Long messageId) {
return db.sql("SELECT raw FROM message_sources WHERE message_id = :id")
.bind("id", messageId)
.map(row -> row.get("raw", byte[].class))
.one();
}
public Mono<Void> put(Long messageId, byte[] raw) {
return db.sql("INSERT INTO message_sources (message_id, raw) VALUES (:id, :raw) "
+ "ON CONFLICT (message_id) DO UPDATE SET raw = EXCLUDED.raw")
.bind("id", messageId)
.bind("raw", raw)
.then();
}
public Mono<Void> remove(Long messageId) {
return db.sql("DELETE FROM message_sources WHERE message_id = :id").bind("id", messageId).then();
}
}