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,345 @@
package com.mailserver.imap;
import com.mailserver.smtp.SmtpTls;
import jakarta.mail.BodyPart;
import jakarta.mail.Flags;
import jakarta.mail.Folder;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.Session;
import jakarta.mail.Store;
import jakarta.mail.UIDFolder;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.search.SubjectTerm;
import org.eclipse.angus.mail.imap.AppendUID;
import org.eclipse.angus.mail.imap.IMAPFolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
class ImapServerTest {
static final String PLAIN = """
From: Bob <bob@elsewhere.test>
To: alice@example.test
Subject: Hello Alice
Date: Thu, 05 Nov 2026 10:00:00 +0000
Message-ID: <hello@elsewhere.test>
Content-Type: text/plain; charset=utf-8
Hi there, line one.
Line two.
""";
static final String WITH_FILE = """
From: =?UTF-8?B?0JDQvdC90LA=?= <anna@elsewhere.test>
To: alice@example.test
Subject: =?UTF-8?B?0J7RgtGH0ZHRgg==?=
Date: Thu, 05 Nov 2026 11:00:00 +0000
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="b1"
preamble
--b1
Content-Type: text/plain; charset=utf-8
See the report.
--b1
Content-Type: application/pdf; name="report.pdf"
Content-Disposition: attachment; filename="report.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQgdGVzdA==
--b1--
""";
@TempDir
Path tlsDir;
private MemoryImapStore store;
private ImapServer server;
@BeforeEach
void start() throws Exception {
store = new MemoryImapStore();
store.deliver("INBOX", PLAIN);
store.deliver("INBOX", WITH_FILE);
server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false,
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000,
Duration.ofMinutes(5), Duration.ofMillis(200)), store);
server.start();
}
@AfterEach
void stop() {
server.stop();
}
private Store connect() throws Exception {
Properties props = new Properties();
props.put("mail.imap.host", "127.0.0.1");
props.put("mail.imap.port", String.valueOf(server.port()));
props.put("mail.imap.starttls.enable", "true");
props.put("mail.imap.starttls.required", "true");
props.put("mail.imap.ssl.trust", "*");
props.put("mail.imap.ssl.checkserveridentity", "false");
Store imap = Session.getInstance(props).getStore("imap");
imap.connect("alice@example.test", "secret");
return imap;
}
@Test
void realClientReadsFoldersMessagesAndAttachments() throws Exception {
try (Store imap = connect()) {
List<String> names = Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName).toList();
assertThat(names).contains("INBOX", "Drafts", "Sent", "Spam", "Trash");
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
assertThat(inbox.getMessageCount()).isEqualTo(2);
assertThat(inbox.getUnreadMessageCount()).isEqualTo(2);
Message first = inbox.getMessage(1);
assertThat(first.getSubject()).isEqualTo("Hello Alice");
assertThat(((InternetAddress) first.getFrom()[0]).getPersonal()).isEqualTo("Bob");
assertThat(first.getContent().toString()).contains("Line two.");
assertThat(first.isSet(Flags.Flag.SEEN)).isTrue(); // BODY[] without PEEK
Message second = inbox.getMessage(2);
assertThat(second.getSubject()).isEqualTo("Отчёт");
assertThat(((InternetAddress) second.getFrom()[0]).getPersonal()).isEqualTo("Анна");
Multipart parts = (Multipart) second.getContent(); // BODYSTRUCTURE, then BODY[1] and BODY[2]
assertThat(parts.getCount()).isEqualTo(2);
assertThat(parts.getBodyPart(0).getContent().toString()).contains("See the report.");
BodyPart file = parts.getBodyPart(1);
assertThat(file.getFileName()).isEqualTo("report.pdf");
assertThat(new String(file.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)).isEqualTo("%PDF-1.4 test");
assertThat(inbox.search(new SubjectTerm("hello"))).hasSize(1);
assertThat(inbox.getUID(first)).isEqualTo(1);
assertThat(inbox.getMessageByUID(2).getSubject()).isEqualTo("Отчёт");
}
}
@Test
void appendCopyMoveAndExpunge() throws Exception {
try (Store imap = connect()) {
IMAPFolder sent = (IMAPFolder) imap.getFolder("Sent");
MimeMessage outgoing = new MimeMessage(Session.getInstance(new Properties()));
outgoing.setFrom(new InternetAddress("alice@example.test"));
outgoing.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@elsewhere.test"));
outgoing.setSubject("Sent from Thunderbird");
outgoing.setText("body");
AppendUID[] appended = sent.appendUIDMessages(new Message[]{outgoing});
assertThat(appended[0].uid).isEqualTo(1);
assertThat(appended[0].uidvalidity).isEqualTo(store.box("Sent").uidValidity());
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
inbox.copyMessages(new Message[]{inbox.getMessage(1)}, imap.getFolder("Trash"));
inbox.moveMessages(new Message[]{inbox.getMessage(2)}, imap.getFolder("Spam"));
inbox.close(false); // JavaMail counts EXPUNGEd messages until the folder is opened again
inbox.open(Folder.READ_WRITE);
assertThat(inbox.getMessageCount()).isEqualTo(1);
inbox.getMessage(1).setFlag(Flags.Flag.DELETED, true);
inbox.expunge();
assertThat(inbox.getMessageCount()).isZero();
}
assertThat(store.entries(store.box("Trash").id())).hasSize(1);
assertThat(store.entries(store.box("Spam").id())).hasSize(1);
assertThat(store.entries(store.box("Sent").id())).hasSize(1);
}
@Test
void createRenameAndDeleteFolders() throws Exception {
try (Store imap = connect()) {
Folder projects = imap.getFolder("Проекты");
assertThat(projects.create(Folder.HOLDS_MESSAGES)).isTrue();
assertThat(Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName)).contains("Проекты");
assertThat(projects.renameTo(imap.getFolder("Archive"))).isTrue();
assertThat(imap.getFolder("Archive").delete(false)).isTrue();
assertThat(imap.getFolder("Archive").exists()).isFalse();
}
}
@Test
void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception {
try (Client client = new Client(server.port())) {
String greeting = client.read();
assertThat(greeting).contains("STARTTLS").contains("LOGINDISABLED").doesNotContain("AUTH=PLAIN");
assertThat(client.send("a LOGIN alice@example.test secret")).startsWith("a NO [PRIVACYREQUIRED]");
}
}
@Test
void literalsAndLiteralPlus() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
client.write("a LOGIN {18}\r\n");
assertThat(client.read()).startsWith("+ ");
client.write("alice@example.test {6+}\r\nsecret\r\n");
assertThat(client.read()).startsWith("a OK");
String message = "Subject: via literal+\r\n\r\nhi\r\n";
client.write("b APPEND Drafts (\\Seen) {" + message.length() + "+}\r\n" + message + "\r\n");
assertThat(client.read()).startsWith("b OK [APPENDUID");
}
assertThat(store.entries(store.box("Drafts").id())).hasSize(1);
}
@Test
void idleReportsNewMailUntilDone() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
client.send("a LOGIN alice@example.test secret");
client.untilTagged("b", "b SELECT INBOX");
client.write("c IDLE\r\n");
assertThat(client.read()).startsWith("+ idling");
store.deliver("INBOX", PLAIN);
assertThat(client.read()).isEqualTo("* 3 EXISTS");
client.write("DONE\r\n");
assertThat(client.read()).startsWith("c OK");
assertThat(client.send("d NOOP")).startsWith("d OK");
}
}
// CVE-2011-0411 class: commands sent in plain text right after STARTTLS must not run inside TLS
@Test
void plainTextAfterStartTlsIsNotExecuted() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.write("s STARTTLS\r\nx LOGIN alice@example.test secret\r\n");
assertThat(client.read()).startsWith("s OK");
String reply;
try {
client.startTls();
reply = client.read();
} catch (java.io.IOException closed) {
reply = null; // the server closed the connection — the expected outcome
}
assertThat(reply).isNull();
}
}
@Test
void sectionsOfANestedMessage() {
String raw = ("Subject: outer\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n"
+ "--x\r\nContent-Type: text/plain\r\n\r\nouter text\r\n"
+ "--x\r\nContent-Type: message/rfc822\r\n\r\nSubject: inner\r\nContent-Type: text/plain\r\n\r\ninner text\r\n"
+ "--x--\r\n");
MimeTree tree = MimeTree.parse(raw.getBytes(StandardCharsets.US_ASCII));
assertThat(text(ImapSession.section(tree, "1"))).isEqualTo("outer text");
assertThat(text(ImapSession.section(tree, "2.HEADER"))).isEqualTo("Subject: inner\r\nContent-Type: text/plain\r\n\r\n");
assertThat(text(ImapSession.section(tree, "2.TEXT"))).isEqualTo("inner text");
assertThat(text(ImapSession.section(tree, "2.1"))).isEqualTo("inner text");
assertThat(text(ImapSession.section(tree, "1.MIME"))).isEqualTo("Content-Type: text/plain\r\n\r\n");
assertThat(text(ImapSession.section(tree, "HEADER.FIELDS (SUBJECT)"))).isEqualTo("Subject: outer\r\n\r\n");
assertThat(text(ImapSession.section(tree, ""))).isEqualTo(raw);
}
@Test
void mailboxNamesUseModifiedUtf7() {
// the example of RFC 3501 5.1.3
assertThat(ImapWriter.encodeMailbox("~peter/mail/台北/日本語")).isEqualTo("~peter/mail/&U,BTFw-/&ZeVnLIqe-");
assertThat(ImapWriter.decodeMailbox("~peter/mail/&U,BTFw-/&ZeVnLIqe-")).isEqualTo("~peter/mail/台北/日本語");
assertThat(ImapWriter.decodeMailbox(ImapWriter.encodeMailbox("Проекты & план"))).isEqualTo("Проекты & план");
}
private static String text(byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
/** Line-oriented IMAP test client. */
static final class Client implements AutoCloseable {
private Socket socket;
private BufferedReader in;
private OutputStream out;
Client(int port) throws Exception {
socket = new Socket("127.0.0.1", port);
socket.setSoTimeout(5000);
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
out = socket.getOutputStream();
}
void write(String text) throws Exception {
out.write(text.getBytes(StandardCharsets.UTF_8));
out.flush();
}
String read() throws Exception {
return in.readLine();
}
String send(String line) throws Exception {
write(line + "\r\n");
return read();
}
/** Sends a command, returns all lines up to its tagged response. */
List<String> untilTagged(String tag, String line) throws Exception {
write(line + "\r\n");
List<String> lines = new ArrayList<>();
String next;
do {
next = read();
lines.add(next);
} while (next != null && !next.startsWith(tag + " "));
return lines;
}
void tls(String tag) throws Exception {
assertThat(send(tag + " STARTTLS")).startsWith(tag + " OK");
startTls();
}
void startTls() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}}, null);
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
ssl.setUseClientMode(true);
ssl.startHandshake();
socket = ssl;
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
out = ssl.getOutputStream();
}
@Override
public void close() throws Exception {
socket.close();
}
}
}
@@ -0,0 +1,172 @@
package com.mailserver.imap;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
/** In-memory {@link ImapStore}: one user alice@example.test / secret with the system folders. */
class MemoryImapStore implements ImapStore {
static final class Stored {
final long id;
volatile long folderId;
volatile long uid;
volatile boolean seen;
volatile boolean flagged;
volatile boolean answered;
volatile boolean deleted;
final Instant date;
final byte[] raw;
Stored(long id, long folderId, long uid, byte[] raw, Instant date) {
this.id = id;
this.folderId = folderId;
this.uid = uid;
this.raw = raw;
this.date = date;
}
Entry entry() {
return new Entry(id, uid, seen, flagged, answered, deleted, date);
}
}
private final AtomicLong ids = new AtomicLong(100);
final List<Mailbox> boxes = new CopyOnWriteArrayList<>();
final Map<Long, Long> uidNext = new ConcurrentHashMap<>();
final List<Stored> messages = new CopyOnWriteArrayList<>();
MemoryImapStore() {
for (String name : List.of("INBOX", "Drafts", "Sent", "Spam", "Trash")) {
addBox(name, true);
}
}
private Mailbox addBox(String name, boolean system) {
long id = ids.incrementAndGet();
uidNext.put(id, 1L);
Mailbox box = new Mailbox(id, name, system, 1700000000L + id, 1);
boxes.add(box);
return box;
}
Mailbox box(String name) {
return boxes.stream().filter(b -> b.name().equals(name)).findFirst().orElseThrow();
}
/** Delivers a message into a folder, as SMTP would. */
synchronized Stored deliver(String folder, String raw) {
Mailbox box = box(folder);
Stored stored = new Stored(ids.incrementAndGet(), box.id(), nextUid(box.id()),
raw.replace("\n", "\r\n").replace("\r\r\n", "\r\n").getBytes(), Instant.parse("2026-11-05T10:00:00Z"));
messages.add(stored);
return stored;
}
private synchronized long nextUid(long folderId) {
long uid = uidNext.get(folderId);
uidNext.put(folderId, uid + 1);
return uid;
}
@Override
public Account authenticate(String username, String password) {
return "alice@example.test".equalsIgnoreCase(username) && "secret".equals(password)
? new Account(1, "alice@example.test") : null;
}
@Override
public List<Mailbox> mailboxes(long userId) {
return boxes.stream().map(b -> new Mailbox(b.id(), b.name(), b.system(), b.uidValidity(), uidNext.get(b.id()))).toList();
}
@Override
public synchronized Mailbox create(long userId, String name) {
return addBox(name, false);
}
@Override
public synchronized void rename(long userId, Mailbox mailbox, String newName) {
boxes.removeIf(b -> b.id() == mailbox.id());
boxes.add(new Mailbox(mailbox.id(), newName, false, mailbox.uidValidity(), mailbox.uidNext()));
}
@Override
public synchronized void delete(long userId, Mailbox mailbox) {
Mailbox trash = box("Trash");
move(userId, entries(mailbox.id()), trash);
boxes.removeIf(b -> b.id() == mailbox.id());
}
@Override
public List<Entry> entries(long folderId) {
return messages.stream().filter(m -> m.folderId == folderId)
.sorted((a, b) -> Long.compare(a.uid, b.uid)).map(Stored::entry).toList();
}
@Override
public byte[] source(long messageId) {
return find(messageId).raw;
}
private Stored find(long id) {
return messages.stream().filter(m -> m.id == id).findFirst().orElseThrow();
}
@Override
public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) {
Stored m = find(messageId);
m.seen = seen;
m.flagged = flagged;
m.answered = answered;
m.deleted = deleted;
}
@Override
public synchronized long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged,
boolean answered, boolean deleted, Instant internalDate) {
Stored stored = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), raw,
internalDate == null ? Instant.now() : internalDate);
stored.seen = seen;
stored.flagged = flagged;
stored.answered = answered;
stored.deleted = deleted;
messages.add(stored);
return stored.uid;
}
@Override
public synchronized List<Long> copy(long userId, List<Entry> list, Mailbox target) {
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Stored from = find(entry.id());
Stored copy = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), from.raw, from.date);
copy.seen = from.seen;
copy.flagged = from.flagged;
messages.add(copy);
uids.add(copy.uid);
}
return uids;
}
@Override
public synchronized List<Long> move(long userId, List<Entry> list, Mailbox target) {
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Stored m = find(entry.id());
m.folderId = target.id();
m.uid = nextUid(target.id());
uids.add(m.uid);
}
return uids;
}
@Override
public void expunge(List<Long> messageIds) {
messages.removeIf(m -> messageIds.contains(m.id));
}
}