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