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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user