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 com.mailserver.security.LoginGuard; 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 To: alice@example.test Subject: Hello Alice Date: Thu, 05 Nov 2026 10:00:00 +0000 Message-ID: Content-Type: text/plain; charset=utf-8 Hi there, line one. Line two. """; static final String WITH_FILE = """ From: =?UTF-8?B?0JDQvdC90LA=?= 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 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 wrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() throws Exception { server.stop(); LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 4, 100, Duration.ofMinutes(15), Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), java.util.Set.of(), Duration.ZERO)); 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, guard); server.start(); try (Client client = new Client(server.port())) { client.read(); client.tls("s"); assertThat(client.send("a LOGIN alice@example.test w1")).startsWith("a NO [AUTHENTICATIONFAILED]"); assertThat(client.send("b LOGIN alice@example.test w2")).startsWith("b NO [AUTHENTICATIONFAILED]"); assertThat(client.untilTagged("c", "c LOGIN alice@example.test w3")).contains("* BYE Too many failed logins"); assertThat(client.read()).isNull(); } try (Client client = new Client(server.port())) { client.read(); client.tls("s"); assertThat(client.send("a LOGIN alice@example.test w4")).startsWith("a NO [AUTHENTICATIONFAILED]"); assertThat(client.untilTagged("b", "b LOGIN alice@example.test secret")) .anyMatch(line -> line.startsWith("* BYE Too many failed logins, try again in 15 min")) .anyMatch(line -> line.startsWith("b NO [UNAVAILABLE]")); } } @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 untilTagged(String tag, String line) throws Exception { write(line + "\r\n"); List 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(); } } }