summaryrefslogtreecommitdiff
path: root/mail/protocols/imap/src/test/java
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /mail/protocols/imap/src/test/java
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'mail/protocols/imap/src/test/java')
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/AlertResponseTest.java66
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/CapabilityResponseTest.java124
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt29
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt57
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.kt79
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapListTest.java142
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseHelper.java38
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.kt521
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapUtilityTest.java146
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ListResponseTest.java99
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/NamespaceResponseTest.java103
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponseTest.java109
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt1136
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderIdlerTest.kt344
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt1235
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt446
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractorTest.java38
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseTextExtractorTest.kt45
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SearchResponseTest.java89
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponseTest.java96
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt21
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestIdleRefreshManager.kt35
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapConnection.kt135
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapFolder.kt126
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapStore.kt24
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestWakeLock.kt47
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java139
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java38
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidValidityResponseTest.kt63
-rw-r--r--mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/mockserver/MockImapServer.java420
30 files changed, 5990 insertions, 0 deletions
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/AlertResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/AlertResponseTest.java
new file mode 100644
index 0000000..3617dea
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/AlertResponseTest.java
@@ -0,0 +1,66 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import org.junit.Test;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+
+public class AlertResponseTest {
+
+ @Test
+ public void getAlertText_withProperAlertResponse() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO [ALERT] Please don't do that");
+
+ String result = AlertResponse.getAlertText(imapResponse);
+
+ assertEquals("Please don't do that", result);
+ }
+
+ @Test
+ public void getAlertText_withoutResponseCodeText_shouldReturnNull() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO");
+
+ String result = AlertResponse.getAlertText(imapResponse);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void getAlertText_withoutAlertText_shouldReturnNull() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO [ALERT]");
+
+ String result = AlertResponse.getAlertText(imapResponse);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void getAlertText_withoutResponseCodeTextList_shouldReturnNull() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO ALERT ALARM!");
+
+ String result = AlertResponse.getAlertText(imapResponse);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void getAlertText_withResponseCodeTextContainingTooManyItems_shouldReturnNull() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO [ALERT SOMETHING] ALARM!");
+
+ String result = AlertResponse.getAlertText(imapResponse);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void getAlertText_withWrongResponseCodeText_shouldReturnNull() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO [ALARM] ALERT!");
+
+ String result = AlertResponse.getAlertText(imapResponse);
+
+ assertNull(result);
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/CapabilityResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/CapabilityResponseTest.java
new file mode 100644
index 0000000..e82670c
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/CapabilityResponseTest.java
@@ -0,0 +1,124 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.Test;
+import org.mockito.internal.util.collections.Sets;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+
+public class CapabilityResponseTest {
+
+ @Test
+ public void parse_withProperResponseContainingCapabilityCode() throws Exception {
+ CapabilityResponse result = parse("* OK [CAPABILITY IMAP4rev1 IDLE] Welcome");
+
+ assertNotNull(result);
+ assertEquals(Sets.newSet("IMAP4REV1", "IDLE"), result.getCapabilities());
+ }
+
+ @Test
+ public void parse_withTaggedResponse_shouldReturnNull() throws Exception {
+ CapabilityResponse result = parse("1 OK");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutOkResponse_shouldReturnNull() throws Exception {
+ CapabilityResponse result = parse("* BAD Go Away");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withOkResponseWithoutList_shouldReturnNull() throws Exception {
+ CapabilityResponse result = parse("* OK Welcome");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withProperCapabilityResponse() throws Exception {
+ ImapList list = createImapResponse("* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI XPIG-LATIN");
+
+ CapabilityResponse result = CapabilityResponse.parse(list);
+
+ assertNotNull(result);
+ assertEquals(Sets.newSet("IMAP4REV1", "STARTTLS", "AUTH=GSSAPI", "XPIG-LATIN"), result.getCapabilities());
+ }
+
+ @Test
+ public void parse_withListInCapabilityResponse_shouldReturnNull() throws Exception {
+ ImapList list = createImapResponse("* CAPABILITY IMAP4rev1 []");
+
+ CapabilityResponse result = CapabilityResponse.parse(list);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutCapabilityResponse_shouldReturnNull() throws Exception {
+ ImapList list = createImapResponse("* EXISTS 1");
+
+ CapabilityResponse result = CapabilityResponse.parse(list);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withEmptyResponseList_shouldReturnNull() throws Exception {
+ List<ImapResponse> responses = Collections.emptyList();
+
+ CapabilityResponse result = CapabilityResponse.parse(responses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutCapabilityResponseInResponseList_shouldReturnNull() throws Exception {
+ List<ImapResponse> responses = Collections.singletonList(createImapResponse("* EXISTS 42"));
+
+ CapabilityResponse result = CapabilityResponse.parse(responses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withSingleCapabilityResponseInResponseList() throws Exception {
+ ImapResponse response = createImapResponse("* CAPABILITY IMAP4rev1 LOGINDISABLED STARTTLS");
+ List<ImapResponse> responses = Collections.singletonList(response);
+
+ CapabilityResponse result = CapabilityResponse.parse(responses);
+
+ assertNotNull(result);
+ assertEquals(Sets.newSet("IMAP4REV1", "STARTTLS", "LOGINDISABLED"), result.getCapabilities());
+ }
+
+ @Test
+ public void parse_withCapabilityResponseInResponseList() throws Exception {
+ ImapResponse responseOne = createImapResponse("* EXPUNGE 4");
+ ImapResponse responseTwo = createImapResponse("* CAPABILITY IMAP4rev1 IDLE");
+ List<ImapResponse> responses = Arrays.asList(responseOne, responseTwo);
+
+ CapabilityResponse result = CapabilityResponse.parse(responses);
+
+ assertNotNull(result);
+ assertEquals(Sets.newSet("IMAP4REV1", "IDLE"), result.getCapabilities());
+ }
+
+ private CapabilityResponse parse(String responseText) throws IOException {
+ ImapResponse response = createImapResponse(responseText);
+ List<ImapResponse> responses = Collections.singletonList(response);
+
+ return CapabilityResponse.parse(responses);
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt
new file mode 100644
index 0000000..3ca1a4e
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt
@@ -0,0 +1,29 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import org.junit.Test
+
+class FolderNameCodecTest {
+ private var folderNameCode = FolderNameCodec()
+
+ @Test
+ fun `encode() with ASCII argument should return input`() {
+ assertThat(folderNameCode.encode("ASCII")).isEqualTo("ASCII")
+ }
+
+ @Test
+ fun `encode() with non-ASCII argument should return encoded string`() {
+ assertThat(folderNameCode.encode("über")).isEqualTo("&APw-ber")
+ }
+
+ @Test
+ fun `decode() with encoded argument should return decoded string`() {
+ assertThat(folderNameCode.decode("&ANw-bergr&APYA3w-entr&AOQ-ger")).isEqualTo("Übergrößenträger")
+ }
+
+ @Test(expected = CharacterCodingException::class)
+ fun `decode() with invalid encoded argument should throw`() {
+ folderNameCode.decode("&12-foo")
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt
new file mode 100644
index 0000000..f225cac
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt
@@ -0,0 +1,57 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.containsExactlyInAnyOrder
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import org.junit.Test
+
+class IdGrouperTest {
+ @Test
+ fun `groupIds() with single contiguous group`() {
+ val ids = setOf(1L, 2L, 3L)
+
+ val groupedIds = IdGrouper.groupIds(ids)
+
+ assertThat(groupedIds.ids).isEmpty()
+ assertThat(groupedIds.idGroups.mapToString()).containsExactly("1:3")
+ }
+
+ @Test
+ fun `groupIds() without contiguous group`() {
+ val ids = setOf(23L, 42L, 2L, 5L)
+
+ val groupedIds = IdGrouper.groupIds(ids)
+
+ assertThat(groupedIds.ids).isEqualTo(ids)
+ assertThat(groupedIds.idGroups).isEmpty()
+ }
+
+ @Test
+ fun `groupIds() with multiple contiguous groups`() {
+ val ids = setOf(1L, 3L, 4L, 5L, 6L, 10L, 12L, 13L, 14L, 23L)
+
+ val groupedIds = IdGrouper.groupIds(ids)
+
+ assertThat(groupedIds.ids).containsExactlyInAnyOrder(1L, 10L, 23L)
+ assertThat(groupedIds.idGroups.mapToString()).containsExactly("3:6", "12:14")
+ }
+
+ @Test
+ fun `groupIds() with single ID`() {
+ val ids = setOf(23L)
+
+ val groupedIds = IdGrouper.groupIds(ids)
+
+ assertThat(groupedIds.ids).containsExactlyInAnyOrder(23L)
+ assertThat(groupedIds.idGroups).isEmpty()
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `groupIds() with empty set should throw`() {
+ IdGrouper.groupIds(emptySet())
+ }
+}
+
+private fun <T> List<T>.mapToString() = map { it.toString() }
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.kt
new file mode 100644
index 0000000..f1da44f
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.kt
@@ -0,0 +1,79 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isLessThanOrEqualTo
+import java.util.TreeSet
+import org.junit.Test
+
+class ImapCommandSplitterTest {
+
+ @Test
+ fun splitCommand_withManyNonContiguousIds_shouldSplitCommand() {
+ val ids = ImapResponseHelper.createNonContiguousIdSet(10000, 10500, 2)
+ val groupedIds = GroupedIds(ids, emptyList())
+
+ val commands = ImapCommandSplitter.splitCommand(COMMAND_PREFIX, COMMAND_SUFFIX, groupedIds, 980)
+
+ assertThat(commands.size).isEqualTo(2)
+ assertCommandLengthLimit(commands, 980)
+ verifyCommandString(commands[0], ImapResponseHelper.createNonContiguousIdSet(10000, 10316, 2))
+ verifyCommandString(commands[1], ImapResponseHelper.createNonContiguousIdSet(10318, 10500, 2))
+ }
+
+ @Test
+ fun splitCommand_withContiguousAndNonContiguousIds_shouldGroupIdsAndSplitCommand() {
+ val idSet: Set<Long> = ImapResponseHelper.createNonContiguousIdSet(10000, 10298, 2) +
+ ImapResponseHelper.createNonContiguousIdSet(10402, 10500, 2)
+ val idGroups = listOf(ContiguousIdGroup(10300L, 10400L))
+ val groupedIds = GroupedIds(idSet, idGroups)
+
+ val commands = ImapCommandSplitter.splitCommand(COMMAND_PREFIX, COMMAND_SUFFIX, groupedIds, 980)
+
+ assertThat(commands.size).isEqualTo(2)
+ assertCommandLengthLimit(commands, 980)
+ verifyCommandString(
+ commands[0],
+ ImapResponseHelper.createNonContiguousIdSet(10000, 10298, 2) +
+ ImapResponseHelper.createNonContiguousIdSet(10402, 10418, 2),
+ )
+ verifyCommandString(commands[1], ImapResponseHelper.createNonContiguousIdSet(10420, 10500, 2), "10300:10400")
+ }
+
+ @Test
+ fun splitCommand_withEmptySuffix_shouldCreateCommandWithoutTrailingSpace() {
+ val ids = ImapResponseHelper.createNonContiguousIdSet(1, 2, 1)
+ val groupedIds = GroupedIds(ids, emptyList())
+
+ val commands = ImapCommandSplitter.splitCommand("UID SEARCH UID", "", groupedIds, 980)
+
+ assertThat(commands.size).isEqualTo(1)
+ assertThat(commands[0]).isEqualTo("UID SEARCH UID 1,2")
+ }
+
+ private fun assertCommandLengthLimit(commands: List<String>, lengthLimit: Int) {
+ for (command in commands) {
+ assertThat(command.length, "Command is too long").isLessThanOrEqualTo(lengthLimit)
+ }
+ }
+
+ private fun verifyCommandString(actualCommand: String, ids: Set<Long>, idGroupString: String? = null) {
+ val sortedIds: Set<Long> = TreeSet(ids)
+ val expectedCommandBuilder = StringBuilder(COMMAND_PREFIX)
+ .append(" ")
+ .append(ImapUtility.join(",", sortedIds))
+ if (idGroupString != null) {
+ expectedCommandBuilder.append(',').append(idGroupString)
+ }
+ expectedCommandBuilder.append(" ").append(COMMAND_SUFFIX)
+
+ val expectedCommand = expectedCommandBuilder.toString()
+
+ assertThat(actualCommand).isEqualTo(expectedCommand)
+ }
+
+ companion object {
+ private const val COMMAND_PREFIX = "UID COPY"
+ private const val COMMAND_SUFFIX = "\"Destination\""
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapListTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapListTest.java
new file mode 100644
index 0000000..ca9d5bd
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapListTest.java
@@ -0,0 +1,142 @@
+package com.fsck.k9.mail.store.imap;
+
+import com.fsck.k9.mail.MessagingException;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Calendar;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+
+public class ImapListTest {
+
+ private ImapList buildSampleList() {
+ ImapList list = new ImapList();
+ list.add("ONE");
+ list.add("TWO");
+ list.add("THREE");
+ return list;
+ }
+
+ @Test public void containsKey_returnsTrueForKeys() throws IOException {
+ ImapList list = buildSampleList();
+
+ assertTrue(list.containsKey("ONE"));
+ assertTrue(list.containsKey("TWO"));
+ assertFalse(list.containsKey("THREE"));
+ assertFalse(list.containsKey("nonexistent"));
+ }
+
+ @Test public void containsKey_returnsFalseForStringThatCantBeKey() throws IOException {
+ ImapList list = buildSampleList();
+
+ assertFalse(list.containsKey("THREE"));
+ }
+
+ @Test public void containsKey_returnsFalseForStringNotInList() throws IOException {
+ ImapList list = buildSampleList();
+
+ assertFalse(list.containsKey("nonexistent"));
+ }
+
+ @Test
+ public void getKeyedValue_providesCorrespondingValues() {
+ ImapList list = buildSampleList();
+
+ assertEquals("TWO", list.getKeyedValue("ONE"));
+ assertEquals("THREE", list.getKeyedValue("TWO"));
+ assertNull(list.getKeyedValue("THREE"));
+ assertNull(list.getKeyedValue("nonexistent"));
+ }
+
+ @Test
+ public void getKeyIndex_providesIndexForKeys() {
+ ImapList list = buildSampleList();
+
+ assertEquals(0, list.getKeyIndex("ONE"));
+ assertEquals(1, list.getKeyIndex("TWO"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void getKeyIndex_throwsExceptionForValue() {
+ ImapList list = buildSampleList();
+
+ list.getKeyIndex("THREE");
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void getKeyIndex_throwsExceptionForNonExistantKey() {
+ ImapList list = buildSampleList();
+
+ list.getKeyIndex("nonexistent");
+ }
+
+ @Test
+ public void getDate_returnsCorrectDateForValidString() throws MessagingException {
+ ImapList list = new ImapList();
+ list.add("INTERNALDATE");
+ list.add("10-Mar-2000 12:02:01 GMT");
+
+ Calendar c = Calendar.getInstance();
+ c.setTime(list.getDate(1));
+
+ assertEquals(2000, c.get(Calendar.YEAR));
+ assertEquals(Calendar.MARCH, c.get(Calendar.MONTH));
+ assertEquals(10, c.get(Calendar.DAY_OF_MONTH));
+ }
+
+ @Test(expected = MessagingException.class)
+ public void getDate_throwsExceptionForInvalidDate() throws MessagingException {
+ ImapList list = new ImapList();
+ list.add("INTERNALDATE");
+ list.add("InvalidDate");
+
+ list.getDate(1);
+ }
+
+ @Test
+ public void getDate_returnsNullForNIL() throws MessagingException {
+ ImapList list = new ImapList();
+ list.add("INTERNALDATE");
+ list.add("NIL");
+
+ assertNull(list.getDate(1));
+ }
+
+ @Test
+ public void getKeyedDate_returnsCorrectDateForValidString() throws MessagingException {
+ ImapList list = new ImapList();
+ list.add("INTERNALDATE");
+ list.add("10-Mar-2000 12:02:01 GMT");
+
+ Calendar c = Calendar.getInstance();
+ c.setTime(list.getKeyedDate("INTERNALDATE"));
+
+ assertEquals(2000, c.get(Calendar.YEAR));
+ assertEquals(Calendar.MARCH, c.get(Calendar.MONTH));
+ assertEquals(10, c.get(Calendar.DAY_OF_MONTH));
+ }
+
+ @Test(expected = MessagingException.class)
+ public void getKeyedDate_throwsExceptionForInvalidDate() throws MessagingException {
+ ImapList list = new ImapList();
+ list.add("INTERNALDATE");
+ list.add("InvalidDate");
+
+ list.getKeyedDate("INTERNALDATE");
+ }
+
+ @Test
+ public void getKeyedDate_returnsNullForNIL() throws MessagingException {
+ ImapList list = new ImapList();
+ list.add("INTERNALDATE");
+ list.add("NIL");
+
+ assertNull(list.getKeyedDate("INTERNALDATE"));
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseHelper.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseHelper.java
new file mode 100644
index 0000000..5eb48ec
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseHelper.java
@@ -0,0 +1,38 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.fsck.k9.mail.filter.PeekableInputStream;
+
+
+public class ImapResponseHelper {
+ public static List<ImapResponse> createImapResponseList(String... responses) throws IOException {
+ List<ImapResponse> imapResponses = new ArrayList<>();
+ for (String response : responses) {
+ imapResponses.add(createImapResponse(response));
+ }
+ return imapResponses;
+ }
+
+ public static ImapResponse createImapResponse(String response) throws IOException {
+ String input = response + "\r\n";
+ PeekableInputStream inputStream = new PeekableInputStream(new ByteArrayInputStream(input.getBytes()));
+ ImapResponseParser parser = new ImapResponseParser(inputStream);
+
+ return parser.readResponse();
+ }
+
+ public static Set<Long> createNonContiguousIdSet(long start, long end, int interval) {
+ Set<Long> ids = new HashSet<>();
+ for (long i = start;i <= end;i += interval) {
+ ids.add(i);
+ }
+ return ids;
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.kt
new file mode 100644
index 0000000..dfaa75b
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.kt
@@ -0,0 +1,521 @@
+package com.fsck.k9.mail.store.imap
+
+import com.fsck.k9.mail.filter.FixedLengthInputStream
+import com.fsck.k9.mail.filter.PeekableInputStream
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import org.junit.Assert
+import org.junit.Test
+
+class ImapResponseParserTest {
+ private var peekableInputStream: PeekableInputStream? = null
+ @Test
+ @Throws(IOException::class)
+ fun testSimpleOkResponse() {
+ val parser = createParser("* OK\r\n")
+ val response = parser.readResponse()
+ Assert.assertNotNull(response)
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("OK", response[0])
+ }
+
+ @Test
+ @Throws(IOException::class)
+ fun testOkResponseWithText() {
+ val parser = createParser("* OK Some text here\r\n")
+ val response = parser.readResponse()
+ Assert.assertNotNull(response)
+ Assert.assertEquals(2, response.size.toLong())
+ Assert.assertEquals("OK", response[0])
+ Assert.assertEquals("Some text here", response[1])
+ }
+
+ @Test
+ @Throws(IOException::class)
+ fun testOkResponseWithRespTextCode() {
+ val parser = createParser("* OK [UIDVALIDITY 3857529045]\r\n")
+ val response = parser.readResponse()
+ Assert.assertNotNull(response)
+ Assert.assertEquals(2, response.size.toLong())
+ Assert.assertEquals("OK", response[0])
+ Assert.assertTrue(response[1] is ImapList)
+ val respTextCode = response[1] as ImapList
+ Assert.assertEquals(2, respTextCode.size.toLong())
+ Assert.assertEquals("UIDVALIDITY", respTextCode[0])
+ Assert.assertEquals("3857529045", respTextCode[1])
+ }
+
+ @Test
+ @Throws(IOException::class)
+ fun testOkResponseWithRespTextCodeAndText() {
+ val parser = createParser("* OK [token1 token2] {x} test [...]\r\n")
+ val response = parser.readResponse()
+ Assert.assertNotNull(response)
+ Assert.assertEquals(3, response.size.toLong())
+ Assert.assertEquals("OK", response[0])
+ Assert.assertTrue(response[1] is ImapList)
+ Assert.assertEquals("{x} test [...]", response[2])
+ val respTextCode = response[1] as ImapList
+ Assert.assertEquals(2, respTextCode.size.toLong())
+ Assert.assertEquals("token1", respTextCode[0])
+ Assert.assertEquals("token2", respTextCode[1])
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testReadStatusResponseWithOKResponse() {
+ val parser = createParser(
+ "* COMMAND BAR\tBAZ\r\n" +
+ "TAG OK COMMAND completed\r\n"
+ )
+ val responses = parser.readStatusResponse("TAG", null, null, null)
+ Assert.assertEquals(2, responses.size.toLong())
+ Assert.assertEquals(mutableListOf("COMMAND", "BAR", "BAZ"), responses[0])
+ Assert.assertEquals(mutableListOf("OK", "COMMAND completed"), responses[1])
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testReadStatusResponseUntaggedHandlerGetsUntaggedOnly() {
+ val parser = createParser(
+ """
+ * UNTAGGED
+ A2 OK COMMAND completed
+
+ """.trimIndent()
+ )
+ val untaggedHandler = TestUntaggedHandler()
+ parser.readStatusResponse("A2", null, null, untaggedHandler)
+ Assert.assertEquals(1, untaggedHandler.responses.size.toLong())
+ Assert.assertEquals(mutableListOf("UNTAGGED"), untaggedHandler.responses[0])
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testReadStatusResponseSkippingWrongTag() {
+ val parser = createParser(
+ """
+ * UNTAGGED
+ * 0 EXPUNGE
+ * 42 EXISTS
+ A1 COMMAND BAR BAZ
+ A2 OK COMMAND completed
+
+ """.trimIndent()
+ )
+ val untaggedHandler = TestUntaggedHandler()
+ val responses = parser.readStatusResponse("A2", null, null, untaggedHandler)
+ Assert.assertEquals(3, responses.size.toLong())
+ Assert.assertEquals(mutableListOf("0", "EXPUNGE"), responses[0])
+ Assert.assertEquals(mutableListOf("42", "EXISTS"), responses[1])
+ Assert.assertEquals(mutableListOf("OK", "COMMAND completed"), responses[2])
+ Assert.assertEquals(mutableListOf("UNTAGGED"), untaggedHandler.responses[0])
+ Assert.assertEquals(responses[0], untaggedHandler.responses[1])
+ Assert.assertEquals(responses[1], untaggedHandler.responses[2])
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testReadStatusResponseUntaggedHandlerStillCalledOnNegativeReply() {
+ val parser = createParser(
+ """
+ + text
+ A2 NO Bad response
+
+ """.trimIndent()
+ )
+ val untaggedHandler = TestUntaggedHandler()
+ try {
+ val responses = parser.readStatusResponse("A2", null, null, untaggedHandler)
+ } catch (e: NegativeImapResponseException) {
+ }
+ Assert.assertEquals(1, untaggedHandler.responses.size.toLong())
+ Assert.assertEquals(mutableListOf("text"), untaggedHandler.responses[0])
+ }
+
+ @Test(expected = NegativeImapResponseException::class)
+ @Throws(Exception::class)
+ fun testReadStatusResponseWithErrorResponse() {
+ val parser = createParser("* COMMAND BAR BAZ\r\nTAG ERROR COMMAND errored\r\n")
+ parser.readStatusResponse("TAG", null, null, null)
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testRespTextCodeWithList() {
+ val parser = createParser(
+ """
+ * OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft NonJunk ${"$"}MDNSent \*)] Flags permitted.
+
+ """.trimIndent()
+ )
+ val response = parser.readResponse()
+ Assert.assertEquals(3, response.size.toLong())
+ Assert.assertTrue(response[1] is ImapList)
+ Assert.assertEquals(2, response.getList(1).size.toLong())
+ Assert.assertEquals("PERMANENTFLAGS", response.getList(1).getString(0))
+ Assert.assertTrue(response.getList(1)[1] is ImapList)
+ Assert.assertEquals("\\Answered", response.getList(1).getList(1).getString(0))
+ Assert.assertEquals("\\Flagged", response.getList(1).getList(1).getString(1))
+ Assert.assertEquals("\\Deleted", response.getList(1).getList(1).getString(2))
+ Assert.assertEquals("\\Seen", response.getList(1).getList(1).getString(3))
+ Assert.assertEquals("\\Draft", response.getList(1).getList(1).getString(4))
+ Assert.assertEquals("NonJunk", response.getList(1).getList(1).getString(5))
+ Assert.assertEquals("\$MDNSent", response.getList(1).getList(1).getString(6))
+ Assert.assertEquals("\\*", response.getList(1).getList(1).getString(7))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testExistsResponse() {
+ val parser = createParser("* 23 EXISTS\r\n")
+ val response = parser.readResponse()
+ Assert.assertEquals(2, response.size.toLong())
+ Assert.assertEquals(23, response.getNumber(0).toLong())
+ Assert.assertEquals("EXISTS", response.getString(1))
+ }
+
+ @Test(expected = IOException::class)
+ @Throws(IOException::class)
+ fun testReadStringUntilEndOfStream() {
+ val parser = createParser("* OK Some text ")
+ parser.readResponse()
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testCommandContinuation() {
+ val parser = createParser("+ Ready for additional command text\r\n")
+ val response = parser.readResponse()
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("Ready for additional command text", response.getString(0))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testParseLiteral() {
+ val parser = createParser("* {4}\r\ntest\r\n")
+ val response = parser.readResponse()
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("test", response.getString(0))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testParseLiteralWithEmptyString() {
+ val parser = createParser("* {0}\r\n\r\n")
+ val response = parser.readResponse()
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("", response.getString(0))
+ }
+
+ @Test(expected = IOException::class)
+ @Throws(Exception::class)
+ fun testParseLiteralToEndOfStream() {
+ val parser = createParser("* {4}\r\nabc")
+ parser.readResponse()
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testParseLiteralWithConsumingCallbackReturningNull() {
+ val parser = createParser("* {4}\r\ntest\r\n")
+ val callback = TestImapResponseCallback.readBytesAndReturn(4, "cheeseburger")
+ val response = parser.readResponse(callback)
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("cheeseburger", response.getString(0))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testParseLiteralWithNonConsumingCallbackReturningNull() {
+ val parser = createParser("* {4}\r\ntest\r\n")
+ val callback = TestImapResponseCallback.readBytesAndReturn(0, null)
+ val response = parser.readResponse(callback)
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("test", response.getString(0))
+ Assert.assertTrue(callback.foundLiteralCalled)
+ assertAllInputConsumed()
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun readResponse_withPartlyConsumingCallbackReturningNull_shouldThrow() {
+ val parser = createParser("* {4}\r\ntest\r\n")
+ val callback = TestImapResponseCallback.readBytesAndReturn(2, null)
+ try {
+ parser.readResponse(callback)
+ Assert.fail()
+ } catch (e: AssertionError) {
+ Assert.assertEquals("Callback consumed some data but returned no result", e.message)
+ }
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun readResponse_withPartlyConsumingCallbackThatThrows_shouldReadAllDataAndThrow() {
+ val parser = createParser("* {4}\r\ntest\r\n")
+ val callback = TestImapResponseCallback.readBytesAndThrow(2)
+ try {
+ parser.readResponse(callback)
+ Assert.fail()
+ } catch (e: ImapResponseParserException) {
+ Assert.assertEquals("readResponse(): Exception in callback method", e.message)
+ Assert.assertEquals(ImapResponseParserTestException::class.java, e.cause!!.javaClass)
+ }
+ assertAllInputConsumed()
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun readResponse_withCallbackThatThrowsRepeatedly_shouldConsumeAllInputAndThrowFirstException() {
+ val parser = createParser("* {3}\r\none {3}\r\ntwo\r\n")
+ val callback = TestImapResponseCallback.readBytesAndThrow(3)
+ try {
+ parser.readResponse(callback)
+ Assert.fail()
+ } catch (e: ImapResponseParserException) {
+ Assert.assertEquals("readResponse(): Exception in callback method", e.message)
+ Assert.assertEquals(ImapResponseParserTestException::class.java, e.cause!!.javaClass)
+ Assert.assertEquals(0, (e.cause as ImapResponseParserTestException?)!!.instanceNumber.toLong())
+ }
+ assertAllInputConsumed()
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testParseLiteralWithIncompleteConsumingCallbackReturningString() {
+ val parser = createParser("* {4}\r\ntest\r\n")
+ val callback = TestImapResponseCallback.readBytesAndReturn(2, "ninja")
+ val response = parser.readResponse(callback)
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("ninja", response.getString(0))
+ assertAllInputConsumed()
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testParseLiteralWithThrowingCallback() {
+ val parser = createParser("* {4}\r\ntest\r\n")
+ val callback: ImapResponseCallback = TestImapResponseCallback.readBytesAndThrow(0)
+ try {
+ parser.readResponse(callback)
+ Assert.fail()
+ } catch (e: ImapResponseParserException) {
+ Assert.assertEquals("readResponse(): Exception in callback method", e.message)
+ }
+ assertAllInputConsumed()
+ }
+
+ @Test(expected = IOException::class)
+ @Throws(Exception::class)
+ fun testParseLiteralWithCallbackThrowingIOException() {
+ val parser = createParser("* {4}\r\ntest\r\n")
+ val callback = ImapResponseCallback { response, literal -> throw IOException() }
+ parser.readResponse(callback)
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testParseQuoted() {
+ val parser = createParser("* \"qu\\\"oted\"\r\n")
+ val response = parser.readResponse()
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("qu\"oted", response.getString(0))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun utf8InQuotedString() {
+ val parser = createParser("* \"quöted\"\r\n")
+ val response = parser.readResponse()
+ Assert.assertEquals(1, response.size.toLong())
+ Assert.assertEquals("quöted", response.getString(0))
+ }
+
+ @Test(expected = IOException::class)
+ @Throws(Exception::class)
+ fun testParseQuotedToEndOfStream() {
+ val parser = createParser("* \"abc")
+ parser.readResponse()
+ }
+
+ @Test(expected = IOException::class)
+ @Throws(Exception::class)
+ fun testParseAtomToEndOfStream() {
+ val parser = createParser("* abc")
+ parser.readResponse()
+ }
+
+ @Test(expected = IOException::class)
+ @Throws(Exception::class)
+ fun testParseUntaggedResponseWithoutSpace() {
+ val parser = createParser("*\r\n")
+ parser.readResponse()
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testListResponseContainingFolderNameWithBrackets() {
+ val parser = createParser("* LIST (\\HasNoChildren) \".\" [FolderName]\r\n")
+ val response = parser.readResponse()
+ Assert.assertEquals(4, response.size.toLong())
+ Assert.assertEquals("LIST", response[0])
+ Assert.assertEquals(1, response.getList(1).size.toLong())
+ Assert.assertEquals("\\HasNoChildren", response.getList(1).getString(0))
+ Assert.assertEquals(".", response[2])
+ Assert.assertEquals("[FolderName]", response[3])
+ }
+
+ @Test(expected = IOException::class)
+ @Throws(Exception::class)
+ fun testListResponseContainingFolderNameContainingBracketsThrowsException() {
+ val parser = createParser(
+ "* LIST (\\NoInferiors) \"/\" Root/Folder/Subfolder()\r\n"
+ )
+ parser.readResponse()
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun readResponseShouldReadWholeListResponseLine() {
+ val parser = createParser(
+ """* LIST (\HasNoChildren) "." [FolderName]
+TAG OK [List complete]
+"""
+ )
+ parser.readResponse()
+ val responseTwo = parser.readResponse()
+ Assert.assertEquals("TAG", responseTwo.tag)
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun readResponse_withListResponseContainingNil() {
+ val parser = createParser("* LIST (\\NoInferiors) NIL INBOX\r\n")
+ val response = parser.readResponse()
+ Assert.assertEquals(4, response.size.toLong())
+ Assert.assertEquals("LIST", response[0])
+ Assert.assertEquals(1, response.getList(1).size.toLong())
+ Assert.assertEquals("\\NoInferiors", response.getList(1).getString(0))
+ Assert.assertEquals(null, response[2])
+ Assert.assertEquals("INBOX", response[3])
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun readResponse_withListAsFirstToken_shouldThrow() {
+ val parser = createParser("* [1 2] 3\r\n")
+ try {
+ parser.readResponse()
+ Assert.fail("Expected exception")
+ } catch (e: IOException) {
+ Assert.assertEquals("Unexpected non-string token: ImapList - [1, 2]", e.message)
+ }
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testFetchResponse() {
+ val parser = createParser(
+ """* 1 FETCH (UID 23 INTERNALDATE "01-Jul-2015 12:34:56 +0200" RFC822.SIZE 3456 BODY[HEADER.FIELDS (date subject from)] "<headers>" FLAGS (\Seen))
+"""
+ )
+ val response = parser.readResponse()
+ Assert.assertEquals(3, response.size.toLong())
+ Assert.assertEquals("1", response.getString(0))
+ Assert.assertEquals("FETCH", response.getString(1))
+ Assert.assertEquals("UID", response.getList(2).getString(0))
+ Assert.assertEquals(23, response.getList(2).getNumber(1).toLong())
+ Assert.assertEquals("INTERNALDATE", response.getList(2).getString(2))
+ Assert.assertEquals("01-Jul-2015 12:34:56 +0200", response.getList(2).getString(3))
+ Assert.assertEquals("RFC822.SIZE", response.getList(2).getString(4))
+ Assert.assertEquals(3456, response.getList(2).getNumber(5).toLong())
+ Assert.assertEquals("BODY", response.getList(2).getString(6))
+ Assert.assertEquals(2, response.getList(2).getList(7).size.toLong())
+ Assert.assertEquals("HEADER.FIELDS", response.getList(2).getList(7).getString(0))
+ Assert.assertEquals(3, response.getList(2).getList(7).getList(1).size.toLong())
+ Assert.assertEquals("date", response.getList(2).getList(7).getList(1).getString(0))
+ Assert.assertEquals("subject", response.getList(2).getList(7).getList(1).getString(1))
+ Assert.assertEquals("from", response.getList(2).getList(7).getList(1).getString(2))
+ Assert.assertEquals("<headers>", response.getList(2).getString(8))
+ Assert.assertEquals("FLAGS", response.getList(2).getString(9))
+ Assert.assertEquals(1, response.getList(2).getList(10).size.toLong())
+ Assert.assertEquals("\\Seen", response.getList(2).getList(10).getString(0))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun readStatusResponse_withNoResponse_shouldThrow() {
+ val parser = createParser("1 NO\r\n")
+ try {
+ parser.readStatusResponse("1", "COMMAND", "[logId]", null)
+ Assert.fail("Expected exception")
+ } catch (e: NegativeImapResponseException) {
+ Assert.assertEquals("Command: COMMAND; response: #1# [NO]", e.message)
+ }
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun readStatusResponse_withNoResponseAndAlertText_shouldThrowWithAlertText() {
+ val parser = createParser("1 NO [ALERT] Access denied\r\n")
+ try {
+ parser.readStatusResponse("1", "COMMAND", "[logId]", null)
+ Assert.fail("Expected exception")
+ } catch (e: NegativeImapResponseException) {
+ Assert.assertEquals("Access denied", e.alertText)
+ }
+ }
+
+ private fun createParser(response: String): ImapResponseParser {
+ val byteArrayInputStream = ByteArrayInputStream(response.toByteArray(UTF_8))
+ peekableInputStream = PeekableInputStream(byteArrayInputStream)
+ return ImapResponseParser(peekableInputStream)
+ }
+
+ @Throws(IOException::class)
+ private fun assertAllInputConsumed() {
+ Assert.assertEquals(0, peekableInputStream!!.available().toLong())
+ }
+
+ internal class TestImapResponseCallback private constructor(
+ private val readNumberOfBytes: Int,
+ private val returnValue: Any,
+ private val throwException: Boolean
+ ) : ImapResponseCallback {
+ private var exceptionCount = 0
+ var foundLiteralCalled = false
+ @Throws(Exception::class)
+ override fun foundLiteral(response: ImapResponse, literal: FixedLengthInputStream): Any {
+ foundLiteralCalled = true
+ var skipBytes = readNumberOfBytes
+ while (skipBytes > 0) {
+ val skippedBytes = literal.skip(skipBytes.toLong())
+ skipBytes -= skippedBytes.toInt()
+ }
+ if (throwException) {
+ throw ImapResponseParserTestException(exceptionCount++)
+ }
+ return returnValue
+ }
+
+ companion object {
+ fun readBytesAndReturn(readNumberOfBytes: Int, returnValue: Any?): TestImapResponseCallback {
+ return TestImapResponseCallback(readNumberOfBytes, returnValue!!, false)
+ }
+
+ fun readBytesAndThrow(readNumberOfBytes: Int): TestImapResponseCallback {
+ return TestImapResponseCallback(readNumberOfBytes, null, true)
+ }
+ }
+ }
+
+ internal class ImapResponseParserTestException(val instanceNumber: Int) : RuntimeException()
+ internal class TestUntaggedHandler : UntaggedHandler {
+ val responses: MutableList<ImapResponse> = ArrayList()
+ override fun handleAsyncUntaggedResponse(response: ImapResponse) {
+ responses.add(response)
+ }
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapUtilityTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapUtilityTest.java
new file mode 100644
index 0000000..da285d8
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapUtilityTest.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2012 The K-9 Dog Walkers
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.fsck.k9.mail.store.imap;
+
+
+import java.util.List;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertArrayEquals;
+
+
+public class ImapUtilityTest {
+ @Test
+ public void testGetImapSequenceValues() {
+ String[] expected;
+ List<String> actual;
+
+ // Test valid sets
+ expected = new String[] {"1"};
+ actual = ImapUtility.getImapSequenceValues("1");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[] {"2147483648"}; // Integer.MAX_VALUE + 1
+ actual = ImapUtility.getImapSequenceValues("2147483648");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[] {"4294967295"}; // 2^32 - 1
+ actual = ImapUtility.getImapSequenceValues("4294967295");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[] {"1", "3", "2"};
+ actual = ImapUtility.getImapSequenceValues("1,3,2");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[] {"4", "5", "6"};
+ actual = ImapUtility.getImapSequenceValues("4:6");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[] {"9", "8", "7"};
+ actual = ImapUtility.getImapSequenceValues("9:7");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[] {"1", "2", "3", "4", "9", "8", "7"};
+ actual = ImapUtility.getImapSequenceValues("1,2:4,9:7");
+ assertArrayEquals(expected, actual.toArray());
+
+ // Test numbers larger than Integer.MAX_VALUE (2147483647)
+ expected = new String[] {"2147483646", "2147483647", "2147483648"};
+ actual = ImapUtility.getImapSequenceValues("2147483646:2147483648");
+ assertArrayEquals(expected, actual.toArray());
+
+ // Test partially invalid sets
+ expected = new String[] { "1", "5" };
+ actual = ImapUtility.getImapSequenceValues("1,x,5");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[] { "1", "2", "3" };
+ actual = ImapUtility.getImapSequenceValues("a:d,1:3");
+ assertArrayEquals(expected, actual.toArray());
+
+ // Test invalid sets
+ expected = new String[0];
+ actual = ImapUtility.getImapSequenceValues("");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapSequenceValues(null);
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapSequenceValues("a");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapSequenceValues("1:x");
+ assertArrayEquals(expected, actual.toArray());
+
+ // Test values larger than 2^32 - 1
+ expected = new String[0];
+ actual = ImapUtility.getImapSequenceValues("4294967296:4294967297");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapSequenceValues("4294967296"); // 2^32
+ assertArrayEquals(expected, actual.toArray());
+ }
+
+ @Test public void testGetImapRangeValues() {
+ String[] expected;
+ List<String> actual;
+
+ // Test valid ranges
+ expected = new String[] {"1", "2", "3"};
+ actual = ImapUtility.getImapRangeValues("1:3");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[] {"16", "15", "14"};
+ actual = ImapUtility.getImapRangeValues("16:14");
+ assertArrayEquals(expected, actual.toArray());
+
+ // Test in-valid ranges
+ expected = new String[0];
+ actual = ImapUtility.getImapRangeValues("");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapRangeValues(null);
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapRangeValues("a");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapRangeValues("6");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapRangeValues("1:3,6");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapRangeValues("1:x");
+ assertArrayEquals(expected, actual.toArray());
+
+ expected = new String[0];
+ actual = ImapUtility.getImapRangeValues("1:*");
+ assertArrayEquals(expected, actual.toArray());
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ListResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ListResponseTest.java
new file mode 100644
index 0000000..d27ec6f
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ListResponseTest.java
@@ -0,0 +1,99 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import java.io.IOException;
+import java.util.List;
+
+import org.junit.Test;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.junit.Assert.assertEquals;
+
+
+public class ListResponseTest {
+ @Test
+ public void parseList_withValidResponses_shouldReturnListResponses() throws Exception {
+ List<ImapResponse> responses = asList(
+ createImapResponse("* LIST () \"/\" blurdybloop"),
+ createImapResponse("* LIST (\\Noselect) \"/\" foo"),
+ createImapResponse("* LIST () \"/\" foo/bar"),
+ createImapResponse("* LIST (\\NoInferiors) NIL INBOX"),
+ createImapResponse("X OK LIST completed")
+ );
+
+ List<ListResponse> result = ListResponse.parseList(responses);
+
+ assertEquals(4, result.size());
+ assertListResponseEquals(noAttributes(), "/", "blurdybloop", result.get(0));
+ assertListResponseEquals(singletonList("\\Noselect"), "/", "foo", result.get(1));
+ assertListResponseEquals(noAttributes(), "/", "foo/bar", result.get(2));
+ assertListResponseEquals(singletonList("\\NoInferiors"), null, "INBOX", result.get(3));
+ }
+
+ @Test
+ public void parseList_withValidResponse_shouldReturnListResponse() throws Exception {
+ List<ListResponse> result = parseSingle("* LIST () \".\" \"Folder\"");
+
+ assertEquals(1, result.size());
+ assertListResponseEquals(noAttributes(), ".", "Folder", result.get(0));
+ }
+
+ @Test
+ public void parseList_withValidResponseContainingAttributes_shouldReturnListResponse() throws Exception {
+ List<ListResponse> result = parseSingle("* LIST (\\HasChildren \\Noselect) \".\" \"Folder\"");
+
+ assertEquals(1, result.size());
+ assertListResponseEquals(asList("\\HasChildren", "\\Noselect"), ".", "Folder", result.get(0));
+ }
+
+ @Test
+ public void parseList_withoutListResponse_shouldReturnEmptyList() throws Exception {
+ List<ListResponse> result = parseSingle("* LSUB () \".\" INBOX");
+
+ assertEquals(emptyList(), result);
+ }
+
+ @Test
+ public void parseList_withMalformedListResponse1_shouldReturnEmptyList() throws Exception {
+ List<ListResponse> result = parseSingle("* LIST ([inner list]) \"/\" \"Folder\"");
+
+ assertEquals(emptyList(), result);
+ }
+
+ @Test
+ public void parseList_withMalformedListResponse2_shouldReturnEmptyList() throws Exception {
+ List<ListResponse> result = parseSingle("* LIST () \"ab\" \"Folder\"");
+
+ assertEquals(emptyList(), result);
+ }
+
+ @Test
+ public void parseLsub_withValidResponse_shouldReturnListResponse() throws Exception {
+ List<ImapResponse> responses = singletonList(createImapResponse("* LSUB () \".\" \"Folder\""));
+
+ List<ListResponse> result = ListResponse.parseLsub(responses);
+
+ assertEquals(1, result.size());
+ assertListResponseEquals(noAttributes(), ".", "Folder", result.get(0));
+ }
+
+ private List<ListResponse> parseSingle(String response) throws IOException {
+ List<ImapResponse> responses = singletonList(createImapResponse(response));
+
+ return ListResponse.parseList(responses);
+ }
+
+ private List<String> noAttributes() {
+ return emptyList();
+ }
+
+ private void assertListResponseEquals(List<String> attributes, String delimiter, String name,
+ ListResponse listResponse) {
+ assertEquals(attributes, listResponse.getAttributes());
+ assertEquals(delimiter, listResponse.getHierarchyDelimiter());
+ assertEquals(name, listResponse.getName());
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/NamespaceResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/NamespaceResponseTest.java
new file mode 100644
index 0000000..2c157ff
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/NamespaceResponseTest.java
@@ -0,0 +1,103 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.Test;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+
+public class NamespaceResponseTest {
+
+ @Test
+ public void parse_withProperNamespaceResponse() throws Exception {
+ NamespaceResponse result = parse("* NAMESPACE ((\"\" \"/\")) NIL NIL");
+
+ assertNotNull(result);
+ assertEquals("", result.getPrefix());
+ assertEquals("/", result.getHierarchyDelimiter());
+ }
+
+ @Test
+ public void parse_withoutNamespaceResponse_shouldReturnNull() throws Exception {
+ NamespaceResponse result = parse("* OK Some text here");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withTooShortNamespaceResponse_shouldReturnNull() throws Exception {
+ NamespaceResponse result = parse("* NAMESPACE NIL NIL");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withPersonalNamespacesNotPresent_shouldReturnNull() throws Exception {
+ NamespaceResponse result = parse("* NAMESPACE NIL NIL NIL");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withEmptyListForPersonalNamespaces_shouldReturnNull() throws Exception {
+ NamespaceResponse result = parse("* NAMESPACE () NIL NIL");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withEmptyListForFirstPersonalNamespace_shouldReturnNull() throws Exception {
+ NamespaceResponse result = parse("* NAMESPACE (()) NIL NIL");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withIncompleteFirstPersonalNamespace_shouldReturnNull() throws Exception {
+ NamespaceResponse result = parse("* NAMESPACE ((\"\")) NIL NIL");
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withEmptyResponseList() throws Exception {
+ NamespaceResponse result = NamespaceResponse.parse(Collections.<ImapResponse>emptyList());
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withSingleItemInResponseList() throws Exception {
+ ImapResponse imapResponse = createImapResponse("* NAMESPACE ((\"\" \"/\")) NIL NIL");
+ NamespaceResponse result = NamespaceResponse.parse(Collections.singletonList(imapResponse));
+
+ assertNotNull(result);
+ assertEquals("", result.getPrefix());
+ assertEquals("/", result.getHierarchyDelimiter());
+ }
+
+ @Test
+ public void parse_withResponseList() throws Exception {
+ ImapResponse imapResponseOne = createImapResponse("* OK");
+ ImapResponse imapResponseTwo = createImapResponse("* NAMESPACE ((\"INBOX\" \".\")) NIL NIL");
+ NamespaceResponse result = NamespaceResponse.parse(Arrays.asList(imapResponseOne, imapResponseTwo));
+
+ assertNotNull(result);
+ assertEquals("INBOX", result.getPrefix());
+ assertEquals(".", result.getHierarchyDelimiter());
+ }
+
+
+ private NamespaceResponse parse(String response) throws IOException {
+ ImapResponse imapResponse = createImapResponse(response);
+
+ return NamespaceResponse.parse(imapResponse);
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponseTest.java
new file mode 100644
index 0000000..bad8a46
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponseTest.java
@@ -0,0 +1,109 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import com.fsck.k9.mail.Flag;
+import org.junit.Test;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.mockito.internal.util.collections.Sets.newSet;
+
+
+public class PermanentFlagsResponseTest {
+ @Test
+ public void parse_withPermanentFlagsResponse_shouldExtractFlags() throws Exception {
+ ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen " +
+ "\\Draft $forwarded NonJunk $label1 \\*)] Flags permitted.");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNotNull(result);
+ assertEquals(newSet(Flag.ANSWERED, Flag.FLAGGED, Flag.DELETED, Flag.SEEN, Flag.FORWARDED), result.getFlags());
+ }
+
+ @Test
+ public void parse_withPermanentFlagsResponseContainingSpecialKeyword_shouldSetCanCreateKeywords() throws Exception {
+ ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Deleted \\*)] Flags permitted.");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNotNull(result);
+ assertEquals(true, result.canCreateKeywords());
+ }
+
+ @Test
+ public void parse_withPermanentFlagsResponseNotContainingSpecialKeyword_shouldNotSetCanCreateKeywords()
+ throws Exception {
+ ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Deleted \\Seen)] Flags permitted.");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNotNull(result);
+ assertEquals(false, result.canCreateKeywords());
+ }
+
+ @Test
+ public void parse_withTaggedResponse_shouldReturnNull() throws Exception {
+ ImapResponse response = createImapResponse("1 OK [PERMANENTFLAGS (\\Deleted \\Seen)] Flags permitted.");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutOkResponse_shouldReturnNull() throws Exception {
+ ImapResponse response = createImapResponse("* BYE See you");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutResponseText_shouldReturnNull() throws Exception {
+ ImapResponse response = createImapResponse("* OK Success");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withTooShortResponseText_shouldReturnNull() throws Exception {
+ ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS]");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutPermanentFlagsResponse_shouldReturnNull() throws Exception {
+ ImapResponse response = createImapResponse("* OK [UIDNEXT 1]");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutPermanentFlagsList_shouldReturnNull() throws Exception {
+ ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS none]");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withInvalidElementInPermanentFlagsList_shouldReturnNull() throws Exception {
+ ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Seen ())]");
+
+ PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
+
+ assertNull(result);
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt
new file mode 100644
index 0000000..9198d4c
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt
@@ -0,0 +1,1136 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.assertThat
+import assertk.assertions.contains
+import assertk.assertions.containsExactly
+import assertk.assertions.hasMessage
+import assertk.assertions.hasSize
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFailure
+import assertk.assertions.isFalse
+import assertk.assertions.isInstanceOf
+import assertk.assertions.isNotNull
+import assertk.assertions.isTrue
+import assertk.assertions.message
+import assertk.assertions.prop
+import com.fsck.k9.logging.Timber
+import com.fsck.k9.mail.AuthType
+import com.fsck.k9.mail.AuthenticationFailedException
+import com.fsck.k9.mail.CertificateValidationException
+import com.fsck.k9.mail.ConnectionSecurity
+import com.fsck.k9.mail.K9MailLib
+import com.fsck.k9.mail.MessagingException
+import com.fsck.k9.mail.SystemOutLogger
+import com.fsck.k9.mail.XOAuth2ChallengeParserTest
+import com.fsck.k9.mail.helpers.TestTrustedSocketFactory
+import com.fsck.k9.mail.oauth.OAuth2TokenProvider
+import com.fsck.k9.mail.ssl.TrustedSocketFactory
+import com.fsck.k9.mail.store.imap.mockserver.MockImapServer
+import java.io.IOException
+import java.net.UnknownHostException
+import okio.ByteString.Companion.encodeUtf8
+import org.junit.Before
+import org.junit.Test
+
+private const val DEBUGGING = false
+
+private const val USERNAME = "user"
+private const val PASSWORD = "123456"
+
+private const val SOCKET_CONNECT_TIMEOUT = 10000
+private const val SOCKET_READ_TIMEOUT = 10000
+
+private const val XOAUTH_TOKEN = "token"
+private const val XOAUTH_TOKEN_2 = "token2"
+private val XOAUTH_STRING = "user=$USERNAME\u0001auth=Bearer $XOAUTH_TOKEN\u0001\u0001".base64()
+private val XOAUTH_STRING_RETRY = "user=$USERNAME\u0001auth=Bearer $XOAUTH_TOKEN_2\u0001\u0001".base64()
+private val OAUTHBEARER_STRING = "n,a=$USERNAME,\u0001auth=Bearer $XOAUTH_TOKEN\u0001\u0001".base64()
+
+class RealImapConnectionTest {
+ private var socketFactory = TestTrustedSocketFactory.newInstance()
+ private var oAuth2TokenProvider = TestTokenProvider()
+
+ @Before
+ fun setUp() {
+ if (DEBUGGING) {
+ Timber.logger = SystemOutLogger()
+ K9MailLib.setDebug(true)
+ K9MailLib.setDebugSensitive(true)
+ }
+ }
+
+ @Test
+ fun `open() with no capabilities in initial response should issue pre-auth capabilities command`() {
+ val server = MockImapServer().apply {
+ output("* OK example.org server")
+ expect("1 CAPABILITY")
+ output("* CAPABILITY IMAP4 IMAP4REV1 AUTH=PLAIN")
+ output("1 OK CAPABILITY Completed")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("2 OK Success")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with capabilities in initial response should not issue pre-auth capabilities command`() {
+ val server = MockImapServer().apply {
+ output("* OK [CAPABILITY IMAP4 IMAP4REV1 AUTH=PLAIN]")
+ expect("1 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("1 OK Success")
+ postAuthenticationDialogRequestingCapabilities(tag = 2)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH PLAIN`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("2 OK Success")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() after close() was called should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog()
+ expect("2 LOGIN \"$USERNAME\" \"$PASSWORD\"")
+ output("2 OK LOGIN completed")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+ imapConnection.open()
+ imapConnection.close()
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(IllegalStateException::class)
+ .hasMessage("open() called after close(). Check wrapped exception to see where close() was called.")
+ }
+
+ @Test
+ fun `open() AUTH PLAIN with login disabled should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "LOGINDISABLED")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Server doesn't support unencrypted passwords using AUTH=PLAIN and LOGIN is disabled.")
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH PLAIN with authentication failure should fall back to LOGIN`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("2 NO Login Failure")
+ expect("3 LOGIN \"$USERNAME\" \"$PASSWORD\"")
+ output("3 OK LOGIN completed")
+ postAuthenticationDialogRequestingCapabilities(tag = 4)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH PLAIN and LOGIN fallback with authentication failure should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("2 NO Login Failure")
+ expect("3 LOGIN \"$USERNAME\" \"$PASSWORD\"")
+ output("3 NO Go away")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(AuthenticationFailedException::class)
+ .prop(AuthenticationFailedException::messageFromServer)
+ .isEqualTo("Go away")
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH PLAIN failure and disconnect should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("2 NO [UNAVAILABLE] Maximum number of connections from user+IP exceeded")
+ closeConnection()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(NegativeImapResponseException::class)
+ .message().isNotNull().contains("Maximum number of connections from user+IP exceeded")
+
+ assertThat(imapConnection.isConnected).isFalse()
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH PLAIN with BYE response and connection close should throw AuthenticationFailedException`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("* BYE Go away")
+ output("2 NO Login Failure")
+ closeConnection()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(AuthenticationFailedException::class)
+ .prop(AuthenticationFailedException::messageFromServer)
+ .isEqualTo("Login Failure")
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH PLAIN without AUTH PLAIN capability should use LOGIN command`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog()
+ expect("2 LOGIN \"$USERNAME\" \"$PASSWORD\"")
+ output("2 OK LOGIN completed")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH CRAM-MD5`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=CRAM-MD5")
+ expect("2 AUTHENTICATE CRAM-MD5")
+ output("+ ${"<0000.000000000@example.org>".base64()}")
+ expect("dXNlciA2ZjdiOTcyYjk5YTI4NDk4OTRhN2YyMmE3MGRhZDg0OQ==")
+ output("2 OK Success")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.CRAM_MD5)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH CRAM-MD5 with authentication failure should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=CRAM-MD5")
+ expect("2 AUTHENTICATE CRAM-MD5")
+ output("+ ${"<0000.000000000@example.org>".base64()}")
+ expect("dXNlciA2ZjdiOTcyYjk5YTI4NDk4OTRhN2YyMmE3MGRhZDg0OQ==")
+ output("2 NO Who are you?")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.CRAM_MD5)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(AuthenticationFailedException::class)
+ .prop(AuthenticationFailedException::messageFromServer)
+ .isEqualTo("Who are you?")
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH CRAM-MD5 without AUTH CRAM-MD5 capability should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.CRAM_MD5)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Server doesn't support encrypted passwords using CRAM-MD5.")
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH OAUTHBEARER`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=OAUTHBEARER")
+ expect("2 AUTHENTICATE OAUTHBEARER $OAUTHBEARER_STRING")
+ output("2 OK Success")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH OAUTHBEARER when AUTH=XOAUTH2 and AUTH=OAUTHBEARER capabilities are present`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH2 AUTH=OAUTHBEARER")
+ expect("2 AUTHENTICATE OAUTHBEARER $OAUTHBEARER_STRING")
+ output("2 OK Success")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH XOAUTH2 with SASL-IR`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2")
+ expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING")
+ output("2 OK Success")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH XOAUTH2 with untagged CAPABILITY response after authentication`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2")
+ expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING")
+ output("* CAPABILITY IMAP4rev1 X-GM-EXT-1")
+ output("2 OK Success")
+ simplePostAuthenticationDialog(tag = 3)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ imapConnection.open()
+
+ assertThat(imapConnection.hasCapability("X-GM-EXT-1")).isTrue()
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH XOAUTH2 throws exception on 401 response`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2")
+ expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING")
+ output("+ ${XOAuth2ChallengeParserTest.STATUS_401_RESPONSE}")
+ expect("")
+ output("2 NO SASL authentication failed")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(AuthenticationFailedException::class)
+ .prop(AuthenticationFailedException::messageFromServer)
+ .isEqualTo("SASL authentication failed")
+ }
+
+ @Test
+ fun `open() AUTH XOAUTH2 invalidates and retries new token on 400 response`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2")
+ expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING")
+ output("+ ${XOAuth2ChallengeParserTest.STATUS_400_RESPONSE}")
+ expect("")
+ output("2 NO SASL authentication failed")
+ expect("3 AUTHENTICATE XOAUTH2 $XOAUTH_STRING_RETRY")
+ output("3 OK Success")
+ postAuthenticationDialogRequestingCapabilities(tag = 4)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH XOAUTH2 invalidates and retries new token on invalid JSON response`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2")
+ expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING")
+ output("+ ${XOAuth2ChallengeParserTest.INVALID_RESPONSE}")
+ expect("")
+ output("2 NO SASL authentication failed")
+ expect("3 AUTHENTICATE XOAUTH2 $XOAUTH_STRING_RETRY")
+ output("3 OK Success")
+ requestCapabilities(tag = 4)
+ simplePostAuthenticationDialog(tag = 5)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH XOAUTH2 invalidates and retries new token on missing status JSON response`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2")
+ expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING")
+ output("+ ${XOAuth2ChallengeParserTest.MISSING_STATUS_RESPONSE}")
+ expect("")
+ output("2 NO SASL authentication failed")
+ expect("3 AUTHENTICATE XOAUTH2 $XOAUTH_STRING_RETRY")
+ output("3 OK Success")
+ requestCapabilities(tag = 4)
+ simplePostAuthenticationDialog(tag = 5)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH XOAUTH2 with old token throws exception if retry fails`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2")
+ expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING")
+ output("+ r3j3krj3irj3oir3ojo")
+ expect("")
+ output("2 NO SASL authentication failed")
+ expect("3 AUTHENTICATE XOAUTH2 $XOAUTH_STRING_RETRY")
+ output("+ 433ba3a3a")
+ expect("")
+ output("3 NO SASL authentication failed")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(AuthenticationFailedException::class)
+ .prop(AuthenticationFailedException::messageFromServer)
+ .isEqualTo("SASL authentication failed")
+ }
+
+ @Test
+ fun `open() AUTH XOAUTH2 parses capabilities`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2")
+ expect("2 AUTHENTICATE XOAUTH2 $XOAUTH_STRING")
+ output("2 OK [CAPABILITY IMAP4REV1 IDLE XM-GM-EXT-1]")
+ simplePostAuthenticationDialog(tag = 3)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ assertThat(imapConnection.hasCapability("XM-GM-EXT-1")).isTrue()
+ }
+
+ @Test
+ fun `open() AUTH EXTERNAL`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=EXTERNAL")
+ expect("2 AUTHENTICATE EXTERNAL ${USERNAME.base64()}")
+ output("2 OK Success")
+ postAuthenticationDialogRequestingCapabilities()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.EXTERNAL)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH EXTERNAL with authentication failure should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=EXTERNAL")
+ expect("2 AUTHENTICATE EXTERNAL ${USERNAME.base64()}")
+ output("2 NO Bad certificate")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.EXTERNAL)
+
+ // FIXME: improve exception message
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(CertificateValidationException::class)
+ .message().isNotNull().contains("Bad certificate")
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() AUTH EXTERNAL without AUTH EXTERNAL capability should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.EXTERNAL)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(CertificateValidationException::class)
+ .prop(CertificateValidationException::getReason)
+ .isEqualTo(CertificateValidationException.Reason.MissingCapability)
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with no post-auth CAPABILITY response should issue CAPABILITY command`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("2 OK Success")
+ expect("3 CAPABILITY")
+ output("* CAPABILITY IDLE")
+ output("3 OK CAPABILITY Completed")
+ simplePostAuthenticationDialog(tag = 4)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ assertThat(imapConnection.isIdleCapable).isTrue()
+ }
+
+ @Test
+ fun `open() with untagged post-auth CAPABILITY response`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output(
+ "* CAPABILITY IMAP4rev1 UNSELECT IDLE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS " +
+ "ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS LITERAL- SPECIAL-USE " +
+ "APPENDLIMIT=35651584"
+ )
+ output("2 OK")
+ simplePostAuthenticationDialog(tag = 3)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ assertThat(imapConnection.isIdleCapable).isTrue()
+ }
+
+ @Test
+ fun `open() with post-auth CAPABILITY response`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "AUTH=PLAIN")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("2 OK [CAPABILITY IDLE]")
+ simplePostAuthenticationDialog(tag = 3)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ assertThat(imapConnection.isIdleCapable).isTrue()
+ }
+
+ @Test
+ fun `open() with NAMESPACE capability should issue NAMESPACE command`() {
+ val server = MockImapServer().apply {
+ simplePreAuthAndLoginDialog(postAuthCapabilities = "NAMESPACE")
+ expect("3 NAMESPACE")
+ output("* NAMESPACE ((\"\" \"/\")) NIL NIL")
+ output("3 OK command completed")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test(expected = IOException::class)
+ fun `open() with connection error should throw`() {
+ val settings = createImapSettings(host = "127.1.2.3")
+ val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider)
+
+ imapConnection.open()
+ }
+
+ @Test
+ fun `open() with invalid hostname should throw`() {
+ val settings = createImapSettings(host = "host name")
+ val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(UnknownHostException::class)
+
+ assertThat(imapConnection.isConnected).isFalse()
+ }
+
+ @Test
+ fun `open() with STARTTLS capability should issue STARTTLS command`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "STARTTLS LOGINDISABLED")
+ expect("2 STARTTLS")
+ output("2 OK [CAPABILITY IMAP4REV1 NAMESPACE]")
+ startTls()
+ expect("3 CAPABILITY")
+ output("* CAPABILITY IMAP4 IMAP4REV1")
+ output("3 OK")
+ expect("4 LOGIN \"$USERNAME\" \"$PASSWORD\"")
+ output("4 OK [CAPABILITY NAMESPACE] LOGIN completed")
+ expect("5 NAMESPACE")
+ output("* NAMESPACE ((\"\" \"/\")) NIL NIL")
+ output("5 OK command completed")
+ }
+ val imapConnection = startServerAndCreateImapConnection(
+ server,
+ connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
+ authType = AuthType.PLAIN
+ )
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with STARTTLS but without STARTTLS capability should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog()
+ }
+ val imapConnection = startServerAndCreateImapConnection(
+ server,
+ connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED
+ )
+
+ // FIXME: CertificateValidationException seems wrong
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(CertificateValidationException::class)
+ .hasMessage("STARTTLS connection security not available")
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with untagged CAPABILITY after STARTTLS should not throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "STARTTLS LOGINDISABLED")
+ expect("2 STARTTLS")
+ output("2 OK Begin TLS negotiation now")
+ startTls()
+ output("* CAPABILITY IMAP4REV1 IMAP4")
+ expect("3 CAPABILITY")
+ output("* CAPABILITY IMAP4 IMAP4REV1")
+ output("3 OK")
+ expect("4 LOGIN \"$USERNAME\" \"$PASSWORD\"")
+ output("4 OK [CAPABILITY IMAP4REV1] LOGIN completed")
+ simplePostAuthenticationDialog(tag = 5)
+ }
+ val imapConnection = startServerAndCreateImapConnection(
+ server,
+ connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
+ authType = AuthType.PLAIN
+ )
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with negative response to STARTTLS command should throw`() {
+ val server = MockImapServer().apply {
+ preAuthenticationDialog(capabilities = "STARTTLS")
+ expect("2 STARTTLS")
+ output("2 NO")
+ }
+ val imapConnection = startServerAndCreateImapConnection(
+ server,
+ connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
+ authType = AuthType.PLAIN
+ )
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(NegativeImapResponseException::class)
+ .hasMessage("Command: STARTTLS; response: #2# [NO]")
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with COMPRESS=DEFLATE capability should enable compression`() {
+ val server = MockImapServer().apply {
+ simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE")
+ expect("3 COMPRESS DEFLATE")
+ output("3 OK")
+ enableCompression()
+ simplePostAuthenticationDialog(tag = 4)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, useCompression = true)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with negative response to COMPRESS command should continue`() {
+ val server = MockImapServer().apply {
+ simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE")
+ expect("3 COMPRESS DEFLATE")
+ output("3 NO")
+ simplePostAuthenticationDialog(tag = 4)
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, useCompression = true)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with IOException during COMPRESS command should throw`() {
+ val server = MockImapServer().apply {
+ simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE")
+ expect("3 COMPRESS DEFLATE")
+ closeConnection()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, useCompression = true)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(IOException::class)
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with IOException during LIST command should throw`() {
+ val server = MockImapServer().apply {
+ simplePreAuthAndLoginDialog()
+ expect("3 LIST \"\" \"\"")
+ output("* Now what?")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(IOException::class)
+
+ server.verifyConnectionClosed()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `open() with negative response to LIST command`() {
+ val server = MockImapServer().apply {
+ simplePreAuthAndLoginDialog()
+ expect("3 LIST \"\" \"\"")
+ output("3 NO")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server)
+
+ imapConnection.open()
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `isConnected without previous open() should return false`() {
+ val settings = createImapSettings()
+ val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider)
+
+ val result = imapConnection.isConnected
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `isConnected after open() should return true`() {
+ val server = MockImapServer()
+ val imapConnection = simpleOpen(server)
+
+ val result = imapConnection.isConnected
+
+ assertThat(result).isTrue()
+ server.verifyConnectionStillOpen()
+ server.shutdown()
+ }
+
+ @Test
+ fun isConnected_afterOpenAndClose_shouldReturnFalse() {
+ val server = MockImapServer()
+ val imapConnection = simpleOpen(server)
+ imapConnection.close()
+
+ val result = imapConnection.isConnected
+
+ assertThat(result).isFalse()
+ server.verifyConnectionClosed()
+ server.shutdown()
+ }
+
+ @Test
+ fun `close() without open() should not throw`() {
+ val settings = createImapSettings()
+ val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider)
+
+ imapConnection.close()
+ }
+
+ @Test
+ fun `close() after open() should close connection`() {
+ val server = MockImapServer()
+ val imapConnection = simpleOpen(server)
+
+ imapConnection.close()
+
+ server.verifyConnectionClosed()
+ server.shutdown()
+ }
+
+ @Test
+ fun `isIdleCapable without IDLE capability should return false`() {
+ val server = MockImapServer()
+ val imapConnection = simpleOpen(server)
+
+ val result = imapConnection.isIdleCapable
+
+ assertThat(result).isFalse()
+ server.shutdown()
+ }
+
+ @Test
+ fun `isIdleCapable with IDLE capability should return true`() {
+ val server = MockImapServer()
+ val imapConnection = simpleOpenWithCapabilities(server, postAuthCapabilities = "IDLE")
+
+ val result = imapConnection.isIdleCapable
+
+ assertThat(result).isTrue()
+ server.shutdown()
+ }
+
+ @Test
+ fun `sendContinuation() should send line without tag`() {
+ val server = MockImapServer().apply {
+ simpleOpenDialog(postAuthCapabilities = "IDLE")
+ expect("4 IDLE")
+ output("+ idling")
+ expect("DONE")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
+
+ imapConnection.open()
+ imapConnection.sendCommand("IDLE", false)
+ imapConnection.readResponse()
+ imapConnection.sendContinuation("DONE")
+
+ server.waitForInteractionToComplete()
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `executeSimpleCommand() with OK response should return result`() {
+ val server = MockImapServer().apply {
+ simpleOpenDialog()
+ expect("4 CREATE Folder")
+ output("4 OK Folder created")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server)
+
+ val result = imapConnection.executeSimpleCommand("CREATE Folder")
+
+ assertThat(result).hasSize(1)
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `executeSimpleCommand() with NO response should throw NegativeImapResponseException`() {
+ val server = MockImapServer().apply {
+ simpleOpenDialog()
+ expect("4 CREATE Folder")
+ output("4 NO Folder exists")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server)
+
+ assertThat {
+ imapConnection.executeSimpleCommand("CREATE Folder")
+ }.isFailure()
+ .isInstanceOf(NegativeImapResponseException::class)
+ .prop(NegativeImapResponseException::getLastResponse)
+ .containsExactly("NO", "Folder exists")
+
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `hasCapability() with not yet opened connection should connect and fetch capabilities`() {
+ val server = MockImapServer().apply {
+ simpleOpenDialog(postAuthCapabilities = "X-SOMETHING")
+ }
+ val imapConnection = startServerAndCreateImapConnection(server)
+
+ val capabilityPresent = imapConnection.hasCapability("X-SOMETHING")
+
+ assertThat(capabilityPresent).isTrue()
+ server.verifyConnectionStillOpen()
+ server.verifyInteractionCompleted()
+ }
+
+ @Test
+ fun `disconnect during LOGIN fallback should throw AuthenticationFailedException`() {
+ val server = MockImapServer().apply {
+ output("* OK example.org server")
+ expect("1 CAPABILITY")
+ output("* CAPABILITY IMAP4 IMAP4REV1 AUTH=PLAIN")
+ output("1 OK CAPABILITY Completed")
+ expect("2 AUTHENTICATE PLAIN")
+ output("+")
+ expect("\u0000$USERNAME\u0000$PASSWORD".base64())
+ output("2 NO AUTHENTICATE failed")
+ expect("3 LOGIN \"$USERNAME\" \"$PASSWORD\"")
+ output("* BYE IMAP server terminating connection")
+ closeConnection()
+ }
+ val imapConnection = startServerAndCreateImapConnection(server)
+
+ assertThat {
+ imapConnection.open()
+ }.isFailure()
+ .isInstanceOf(AuthenticationFailedException::class)
+ .prop(AuthenticationFailedException::messageFromServer)
+ .isEqualTo("AUTHENTICATE failed")
+
+ server.verifyInteractionCompleted()
+ }
+
+ private fun createImapConnection(
+ settings: ImapSettings,
+ socketFactory: TrustedSocketFactory,
+ oAuth2TokenProvider: OAuth2TokenProvider
+ ): ImapConnection {
+ val connectionGeneration = 1
+ return RealImapConnection(
+ settings,
+ socketFactory,
+ oAuth2TokenProvider,
+ connectionGeneration,
+ SOCKET_CONNECT_TIMEOUT,
+ SOCKET_READ_TIMEOUT
+ )
+ }
+
+ private fun startServerAndCreateImapConnection(
+ server: MockImapServer,
+ connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE,
+ authType: AuthType = AuthType.PLAIN,
+ useCompression: Boolean = false
+ ): ImapConnection {
+ server.start()
+
+ val settings = SimpleImapSettings(
+ host = server.host,
+ port = server.port,
+ connectionSecurity = connectionSecurity,
+ authType = authType,
+ username = USERNAME,
+ password = PASSWORD,
+ useCompression = useCompression
+ )
+
+ return createImapConnection(settings, socketFactory, oAuth2TokenProvider)
+ }
+
+ private fun simpleOpen(server: MockImapServer): ImapConnection {
+ return simpleOpenWithCapabilities(server, postAuthCapabilities = "")
+ }
+
+ private fun simpleOpenWithCapabilities(server: MockImapServer, postAuthCapabilities: String): ImapConnection {
+ server.simpleOpenDialog(postAuthCapabilities)
+
+ val imapConnection = startServerAndCreateImapConnection(server)
+ imapConnection.open()
+
+ return imapConnection
+ }
+
+ private fun MockImapServer.preAuthenticationDialog(capabilities: String = "") {
+ output("* OK IMAP4rev1 Service Ready")
+ expect("1 CAPABILITY")
+ output("* CAPABILITY IMAP4 IMAP4REV1 $capabilities")
+ output("1 OK CAPABILITY")
+ }
+
+ private fun MockImapServer.postAuthenticationDialogRequestingCapabilities(tag: Int = 3) {
+ requestCapabilities(tag)
+ simplePostAuthenticationDialog(tag + 1)
+ }
+
+ private fun MockImapServer.requestCapabilities(tag: Int) {
+ expect("$tag CAPABILITY")
+ output("* CAPABILITY IMAP4 IMAP4REV1 ")
+ output("$tag OK CAPABILITY")
+ }
+
+ private fun MockImapServer.simplePostAuthenticationDialog(tag: Int) {
+ expect("$tag LIST \"\" \"\"")
+ output("* LIST () \"/\" foo/bar")
+ output("$tag OK")
+ }
+
+ private fun MockImapServer.simpleOpenDialog(postAuthCapabilities: String = "") {
+ simplePreAuthAndLoginDialog(postAuthCapabilities)
+ simplePostAuthenticationDialog(3)
+ }
+
+ private fun MockImapServer.simplePreAuthAndLoginDialog(postAuthCapabilities: String = "") {
+ preAuthenticationDialog()
+ expect("2 LOGIN \"$USERNAME\" \"$PASSWORD\"")
+ output("2 OK [CAPABILITY $postAuthCapabilities] LOGIN completed")
+ }
+
+ private fun createImapSettings(host: String = "irrelevant"): ImapSettings {
+ return SimpleImapSettings(
+ host = host,
+ port = 143,
+ authType = AuthType.PLAIN,
+ username = "irrelevant"
+ )
+ }
+}
+
+class TestTokenProvider : OAuth2TokenProvider {
+ private var invalidationCount = 0
+
+ override fun getToken(timeoutMillis: Long): String {
+ assertThat(timeoutMillis).isEqualTo(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
+
+ return when (invalidationCount) {
+ 0 -> XOAUTH_TOKEN
+ 1 -> XOAUTH_TOKEN_2
+ else -> {
+ throw AuthenticationFailedException(
+ "Ran out of auth tokens. invalidateToken() called too often?"
+ )
+ }
+ }
+ }
+
+ override fun invalidateToken() {
+ invalidationCount++
+ }
+}
+
+private fun String.base64() = this.encodeUtf8().base64()
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderIdlerTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderIdlerTest.kt
new file mode 100644
index 0000000..98ff6f6
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderIdlerTest.kt
@@ -0,0 +1,344 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.assertThat
+import assertk.assertions.hasMessage
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFailure
+import assertk.assertions.isFalse
+import assertk.assertions.isGreaterThan
+import assertk.assertions.isInstanceOf
+import assertk.assertions.isTrue
+import com.fsck.k9.mail.AuthenticationFailedException
+import java.io.IOException
+import java.net.SocketException
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.concurrent.thread
+import org.junit.Test
+
+private const val FOLDER_SERVER_ID = "folder"
+private const val TEST_TIMEOUT_SECONDS = 5L
+private const val IDLE_TIMEOUT_MS = 28 * 60 * 1000L
+
+class RealImapFolderIdlerTest {
+ private val idleRefreshManager = TestIdleRefreshManager()
+ private val wakeLock = TestWakeLock(timeoutSeconds = TEST_TIMEOUT_SECONDS, isHeld = true)
+ private val imapConnection = TestImapConnection(timeout = TEST_TIMEOUT_SECONDS)
+ private val imapFolder = TestImapFolder(FOLDER_SERVER_ID, imapConnection)
+ private val imapStore = TestImapStore(imapFolder)
+ private val idleRefreshTimeoutProvider = object : IdleRefreshTimeoutProvider {
+ override val idleRefreshTimeoutMs = IDLE_TIMEOUT_MS
+ }
+ private val idler = RealImapFolderIdler(
+ idleRefreshManager,
+ wakeLock,
+ imapStore,
+ imapStore,
+ FOLDER_SERVER_ID,
+ idleRefreshTimeoutProvider
+ )
+
+ @Test
+ fun `new message during IDLE`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.SYNC)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueContinuationServerResponse()
+ imapConnection.enqueueUntaggedServerResponse("1 EXISTS")
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `flag change during IDLE`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.SYNC)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueContinuationServerResponse()
+ imapConnection.enqueueUntaggedServerResponse("42 FETCH (FLAGS (\\Seen))")
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `expunge during IDLE`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.SYNC)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueContinuationServerResponse()
+ imapConnection.enqueueUntaggedServerResponse("23 EXPUNGE")
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `refresh IDLE connection`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.SYNC)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ assertThat(wakeLock.isHeld).isTrue()
+ imapConnection.enqueueContinuationServerResponse()
+ wakeLock.waitForRelease()
+ idleRefreshManager.resetTimers()
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+
+ imapConnection.waitForCommand("IDLE")
+ assertThat(wakeLock.isHeld).isTrue()
+ imapConnection.enqueueContinuationServerResponse()
+ wakeLock.waitForRelease()
+ imapConnection.enqueueUntaggedServerResponse("1 EXISTS")
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ assertThat(wakeLock.isHeld).isTrue()
+ }
+
+ @Test
+ fun `stop ImapFolderIdler while IDLE`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.STOPPED)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueContinuationServerResponse()
+ wakeLock.waitForRelease()
+ idler.stop()
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `idle refresh timeout`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.STOPPED)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueContinuationServerResponse()
+ wakeLock.waitForRelease()
+ assertThat(idleRefreshManager.getTimeoutValue()).isEqualTo(IDLE_TIMEOUT_MS)
+ idler.stop()
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `socket read timeouts`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.STOPPED)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueContinuationServerResponse()
+ wakeLock.waitForRelease()
+ assertThat(imapConnection.currentSocketReadTimeout).isGreaterThan(IDLE_TIMEOUT_MS.toInt())
+ idler.stop()
+ imapConnection.waitForCommand("DONE")
+ assertThat(imapConnection.currentSocketReadTimeout).isEqualTo(imapConnection.defaultSocketReadTimeout)
+ imapConnection.enqueueTaggedServerResponse("OK")
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `IDLE not supported`() {
+ val latch = CountDownLatch(1)
+ imapConnection.setIdleNotSupported()
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.NOT_SUPPORTED)
+ latch.countDown()
+ }
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `authentication error`() {
+ val latch = CountDownLatch(1)
+ imapFolder.throwOnOpen { throw AuthenticationFailedException("Authentication failure for test") }
+
+ thread {
+ assertThat {
+ idler.idle()
+ }.isFailure()
+ .isInstanceOf(AuthenticationFailedException::class)
+ .hasMessage("Authentication failure for test")
+
+ latch.countDown()
+ }
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `network error on folder open`() {
+ val latch = CountDownLatch(1)
+ imapFolder.throwOnOpen { throw IOException("I/O error for test") }
+
+ thread {
+ assertThat {
+ idler.idle()
+ }.isFailure()
+ .isInstanceOf(IOException::class)
+ .hasMessage("I/O error for test")
+
+ latch.countDown()
+ }
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `network error on IDLE`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ assertThat {
+ idler.idle()
+ }.isFailure()
+ .isInstanceOf(IOException::class)
+ .hasMessage("Socket closed during IDLE")
+
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueContinuationServerResponse()
+ imapConnection.waitForBlockingRead()
+ imapConnection.throwOnRead { throw SocketException("Socket closed during IDLE") }
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `NO response to IDLE command`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.NOT_SUPPORTED)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueTaggedServerResponse("NO")
+
+ latch.awaitWithTimeout()
+ assertThat(imapFolder.isOpen).isFalse()
+ }
+
+ @Test
+ fun `irrelevant untagged response to IDLE command before continuation request`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.STOPPED)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueUntaggedServerResponse("OK irrelevant")
+ imapConnection.enqueueContinuationServerResponse()
+
+ wakeLock.waitForRelease()
+ idler.stop()
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+ latch.awaitWithTimeout()
+ }
+
+ @Test
+ fun `relevant untagged response to IDLE command before continuation request`() {
+ val latch = CountDownLatch(1)
+
+ thread {
+ val idleResult = idler.idle()
+
+ assertThat(idleResult).isEqualTo(IdleResult.SYNC)
+ latch.countDown()
+ }
+
+ imapConnection.waitForCommand("IDLE")
+ imapConnection.enqueueUntaggedServerResponse("1 EXISTS")
+ imapConnection.enqueueContinuationServerResponse()
+ imapConnection.waitForCommand("DONE")
+ imapConnection.enqueueTaggedServerResponse("OK")
+ latch.awaitWithTimeout()
+ }
+}
+
+private fun CountDownLatch.awaitWithTimeout() {
+ assertThat(await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Test timed out").isTrue()
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt
new file mode 100644
index 0000000..dad30e1
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt
@@ -0,0 +1,1235 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.all
+import assertk.assertThat
+import assertk.assertions.cause
+import assertk.assertions.containsExactly
+import assertk.assertions.hasMessage
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFailure
+import assertk.assertions.isInstanceOf
+import assertk.assertions.isNotNull
+import com.fsck.k9.mail.Body
+import com.fsck.k9.mail.DefaultBodyFactory
+import com.fsck.k9.mail.FetchProfile
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.MessageRetrievalListener
+import com.fsck.k9.mail.MessagingException
+import com.fsck.k9.mail.Part
+import com.fsck.k9.mail.internet.BinaryTempFileBody
+import com.fsck.k9.mail.internet.MimeHeader
+import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import java.util.Date
+import java.util.TimeZone
+import okio.Buffer
+import org.apache.james.mime4j.util.MimeUtil
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import org.mockito.ArgumentMatchers.anySet
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.ArgumentMatchers.startsWith
+import org.mockito.Mockito.atLeastOnce
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+class RealImapFolderTest {
+ private val internalImapStore = object : InternalImapStore {
+ override val logLabel = "Account"
+ override fun getCombinedPrefix() = ""
+ override fun getPermanentFlagsIndex() = mutableSetOf<Flag>()
+ }
+ private val imapConnection = mock<ImapConnection>()
+ private val testConnectionManager = TestConnectionManager(imapConnection)
+
+ private lateinit var tempDirectory: File
+
+ @Before
+ fun setUp() {
+ tempDirectory = Files.createTempDirectory("RealImapFolderTest").toFile()
+ BinaryTempFileBody.setTempDirectory(tempDirectory)
+ }
+
+ @After
+ fun tearDown() {
+ tempDirectory.deleteRecursively()
+ }
+
+ @Test
+ fun open_readWrite_shouldOpenFolder() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ assertTrue(imapFolder.isOpen)
+ }
+
+ @Test
+ fun open_readOnly_shouldOpenFolder() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+
+ imapFolder.open(OpenMode.READ_ONLY)
+
+ assertTrue(imapFolder.isOpen)
+ }
+
+ @Test
+ fun open_shouldFetchMessageCount() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ assertEquals(23, imapFolder.messageCount)
+ }
+
+ @Test
+ fun open_readWrite_shouldMakeGetModeReturnReadWrite() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ assertEquals(OpenMode.READ_WRITE, imapFolder.mode)
+ }
+
+ @Test
+ fun open_readOnly_shouldMakeGetModeReturnReadOnly() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+
+ imapFolder.open(OpenMode.READ_ONLY)
+
+ assertEquals(OpenMode.READ_ONLY, imapFolder.mode)
+ }
+
+ @Test
+ fun open_shouldMakeExistReturnTrueWithoutExecutingAdditionalCommands() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ assertTrue(imapFolder.exists())
+ verify(imapConnection, times(1)).executeSimpleCommand(anyString())
+ }
+
+ @Test
+ fun open_calledTwice_shouldReuseSameImapConnection() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ assertThat(testConnectionManager.numberOfGetConnectionCalls).isEqualTo(1)
+ }
+
+ @Test
+ fun open_withConnectionThrowingOnReUse_shouldCreateNewImapConnection() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ doThrow(IOException::class).whenever(imapConnection).executeSimpleCommand(Commands.NOOP)
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ assertThat(testConnectionManager.numberOfGetConnectionCalls).isEqualTo(2)
+ }
+
+ @Test
+ fun open_withIoException_shouldThrowMessagingException() {
+ val imapFolder = createFolder("Folder")
+ doThrow(IOException::class).whenever(imapConnection).executeSimpleCommand("SELECT \"Folder\"")
+
+ assertThat {
+ imapFolder.open(OpenMode.READ_WRITE)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class).cause().isNotNull().isInstanceOf(IOException::class)
+ }
+
+ @Test
+ fun open_withMessagingException_shouldThrowMessagingException() {
+ val imapFolder = createFolder("Folder")
+ doThrow(MessagingException::class).whenever(imapConnection).executeSimpleCommand("SELECT \"Folder\"")
+
+ assertThat {
+ imapFolder.open(OpenMode.READ_WRITE)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ }
+
+ @Test
+ fun open_withoutExistsResponse_shouldThrowMessagingException() {
+ val imapFolder = createFolder("Folder")
+ val selectResponses = listOf(
+ createImapResponse("* OK [UIDNEXT 57576] Predicted next UID"),
+ createImapResponse("2 OK [READ-WRITE] Select completed.")
+ )
+ whenever(imapConnection.executeSimpleCommand("SELECT \"Folder\"")).thenReturn(selectResponses)
+
+ assertThat {
+ imapFolder.open(OpenMode.READ_WRITE)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Did not find message count during open")
+ }
+
+ @Test
+ fun close_shouldCloseImapFolder() {
+ val imapFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ imapFolder.open(OpenMode.READ_WRITE)
+
+ imapFolder.close()
+
+ assertFalse(imapFolder.isOpen)
+ }
+
+ @Test
+ fun exists_withClosedFolder_shouldOpenConnectionAndIssueStatusCommand() {
+ val imapFolder = createFolder("Folder")
+
+ imapFolder.exists()
+
+ verify(imapConnection).executeSimpleCommand("STATUS \"Folder\" (UIDVALIDITY)")
+ }
+
+ @Test
+ fun exists_withoutNegativeImapResponse_shouldReturnTrue() {
+ val imapFolder = createFolder("Folder")
+
+ val folderExists = imapFolder.exists()
+
+ assertTrue(folderExists)
+ }
+
+ @Test
+ fun exists_withNegativeImapResponse_shouldReturnFalse() {
+ val imapFolder = createFolder("Folder")
+ doThrow(NegativeImapResponseException::class)
+ .whenever(imapConnection).executeSimpleCommand("STATUS \"Folder\" (UIDVALIDITY)")
+
+ val folderExists = imapFolder.exists()
+
+ assertFalse(folderExists)
+ }
+
+ @Test
+ fun create_withClosedFolder_shouldOpenConnectionAndIssueCreateCommand() {
+ val imapFolder = createFolder("Folder")
+
+ imapFolder.create()
+
+ verify(imapConnection).executeSimpleCommand("CREATE \"Folder\"")
+ }
+
+ @Test
+ fun create_withoutNegativeImapResponse_shouldReturnTrue() {
+ val imapFolder = createFolder("Folder")
+
+ val success = imapFolder.create()
+
+ assertTrue(success)
+ }
+
+ @Test
+ fun create_withNegativeImapResponse_shouldReturnFalse() {
+ val imapFolder = createFolder("Folder")
+ doThrow(NegativeImapResponseException::class).whenever(imapConnection).executeSimpleCommand("CREATE \"Folder\"")
+
+ val success = imapFolder.create()
+
+ assertFalse(success)
+ }
+
+ @Test
+ fun copyMessages_withEmptyMessageList_shouldReturnNull() {
+ val sourceFolder = createFolder("Source")
+ val destinationFolder = createFolder("Destination")
+ val messages = emptyList<ImapMessage>()
+
+ val uidMapping = sourceFolder.copyMessages(messages, destinationFolder)
+
+ assertNull(uidMapping)
+ }
+
+ @Test
+ fun copyMessages_withClosedFolder_shouldThrow() {
+ val sourceFolder = createFolder("Source")
+ val destinationFolder = createFolder("Destination")
+ val messages = listOf(mock<ImapMessage>())
+
+ assertThat {
+ sourceFolder.copyMessages(messages, destinationFolder)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Folder Source is not open.")
+ }
+
+ @Test
+ fun copyMessages() {
+ val sourceFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ val destinationFolder = createFolder("Destination")
+ val messages = listOf(createImapMessage("1"))
+ setupCopyResponse("x OK [COPYUID 23 1 101] Success")
+ sourceFolder.open(OpenMode.READ_WRITE)
+
+ val uidMapping = sourceFolder.copyMessages(messages, destinationFolder)
+
+ assertNotNull(uidMapping)
+ assertEquals("101", uidMapping!!["1"])
+ }
+
+ @Test
+ fun moveMessages_shouldCopyMessages() {
+ val sourceFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ val destinationFolder = createFolder("Destination")
+ val messages = listOf(createImapMessage("1"))
+ setupCopyResponse("x OK [COPYUID 23 1 101] Success")
+ sourceFolder.open(OpenMode.READ_WRITE)
+
+ val uidMapping = sourceFolder.moveMessages(messages, destinationFolder)
+
+ assertNotNull(uidMapping)
+ assertEquals("101", uidMapping!!["1"])
+ }
+
+ @Test
+ fun moveMessages_shouldDeleteMessagesFromSourceFolder() {
+ val sourceFolder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ val destinationFolder = createFolder("Destination")
+ val messages = listOf(createImapMessage("1"))
+ sourceFolder.open(OpenMode.READ_WRITE)
+
+ sourceFolder.moveMessages(messages, destinationFolder)
+
+ assertCommandWithIdsIssued("UID STORE 1 +FLAGS.SILENT (\\Deleted)")
+ }
+
+ @Test
+ fun moveMessages_withEmptyMessageList_shouldReturnNull() {
+ val sourceFolder = createFolder("Source")
+ val destinationFolder = createFolder("Destination")
+ val messages = emptyList<ImapMessage>()
+
+ val uidMapping = sourceFolder.moveMessages(messages, destinationFolder)
+
+ assertNull(uidMapping)
+ }
+
+ @Test
+ fun getUnreadMessageCount_withClosedFolder_shouldThrow() {
+ val folder = createFolder("FolderName")
+
+ assertThat {
+ folder.unreadMessageCount
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Folder FolderName is not open.")
+ }
+
+ @Test
+ fun getUnreadMessageCount_connectionThrowsIOException_shouldThrowMessagingException() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ whenever(imapConnection.executeSimpleCommand("SEARCH 1:* UNSEEN NOT DELETED")).thenThrow(IOException())
+ folder.open(OpenMode.READ_WRITE)
+
+ assertThat {
+ folder.unreadMessageCount
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("IO Error")
+ }
+
+ @Test
+ fun getUnreadMessageCount() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ val imapResponses = listOf(createImapResponse("* SEARCH 1 2 3"))
+ whenever(imapConnection.executeSimpleCommand("SEARCH 1:* UNSEEN NOT DELETED")).thenReturn(imapResponses)
+ folder.open(OpenMode.READ_WRITE)
+
+ val unreadMessageCount = folder.unreadMessageCount
+
+ assertEquals(3, unreadMessageCount)
+ }
+
+ @Test
+ fun getFlaggedMessageCount_withClosedFolder_shouldThrow() {
+ val folder = createFolder("FolderName")
+
+ assertThat {
+ folder.flaggedMessageCount
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Folder FolderName is not open.")
+ }
+
+ @Test
+ fun getFlaggedMessageCount() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ val imapResponses = listOf(
+ createImapResponse("* SEARCH 1 2"),
+ createImapResponse("* SEARCH 23 42")
+ )
+ whenever(imapConnection.executeSimpleCommand("SEARCH 1:* FLAGGED NOT DELETED")).thenReturn(imapResponses)
+ folder.open(OpenMode.READ_WRITE)
+
+ val flaggedMessageCount = folder.flaggedMessageCount
+
+ assertEquals(4, flaggedMessageCount)
+ }
+
+ @Test
+ fun getHighestUid() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupUidSearchResponses("* SEARCH 42")
+ folder.open(OpenMode.READ_WRITE)
+
+ val highestUid = folder.highestUid
+
+ assertEquals(42L, highestUid)
+ }
+
+ @Test
+ fun getHighestUid_imapConnectionThrowsNegativesResponse_shouldReturnMinusOne() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ doThrow(NegativeImapResponseException::class).whenever(imapConnection).executeSimpleCommand("UID SEARCH *:*")
+ folder.open(OpenMode.READ_WRITE)
+
+ val highestUid = folder.highestUid
+
+ assertEquals(-1L, highestUid)
+ }
+
+ @Test
+ fun getHighestUid_imapConnectionThrowsIOException_shouldThrowMessagingException() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ doThrow(IOException::class).whenever(imapConnection).executeSimpleCommand("UID SEARCH *:*")
+ folder.open(OpenMode.READ_WRITE)
+
+ assertThat {
+ folder.highestUid
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("IO Error")
+ }
+
+ @Test
+ fun getMessages_withoutDateConstraint() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupUidSearchResponses("* SEARCH 3", "* SEARCH 5", "* SEARCH 6")
+ folder.open(OpenMode.READ_WRITE)
+
+ val messages = folder.getMessages(1, 10, null, null)
+
+ assertNotNull(messages)
+ assertEquals(setOf("3", "5", "6"), extractMessageUids(messages))
+ }
+
+ @Test
+ fun getMessages_withDateConstraint() {
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupUidSearchResponses("* SEARCH 47", "* SEARCH 18")
+ folder.open(OpenMode.READ_WRITE)
+
+ val messages = folder.getMessages(1, 10, Date(1454719826000L), null)
+
+ assertNotNull(messages)
+ assertEquals(setOf("18", "47"), extractMessageUids(messages))
+ }
+
+ @Test
+ fun getMessages_withListener_shouldCallListener() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupUidSearchResponses("* SEARCH 99")
+ folder.open(OpenMode.READ_WRITE)
+ val listener = createMessageRetrievalListener()
+
+ val messages = folder.getMessages(1, 10, null, listener)
+
+ verify(listener).messageFinished(messages[0])
+ verifyNoMoreInteractions(listener)
+ }
+
+ @Test
+ fun getMessages_withInvalidStartArgument_shouldThrow() {
+ val folder = createFolder("Folder")
+
+ assertThat {
+ folder.getMessages(0, 10, null, null)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Invalid message set 0 10")
+ }
+
+ @Test
+ fun getMessages_withInvalidEndArgument_shouldThrow() {
+ val folder = createFolder("Folder")
+
+ assertThat {
+ folder.getMessages(10, 0, null, null)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Invalid message set 10 0")
+ }
+
+ @Test
+ fun getMessages_withEndArgumentSmallerThanStartArgument_shouldThrow() {
+ val folder = createFolder("Folder")
+
+ assertThat {
+ folder.getMessages(10, 5, null, null)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Invalid message set 10 5")
+ }
+
+ @Test
+ fun getMessages_withClosedFolder_shouldThrow() {
+ val folder = createFolder("FolderName")
+
+ assertThat {
+ folder.getMessages(1, 5, null, null)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Folder FolderName is not open.")
+ }
+
+ @Test
+ fun getMessages_sequenceNumbers_withClosedFolder_shouldThrow() {
+ val folder = createFolder("FolderName")
+
+ assertThat {
+ folder.getMessages(setOf(1L, 2L, 5L), false, null)
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Folder FolderName is not open.")
+ }
+
+ @Test
+ fun getMessages_sequenceNumbers() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupUidSearchResponses("* SEARCH 17", "* SEARCH 18", "* SEARCH 49")
+ folder.open(OpenMode.READ_WRITE)
+
+ val messages = folder.getMessages(setOf(1L, 2L, 5L), false, null)
+
+ assertNotNull(messages)
+ assertEquals(setOf("17", "18", "49"), extractMessageUids(messages))
+ }
+
+ @Test
+ fun getMessages_sequenceNumbers_withListener_shouldCallListener() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupUidSearchResponses("* SEARCH 99")
+ folder.open(OpenMode.READ_WRITE)
+ val listener = createMessageRetrievalListener()
+
+ val messages = folder.getMessages(setOf(1L), true, listener)
+
+ verify(listener).messageFinished(messages[0])
+ verifyNoMoreInteractions(listener)
+ }
+
+ @Test
+ fun getMessagesFromUids_withClosedFolder_shouldThrow() {
+ val folder = createFolder("FolderName")
+
+ assertThat {
+ folder.getMessagesFromUids(listOf("11", "22", "25"))
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Folder FolderName is not open.")
+ }
+
+ @Test
+ fun getMessagesFromUids() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupUidSearchResponses("* SEARCH 11", "* SEARCH 22", "* SEARCH 25")
+ folder.open(OpenMode.READ_WRITE)
+
+ val messages = folder.getMessagesFromUids(listOf("11", "22", "25"))
+
+ assertNotNull(messages)
+ assertEquals(setOf("11", "22", "25"), extractMessageUids(messages))
+ }
+
+ @Test
+ fun areMoreMessagesAvailable_withClosedFolder_shouldThrow() {
+ val folder = createFolder("FolderName")
+
+ assertThat {
+ folder.areMoreMessagesAvailable(10, Date())
+ }.isFailure()
+ .isInstanceOf(MessagingException::class)
+ .hasMessage("Folder FolderName is not open.")
+ }
+
+ @Test
+ fun areMoreMessagesAvailable_withAdditionalMessages_shouldReturnTrue() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupSearchResponses("* SEARCH 42")
+ folder.open(OpenMode.READ_WRITE)
+
+ val areMoreMessagesAvailable = folder.areMoreMessagesAvailable(10, null)
+
+ assertTrue(areMoreMessagesAvailable)
+ }
+
+ @Test
+ fun areMoreMessagesAvailable_withoutAdditionalMessages_shouldReturnFalse() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupSearchResponses("1 OK SEARCH completed")
+ folder.open(OpenMode.READ_WRITE)
+
+ val areMoreMessagesAvailable = folder.areMoreMessagesAvailable(600, null)
+
+ assertFalse(areMoreMessagesAvailable)
+ }
+
+ @Test
+ fun areMoreMessagesAvailable_withIndexOfOne_shouldReturnFalseWithoutPerformingSearch() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ folder.open(OpenMode.READ_WRITE)
+
+ val areMoreMessagesAvailable = folder.areMoreMessagesAvailable(1, null)
+
+ assertFalse(areMoreMessagesAvailable)
+ // SELECT during OPEN and no more
+ verify(imapConnection, times(1)).executeSimpleCommand(anyString())
+ }
+
+ @Test
+ fun areMoreMessagesAvailable_withoutAdditionalMessages_shouldIssueSearchCommandsUntilAllMessagesSearched() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ setupSearchResponses("1 OK SEARCH Completed")
+ folder.open(OpenMode.READ_WRITE)
+
+ folder.areMoreMessagesAvailable(600, null)
+
+ assertCommandIssued("SEARCH 100:599 NOT DELETED")
+ assertCommandIssued("SEARCH 1:99 NOT DELETED")
+ }
+
+ @Test
+ fun fetch_withEmptyMessageListArgument_shouldDoNothing() {
+ val folder = createFolder("Folder")
+ val fetchProfile = createFetchProfile()
+
+ folder.fetch(emptyList(), fetchProfile, null, MAX_DOWNLOAD_SIZE)
+
+ assertThat(testConnectionManager.numberOfGetConnectionCalls).isEqualTo(0)
+ }
+
+ @Test
+ fun fetch_withFlagsFetchProfile_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK"))
+ val messages = createImapMessages("1")
+ val fetchProfile = createFetchProfile(FetchProfile.Item.FLAGS)
+
+ folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE)
+
+ verify(imapConnection).sendCommand("UID FETCH 1 (UID FLAGS)", false)
+ }
+
+ @Test
+ fun fetch_withEnvelopeFetchProfile_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK"))
+ val messages = createImapMessages("1")
+ val fetchProfile = createFetchProfile(FetchProfile.Item.ENVELOPE)
+
+ folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE)
+
+ verify(imapConnection).sendCommand(
+ "UID FETCH 1 (UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS " +
+ "(date subject from content-type to cc bcc reply-to message-id references in-reply-to " +
+ "list-unsubscribe sender X-K9mail-Identity Chat-Version)])",
+ false
+ )
+ }
+
+ @Test
+ fun fetch_withStructureFetchProfile_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK"))
+ val messages = createImapMessages("1")
+ val fetchProfile = createFetchProfile(FetchProfile.Item.STRUCTURE)
+
+ folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE)
+
+ verify(imapConnection).sendCommand("UID FETCH 1 (UID BODYSTRUCTURE)", false)
+ }
+
+ @Test
+ fun fetch_withStructureFetchProfile_shouldSetContentType() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ val bodyStructure = "(\"TEXT\" \"PLAIN\" (\"CHARSET\" \"US-ASCII\") NIL NIL \"7BIT\" 2279 48)"
+ whenever(imapConnection.readResponse(anyOrNull()))
+ .thenReturn(createImapResponse("* 1 FETCH (BODYSTRUCTURE $bodyStructure UID 1)"))
+ .thenReturn(createImapResponse("x OK"))
+ val messages = createImapMessages("1")
+ val fetchProfile = createFetchProfile(FetchProfile.Item.STRUCTURE)
+
+ folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE)
+
+ verify(messages[0]).setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain;\r\n CHARSET=US-ASCII")
+ }
+
+ @Test
+ fun `fetch() with simple content type parameter`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("text" "plain" ("name" "token") NIL NIL "7bit" 42 23)""",
+ headerName = MimeHeader.HEADER_CONTENT_TYPE,
+ expectedHeaderValue = "text/plain;\r\n name=token"
+ )
+ }
+
+ @Test
+ fun `fetch() with content type parameter that needs to be a quoted string`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("text" "plain" ("name" "one two three") NIL NIL "7bit" 42 23)""",
+ headerName = MimeHeader.HEADER_CONTENT_TYPE,
+ expectedHeaderValue = "text/plain;\r\n name=\"one two three\""
+ )
+ }
+
+ @Test
+ fun `fetch() with content type parameter that needs to be a quoted string with escaped characters`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("text" "plain" ("name" "one \"two\" three") NIL NIL "7bit" 42 23)""",
+ headerName = MimeHeader.HEADER_CONTENT_TYPE,
+ expectedHeaderValue = "text/plain;\r\n name=\"one \\\"two\\\" three\""
+ )
+ }
+
+ @Test
+ fun `fetch() with RFC 2231 encoded content type parameter`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("text" "plain" ("name*" "utf-8''filen%C3%A4me.ext") NIL NIL "7bit" 42 23)""",
+ headerName = MimeHeader.HEADER_CONTENT_TYPE,
+ expectedHeaderValue = "text/plain;\r\n name*=utf-8''filen%C3%A4me.ext"
+ )
+ }
+
+ @Test
+ fun `fetch() with UTF-8 encoded content type parameter`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("text" "plain" ("name" "filenäme.ext") NIL NIL "7bit" 42 23)""",
+ headerName = MimeHeader.HEADER_CONTENT_TYPE,
+ expectedHeaderValue = "text/plain;\r\n name=\"filenäme.ext\""
+ )
+ }
+
+ @Test
+ fun `fetch() with simple content disposition parameter`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ +
+ """("attachment" ("filename" "token")) NIL NIL)""",
+ headerName = MimeHeader.HEADER_CONTENT_DISPOSITION,
+ expectedHeaderValue = "attachment;\r\n filename=token;\r\n size=23"
+ )
+ }
+
+ @Test
+ fun `fetch() with content disposition parameter that needs to be a quoted string`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ +
+ """("attachment" ("filename" "one two three")) NIL NIL)""",
+ headerName = MimeHeader.HEADER_CONTENT_DISPOSITION,
+ expectedHeaderValue = "attachment;\r\n filename=\"one two three\";\r\n size=23"
+ )
+ }
+
+ @Test
+ fun `fetch() with content disposition parameter that needs to be a quoted string with escaped characters`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ +
+ """("attachment" ("filename" "one \"two\" three")) NIL NIL)""",
+ headerName = MimeHeader.HEADER_CONTENT_DISPOSITION,
+ expectedHeaderValue = "attachment;\r\n filename=\"one \\\"two\\\" three\";\r\n size=23"
+ )
+ }
+
+ @Test
+ fun `fetch() with RFC 2231 encoded content disposition parameter`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ +
+ """("attachment" ("filename*" "utf-8''filen%C3%A4me.ext")) NIL NIL)""",
+ headerName = MimeHeader.HEADER_CONTENT_DISPOSITION,
+ expectedHeaderValue = "attachment;\r\n filename*=utf-8''filen%C3%A4me.ext;\r\n size=23"
+ )
+ }
+
+ @Test
+ fun `fetch() with UTF-8 encoded content disposition parameter`() {
+ testHeaderFromBodyStructure(
+ bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ +
+ """("attachment" ("filename" "filenäme.ext")) NIL NIL)""",
+ headerName = MimeHeader.HEADER_CONTENT_DISPOSITION,
+ expectedHeaderValue = "attachment;\r\n filename=\"filenäme.ext\";\r\n size=23"
+ )
+ }
+
+ @Test
+ fun fetch_withBodySaneFetchProfile_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK"))
+ val messages = createImapMessages("1")
+ val fetchProfile = createFetchProfile(FetchProfile.Item.BODY_SANE)
+
+ folder.fetch(messages, fetchProfile, null, 4096)
+
+ verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[]<0.4096>)", false)
+ }
+
+ @Test
+ fun fetch_withBodySaneFetchProfileAndNoMaximumDownloadSize_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK"))
+ val messages = createImapMessages("1")
+ val fetchProfile = createFetchProfile(FetchProfile.Item.BODY_SANE)
+
+ folder.fetch(messages, fetchProfile, null, 0)
+
+ verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[])", false)
+ }
+
+ @Test
+ fun fetch_withBodyFetchProfileAndNoMaximumDownloadSize_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK"))
+ val messages = createImapMessages("1")
+ val fetchProfile = createFetchProfile(FetchProfile.Item.BODY)
+
+ folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE)
+
+ verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[])", false)
+ }
+
+ @Test
+ fun fetch_withFlagsFetchProfile_shouldSetFlags() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ val messages = createImapMessages("1")
+ val fetchProfile = createFetchProfile(FetchProfile.Item.FLAGS)
+ whenever(imapConnection.readResponse(anyOrNull()))
+ .thenReturn(createImapResponse("* 1 FETCH (FLAGS (\\Seen) UID 1)"))
+ .thenReturn(createImapResponse("x OK"))
+
+ folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE)
+
+ verify(messages[0]).setFlag(Flag.SEEN, true)
+ }
+
+ @Test
+ fun fetchPart_withTextSection_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ val message = createImapMessage("1")
+ val part = createPart("TEXT")
+ whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK"))
+
+ folder.fetchPart(message, part, mock(), 4096)
+
+ verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[TEXT]<0.4096>)", false)
+ }
+
+ @Test
+ fun fetchPart_withNonTextSection_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ val message = createImapMessage("1")
+ val part = createPart("1.1")
+ whenever(imapConnection.readResponse(anyOrNull())).thenReturn(createImapResponse("x OK"))
+
+ folder.fetchPart(message, part, mock(), MAX_DOWNLOAD_SIZE)
+
+ verify(imapConnection).sendCommand("UID FETCH 1 (UID BODY.PEEK[1.1])", false)
+ }
+
+ @Test
+ fun fetchPart_withTextSection_shouldProcessImapResponses() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ val message = createImapMessage("1")
+ val part = createPlainTextPart("1.1")
+ setupSingleFetchResponseToCallback()
+
+ folder.fetchPart(message, part, DefaultBodyFactory(), MAX_DOWNLOAD_SIZE)
+
+ val bodyArgumentCaptor = argumentCaptor<Body>()
+ verify(part).body = bodyArgumentCaptor.capture()
+ val body = bodyArgumentCaptor.firstValue
+ val buffer = Buffer()
+ body.writeTo(buffer.outputStream())
+ assertEquals("text", buffer.readUtf8())
+ }
+
+ @Test
+ fun appendMessages_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ folder.open(OpenMode.READ_WRITE)
+ val messages = listOf(createImapMessage("1"))
+ whenever(imapConnection.readResponse()).thenReturn(createImapResponse("x OK [APPENDUID 1 23]"))
+
+ folder.appendMessages(messages)
+
+ verify(imapConnection).sendCommand("APPEND \"Folder\" () {0}", false)
+ }
+
+ @Test
+ fun appendMessages_withNegativeResponse_shouldThrow() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ folder.open(OpenMode.READ_WRITE)
+ val messages = listOf(createImapMessage("1"))
+ whenever(imapConnection.readResponse()).thenReturn(createImapResponse("x NO Can't append to this folder"))
+
+ assertThat {
+ folder.appendMessages(messages)
+ }.isFailure()
+ .isInstanceOf(NegativeImapResponseException::class.java).all {
+ hasMessage("APPEND failed")
+ transform { it.lastResponse[0] }.isEqualTo("NO")
+ }
+ }
+
+ @Test
+ fun appendMessages_withBadResponse_shouldThrow() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ folder.open(OpenMode.READ_WRITE)
+ val messages = listOf(createImapMessage("1"))
+ whenever(imapConnection.readResponse()).thenReturn(createImapResponse("x BAD [TOOBIG] Message too large."))
+
+ assertThat {
+ folder.appendMessages(messages)
+ }.isFailure()
+ .isInstanceOf(NegativeImapResponseException::class.java).all {
+ hasMessage("APPEND failed")
+ transform { it.lastResponse[0] }.isEqualTo("BAD")
+ }
+ }
+
+ @Test
+ fun getUidFromMessageId_withMessageIdHeader_shouldIssueUidSearchCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ folder.open(OpenMode.READ_WRITE)
+ setupUidSearchResponses("1 OK SEARCH Completed")
+
+ folder.getUidFromMessageId("<00000000.0000000@example.org>")
+
+ assertCommandIssued("UID SEARCH HEADER MESSAGE-ID \"<00000000.0000000@example.org>\"")
+ }
+
+ @Test
+ fun getUidFromMessageId() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ folder.open(OpenMode.READ_WRITE)
+ setupUidSearchResponses("* SEARCH 23")
+
+ val uid = folder.getUidFromMessageId("<00000000.0000000@example.org>")
+
+ assertEquals("23", uid)
+ }
+
+ @Test
+ fun expunge_shouldIssueExpungeCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+
+ folder.expunge()
+
+ verify(imapConnection).executeSimpleCommand("EXPUNGE")
+ }
+
+ @Test
+ fun expungeUids_withUidPlus_shouldIssueUidExpungeCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ whenever(imapConnection.isUidPlusCapable).thenReturn(true)
+
+ folder.expungeUids(listOf("1"))
+
+ assertCommandWithIdsIssued("UID EXPUNGE 1")
+ }
+
+ @Test
+ fun expungeUids_withoutUidPlus_shouldIssueExpungeCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+ whenever(imapConnection.isUidPlusCapable).thenReturn(false)
+
+ folder.expungeUids(listOf("1"))
+
+ verify(imapConnection).executeSimpleCommand("EXPUNGE")
+ }
+
+ @Test
+ fun setFlags_shouldIssueUidStoreCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_WRITE)
+
+ folder.setFlags(setOf(Flag.SEEN), true)
+
+ assertCommandIssued("UID STORE 1:* +FLAGS.SILENT (\\Seen)")
+ }
+
+ @Test
+ fun search_withFullTextSearchEnabled_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ setupUidSearchResponses("1 OK SEARCH completed")
+
+ folder.search("query", setOf(Flag.SEEN), emptySet(), true)
+
+ assertCommandIssued("UID SEARCH TEXT \"query\" SEEN")
+ }
+
+ @Test
+ fun search_withFullTextSearchDisabled_shouldIssueRespectiveCommand() {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ setupUidSearchResponses("1 OK SEARCH completed")
+
+ folder.search("query", emptySet(), emptySet(), false)
+
+ assertCommandIssued("""UID SEARCH OR OR OR OR SUBJECT "query" FROM "query" TO "query" CC "query" BCC "query"""")
+ }
+
+ @Test
+ fun getMessageByUid_returnsNewImapMessageWithUid() {
+ val folder = createFolder("Folder")
+
+ val message = folder.getMessage("uid")
+
+ assertEquals("uid", message.uid)
+ }
+
+ @Suppress("SameParameterValue")
+ private fun createPlainTextPart(serverExtra: String): Part {
+ val part = createPart(serverExtra)
+ whenever(part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)).thenReturn(arrayOf(MimeUtil.ENC_7BIT))
+ whenever(part.getHeader(MimeHeader.HEADER_CONTENT_TYPE)).thenReturn(arrayOf("text/plain"))
+ return part
+ }
+
+ private fun setupSingleFetchResponseToCallback() {
+ whenever(imapConnection.readResponse(anyOrNull()))
+ .thenAnswer { invocation ->
+ val callback = invocation.arguments[0] as ImapResponseCallback
+ buildImapFetchResponse(callback)
+ }
+ .thenAnswer { invocation ->
+ val callback = invocation.arguments[0] as ImapResponseCallback
+ ImapResponse.newTaggedResponse(callback, "TAG")
+ }
+ }
+
+ private fun buildImapFetchResponse(callback: ImapResponseCallback): ImapResponse {
+ val response = ImapResponse.newContinuationRequest(callback)
+ response.add("1")
+ response.add("FETCH")
+ val fetchList = ImapList()
+ fetchList.add("UID")
+ fetchList.add("1")
+ fetchList.add("BODY")
+ fetchList.add("1.1")
+ fetchList.add("text")
+ response.add(fetchList)
+ return response
+ }
+
+ private fun extractMessageUids(messages: List<ImapMessage>) = messages.map { it.uid }.toSet()
+
+ private fun createFolder(folderName: String): RealImapFolder {
+ return RealImapFolder(internalImapStore, testConnectionManager, folderName, FolderNameCodec())
+ }
+
+ private fun createImapMessage(uid: String): ImapMessage {
+ return mock {
+ on { this.uid } doReturn uid
+ }
+ }
+
+ private fun createImapMessages(vararg uids: String) = uids.map { createImapMessage(it) }
+
+ private fun createPart(serverExtra: String): Part {
+ return mock {
+ on { this.serverExtra } doReturn serverExtra
+ }
+ }
+
+ private fun createFetchProfile(vararg items: FetchProfile.Item) = items.toCollection(FetchProfile())
+
+ private fun createMessageRetrievalListener() = mock<MessageRetrievalListener<ImapMessage>>()
+
+ private fun prepareImapFolderForOpen(openMode: OpenMode) {
+ val imapResponses = listOf(
+ createImapResponse("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft NonJunk \$MDNSent)"),
+ createImapResponse(
+ "* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft NonJunk \$MDNSent \\*)] " +
+ "Flags permitted."
+ ),
+ createImapResponse("* 23 EXISTS"),
+ createImapResponse("* 0 RECENT"),
+ createImapResponse("* OK [UIDVALIDITY 1125022061] UIDs valid"),
+ createImapResponse("* OK [UIDNEXT 57576] Predicted next UID"),
+ if (openMode == OpenMode.READ_WRITE) {
+ createImapResponse("2 OK [READ-WRITE] Select completed.")
+ } else {
+ createImapResponse("2 OK [READ-ONLY] Examine completed.")
+ }
+ )
+
+ if (openMode == OpenMode.READ_WRITE) {
+ whenever(imapConnection.executeSimpleCommand("SELECT \"Folder\"")).thenReturn(imapResponses)
+ } else {
+ whenever(imapConnection.executeSimpleCommand("EXAMINE \"Folder\"")).thenReturn(imapResponses)
+ }
+ }
+
+ private fun assertCommandWithIdsIssued(expectedCommand: String) {
+ val commandPrefixCaptor = argumentCaptor<String>()
+ val commandSuffixCaptor = argumentCaptor<String>()
+ val commandUidsCaptor = argumentCaptor<Set<Long>>()
+ verify(imapConnection, atLeastOnce()).executeCommandWithIdSet(
+ commandPrefixCaptor.capture(),
+ commandSuffixCaptor.capture(),
+ commandUidsCaptor.capture()
+ )
+
+ val commandPrefixes = commandPrefixCaptor.allValues
+ val commandSuffixes = commandSuffixCaptor.allValues
+ val commandUids = commandUidsCaptor.allValues
+
+ for (i in commandPrefixes.indices) {
+ val command = commandPrefixes[i] + " " + ImapUtility.join(",", commandUids[i]) +
+ if (commandSuffixes[i].isEmpty()) "" else " " + commandSuffixes[i]
+
+ if (command == expectedCommand) {
+ return
+ }
+ }
+
+ fail("Expected IMAP command not issued: $expectedCommand")
+ }
+
+ private fun assertCommandIssued(expectedCommand: String) {
+ verify(imapConnection, atLeastOnce()).executeSimpleCommand(expectedCommand)
+ }
+
+ private fun setupUidSearchResponses(vararg responses: String) {
+ val imapResponses = responses.map { createImapResponse(it) }
+ whenever(imapConnection.executeSimpleCommand(startsWith("UID SEARCH"))).thenReturn(imapResponses)
+ whenever(imapConnection.executeCommandWithIdSet(startsWith("UID SEARCH"), anyString(), anySet()))
+ .thenReturn(imapResponses)
+ }
+
+ private fun setupSearchResponses(vararg responses: String) {
+ val imapResponses = responses.map { createImapResponse(it) }
+ whenever(imapConnection.executeSimpleCommand(startsWith("SEARCH"))).thenReturn(imapResponses)
+ }
+
+ @Suppress("SameParameterValue")
+ private fun setupCopyResponse(response: String) {
+ val imapResponses = listOf(createImapResponse(response))
+ whenever(imapConnection.executeCommandWithIdSet(eq(Commands.UID_COPY), anyString(), anySet()))
+ .thenReturn(imapResponses)
+ }
+
+ private fun testHeaderFromBodyStructure(bodyStructure: String, headerName: String, expectedHeaderValue: String) {
+ val folder = createFolder("Folder")
+ prepareImapFolderForOpen(OpenMode.READ_ONLY)
+ folder.open(OpenMode.READ_ONLY)
+ whenever(imapConnection.readResponse(anyOrNull()))
+ .thenReturn(createImapResponse("* 1 FETCH (BODYSTRUCTURE $bodyStructure UID 1)"))
+ .thenReturn(createImapResponse("x OK"))
+ val imapMessage = ImapMessage("1")
+ val messages = listOf(imapMessage)
+ val fetchProfile = createFetchProfile(FetchProfile.Item.STRUCTURE)
+
+ folder.fetch(messages, fetchProfile, null, MAX_DOWNLOAD_SIZE)
+
+ assertThat(imapMessage.getHeader(headerName)).containsExactly(expectedHeaderValue)
+ }
+
+ companion object {
+ private const val MAX_DOWNLOAD_SIZE = -1
+ }
+}
+
+internal class TestConnectionManager(private val connection: ImapConnection) : ImapConnectionManager {
+ var numberOfGetConnectionCalls = 0
+ private set
+
+ override fun getConnection(): ImapConnection {
+ numberOfGetConnectionCalls++
+ return connection
+ }
+
+ override fun releaseConnection(connection: ImapConnection?) = Unit
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt
new file mode 100644
index 0000000..5c64b49
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt
@@ -0,0 +1,446 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.all
+import assertk.assertThat
+import assertk.assertions.cause
+import assertk.assertions.containsExactly
+import assertk.assertions.hasMessage
+import assertk.assertions.isFailure
+import assertk.assertions.isInstanceOf
+import assertk.assertions.isNotNull
+import assertk.assertions.isSameAs
+import com.fsck.k9.mail.AuthType
+import com.fsck.k9.mail.ConnectionSecurity
+import com.fsck.k9.mail.FolderType
+import com.fsck.k9.mail.MessagingException
+import com.fsck.k9.mail.ServerSettings
+import com.fsck.k9.mail.oauth.OAuth2TokenProvider
+import com.fsck.k9.mail.ssl.TrustedSocketFactory
+import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse
+import com.fsck.k9.mail.store.imap.ImapStoreSettings.createExtra
+import java.io.IOException
+import java.util.ArrayDeque
+import java.util.Deque
+import org.junit.Test
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.verify
+
+class RealImapStoreTest {
+ private val imapStore = createTestImapStore()
+
+ @Test
+ fun `checkSettings() should create ImapConnection and call open()`() {
+ val imapConnection = createMockConnection()
+ imapStore.enqueueImapConnection(imapConnection)
+
+ imapStore.checkSettings()
+
+ verify(imapConnection).open()
+ }
+
+ @Test
+ fun `checkSettings() with open throwing should throw MessagingException`() {
+ val imapConnection = createMockConnection().stub {
+ on { open() } doThrow IOException::class
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ assertThat { imapStore.checkSettings() }.isFailure()
+ .isInstanceOf(MessagingException::class).all {
+ hasMessage("Unable to connect")
+ cause().isNotNull().isInstanceOf(IOException::class)
+ }
+ }
+
+ @Test
+ fun `getFolders() with SPECIAL-USE capability should return special FolderInfo`() {
+ val imapConnection = createMockConnection().stub {
+ on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
+ on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
+ on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf(
+ createImapResponse("""* LIST (\HasNoChildren) "/" "INBOX""""),
+ createImapResponse("""* LIST (\Noselect \HasChildren) "/" "[Gmail]""""),
+ createImapResponse("""* LIST (\HasNoChildren \All) "/" "[Gmail]/All Mail""""),
+ createImapResponse("""* LIST (\HasNoChildren \Drafts) "/" "[Gmail]/Drafts""""),
+ createImapResponse("""* LIST (\HasNoChildren \Important) "/" "[Gmail]/Important""""),
+ createImapResponse("""* LIST (\HasNoChildren \Sent) "/" "[Gmail]/Sent Mail""""),
+ createImapResponse("""* LIST (\HasNoChildren \Junk) "/" "[Gmail]/Spam""""),
+ createImapResponse("""* LIST (\HasNoChildren \Flagged) "/" "[Gmail]/Starred""""),
+ createImapResponse("""* LIST (\HasNoChildren \Trash) "/" "[Gmail]/Trash""""),
+ createImapResponse("5 OK Success")
+ )
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ val folders = imapStore.getFolders()
+
+ val foldersMap = folders.map { it.serverId to it.type }
+ assertThat(foldersMap).containsExactly(
+ "INBOX" to FolderType.INBOX,
+ "[Gmail]/All Mail" to FolderType.ARCHIVE,
+ "[Gmail]/Drafts" to FolderType.DRAFTS,
+ "[Gmail]/Important" to FolderType.REGULAR,
+ "[Gmail]/Sent Mail" to FolderType.SENT,
+ "[Gmail]/Spam" to FolderType.SPAM,
+ "[Gmail]/Starred" to FolderType.REGULAR,
+ "[Gmail]/Trash" to FolderType.TRASH
+ )
+ }
+
+ @Test
+ fun `getFolders() without SPECIAL-USE capability should use simple LIST command`() {
+ val imapConnection = createMockConnection().stub {
+ on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
+ on { hasCapability(Capabilities.SPECIAL_USE) } doReturn false
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ imapStore.getFolders()
+
+ verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""")
+ verify(imapConnection).executeSimpleCommand("""LIST "" "*"""")
+ }
+
+ @Test
+ fun `getFolders() without LIST-EXTENDED capability should use simple LIST command`() {
+ val imapConnection = createMockConnection().stub {
+ on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn false
+ on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ imapStore.getFolders()
+
+ verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""")
+ verify(imapConnection).executeSimpleCommand("""LIST "" "*"""")
+ }
+
+ @Test
+ fun `getFolders() with subscribedFoldersOnly = false`() {
+ val imapStore = createTestImapStore(isSubscribedFoldersOnly = false)
+ val imapConnection = createMockConnection().stub {
+ on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf(
+ createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""),
+ createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""),
+ createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""),
+ createImapResponse("6 OK Success")
+ )
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ val folders = imapStore.getFolders()
+
+ assertThat(folders).isNotNull()
+ assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder")
+ }
+
+ @Test
+ fun `getFolders() with subscribedFoldersOnly = true should only return existing subscribed folders`() {
+ val imapStore = createTestImapStore(isSubscribedFoldersOnly = true)
+ val imapConnection = createMockConnection().stub {
+ on { executeSimpleCommand("""LSUB "" "*"""") } doReturn listOf(
+ createImapResponse("""* LSUB (\HasNoChildren) "." "INBOX""""),
+ createImapResponse("""* LSUB (\Noselect \HasChildren) "." "Folder""""),
+ createImapResponse("""* LSUB (\HasNoChildren) "." "Folder.SubFolder""""),
+ createImapResponse("""* LSUB (\HasNoChildren) "." "SubscribedFolderThatHasBeenDeleted""""),
+ createImapResponse("5 OK Success")
+ )
+ on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf(
+ createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""),
+ createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""),
+ createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""),
+ createImapResponse("6 OK Success")
+ )
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ val folders = imapStore.getFolders()
+
+ assertThat(folders).isNotNull()
+ assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder")
+ }
+
+ @Test
+ fun `getFolders() with namespace prefix`() {
+ val imapConnection = createMockConnection().stub {
+ on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf(
+ createImapResponse("""* LIST () "." "INBOX""""),
+ createImapResponse("""* LIST () "." "INBOX.FolderOne""""),
+ createImapResponse("""* LIST () "." "INBOX.FolderTwo""""),
+ createImapResponse("5 OK Success")
+ )
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+ imapStore.setTestCombinedPrefix("INBOX.")
+
+ val folders = imapStore.getFolders()
+
+ assertThat(folders).isNotNull()
+ assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "INBOX.FolderTwo")
+ assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo")
+ assertThat(folders.map { it.oldServerId }).containsExactly("INBOX", "FolderOne", "FolderTwo")
+ }
+
+ @Test
+ fun `getFolders() with folder not matching namespace prefix`() {
+ val imapConnection = createMockConnection().stub {
+ on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf(
+ createImapResponse("""* LIST () "." "INBOX""""),
+ createImapResponse("""* LIST () "." "INBOX.FolderOne""""),
+ createImapResponse("""* LIST () "." "FolderTwo""""),
+ createImapResponse("5 OK Success")
+ )
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+ imapStore.setTestCombinedPrefix("INBOX.")
+
+ val folders = imapStore.getFolders()
+
+ assertThat(folders).isNotNull()
+ assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "FolderTwo")
+ assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo")
+ assertThat(folders.mapNotNull { it.oldServerId }).containsExactly("INBOX", "FolderOne")
+ }
+
+ @Test
+ fun `getFolders() with duplicate folder names should remove duplicates and keep FolderType`() {
+ val imapConnection = createMockConnection().stub {
+ on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
+ on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
+ on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf(
+ createImapResponse("""* LIST () "." "INBOX""""),
+ createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""),
+ createImapResponse("""* LIST (\Junk) "." "Junk""""),
+ createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""),
+ createImapResponse("5 OK Success")
+ )
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ val folders = imapStore.getFolders()
+
+ assertThat(folders.map { it.serverId to it.type }).containsExactly(
+ "INBOX" to FolderType.INBOX,
+ "Junk" to FolderType.SPAM
+ )
+ }
+
+ @Test
+ fun `getFolders() without exception should leave ImapConnection open`() {
+ val imapConnection = createMockConnection().stub {
+ on { executeSimpleCommand(anyString()) } doReturn listOf(createImapResponse("5 OK Success"))
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ imapStore.getFolders()
+
+ verify(imapConnection, never()).close()
+ }
+
+ @Test
+ fun `getFolders() with IOException should close ImapConnection`() {
+ val imapConnection = createMockConnection().stub {
+ on { executeSimpleCommand("""LIST "" "*"""") } doThrow IOException::class
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ assertThat { imapStore.getFolders() }.isFailure()
+ .isInstanceOf(MessagingException::class)
+
+ verify(imapConnection).close()
+ }
+
+ @Test
+ fun `getConnection() should create ImapConnection`() {
+ val imapConnection = createMockConnection()
+ imapStore.enqueueImapConnection(imapConnection)
+
+ val result = imapStore.getConnection()
+
+ assertThat(result).isSameAs(imapConnection)
+ }
+
+ @Test
+ fun `getConnection() called twice without release should create two ImapConnection instances`() {
+ val imapConnectionOne = createMockConnection()
+ val imapConnectionTwo = createMockConnection()
+ imapStore.enqueueImapConnection(imapConnectionOne)
+ imapStore.enqueueImapConnection(imapConnectionTwo)
+
+ val resultOne = imapStore.getConnection()
+ val resultTwo = imapStore.getConnection()
+
+ assertThat(resultOne).isSameAs(imapConnectionOne)
+ assertThat(resultTwo).isSameAs(imapConnectionTwo)
+ }
+
+ @Test
+ fun `getConnection() called after release should return cached ImapConnection`() {
+ val imapConnection = createMockConnection().stub {
+ on { isConnected } doReturn true
+ }
+ imapStore.enqueueImapConnection(imapConnection)
+
+ val connection = imapStore.getConnection()
+ imapStore.releaseConnection(connection)
+
+ val result = imapStore.getConnection()
+
+ assertThat(result).isSameAs(imapConnection)
+ }
+
+ @Test
+ fun `getConnection() called after release with closed connection should return new ImapConnection instance`() {
+ val imapConnectionOne = createMockConnection()
+ val imapConnectionTwo = createMockConnection()
+ imapStore.enqueueImapConnection(imapConnectionOne)
+ imapStore.enqueueImapConnection(imapConnectionTwo)
+
+ imapStore.getConnection()
+ imapConnectionOne.stub {
+ on { isConnected } doReturn false
+ }
+ imapStore.releaseConnection(imapConnectionOne)
+
+ val result = imapStore.getConnection()
+
+ assertThat(result).isSameAs(imapConnectionTwo)
+ }
+
+ @Test
+ fun `getConnection() with dead connection in pool should return new ImapConnection instance`() {
+ val imapConnectionOne = createMockConnection()
+ val imapConnectionTwo = createMockConnection()
+ imapStore.enqueueImapConnection(imapConnectionOne)
+ imapStore.enqueueImapConnection(imapConnectionTwo)
+
+ imapStore.getConnection()
+ imapConnectionOne.stub {
+ on { isConnected } doReturn true
+ on { executeSimpleCommand(Commands.NOOP) } doThrow IOException::class
+ }
+ imapStore.releaseConnection(imapConnectionOne)
+
+ val result = imapStore.getConnection()
+
+ assertThat(result).isSameAs(imapConnectionTwo)
+ }
+
+ @Test
+ fun `getConnection() with connection in pool and closeAllConnections() should return new ImapConnection instance`() {
+ val imapConnectionOne = createMockConnection(1)
+ val imapConnectionTwo = createMockConnection(2)
+ imapStore.enqueueImapConnection(imapConnectionOne)
+ imapStore.enqueueImapConnection(imapConnectionTwo)
+
+ imapStore.getConnection()
+ imapConnectionOne.stub {
+ on { isConnected } doReturn true
+ }
+ imapStore.releaseConnection(imapConnectionOne)
+ imapStore.closeAllConnections()
+
+ val result = imapStore.getConnection()
+
+ assertThat(result).isSameAs(imapConnectionTwo)
+ }
+
+ @Test
+ fun `getConnection() with connection outside of pool and closeAllConnections() should return new ImapConnection instance`() {
+ val imapConnectionOne = createMockConnection(1)
+ val imapConnectionTwo = createMockConnection(2)
+ imapStore.enqueueImapConnection(imapConnectionOne)
+ imapStore.enqueueImapConnection(imapConnectionTwo)
+
+ imapStore.getConnection()
+ imapConnectionOne.stub {
+ on { isConnected } doReturn true
+ }
+ imapStore.closeAllConnections()
+ imapStore.releaseConnection(imapConnectionOne)
+
+ val result = imapStore.getConnection()
+
+ assertThat(result).isSameAs(imapConnectionTwo)
+ }
+
+ private fun createMockConnection(connectionGeneration: Int = 1): ImapConnection {
+ return mock {
+ on { this.connectionGeneration } doReturn connectionGeneration
+ }
+ }
+
+ private fun createServerSettings(): ServerSettings {
+ return ServerSettings(
+ type = "imap",
+ host = "imap.example.org",
+ port = 143,
+ connectionSecurity = ConnectionSecurity.NONE,
+ authenticationType = AuthType.PLAIN,
+ username = "user",
+ password = "password",
+ clientCertificateAlias = null,
+ extra = createExtra(autoDetectNamespace = true, pathPrefix = null)
+ )
+ }
+
+ private fun createTestImapStore(
+ isSubscribedFoldersOnly: Boolean = false,
+ useCompression: Boolean = false
+ ): TestImapStore {
+ return TestImapStore(
+ serverSettings = createServerSettings(),
+ config = createImapStoreConfig(isSubscribedFoldersOnly, useCompression),
+ trustedSocketFactory = mock(),
+ oauth2TokenProvider = null
+ )
+ }
+
+ private fun createImapStoreConfig(isSubscribedFoldersOnly: Boolean, useCompression: Boolean): ImapStoreConfig {
+ return object : ImapStoreConfig {
+ override val logLabel: String = "irrelevant"
+ override fun isSubscribedFoldersOnly(): Boolean = isSubscribedFoldersOnly
+ override fun useCompression(): Boolean = useCompression
+ }
+ }
+
+ private class TestImapStore(
+ serverSettings: ServerSettings,
+ config: ImapStoreConfig,
+ trustedSocketFactory: TrustedSocketFactory,
+ oauth2TokenProvider: OAuth2TokenProvider?
+ ) : RealImapStore(
+ serverSettings,
+ config,
+ trustedSocketFactory,
+ oauth2TokenProvider
+ ) {
+ private val imapConnections: Deque<ImapConnection> = ArrayDeque()
+ private var testCombinedPrefix: String? = null
+
+ override fun createImapConnection(): ImapConnection {
+ if (imapConnections.isEmpty()) {
+ throw AssertionError("Unexpectedly tried to create an ImapConnection instance")
+ }
+
+ return imapConnections.pop()
+ }
+
+ fun enqueueImapConnection(imapConnection: ImapConnection) {
+ imapConnections.add(imapConnection)
+ }
+
+ override fun getCombinedPrefix(): String {
+ return testCombinedPrefix ?: super.getCombinedPrefix()
+ }
+
+ fun setTestCombinedPrefix(prefix: String?) {
+ testCombinedPrefix = prefix
+ }
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractorTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractorTest.java
new file mode 100644
index 0000000..df5b92d
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractorTest.java
@@ -0,0 +1,38 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import org.junit.Test;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+
+public class ResponseCodeExtractorTest {
+ @Test
+ public void getResponseCode_withResponseCode() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED] No sir");
+
+ String result = ResponseCodeExtractor.getResponseCode(imapResponse);
+
+ assertEquals("AUTHENTICATIONFAILED", result);
+ }
+
+ @Test
+ public void getResponseCode_withoutResponseCode() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO Authentication failed");
+
+ String result = ResponseCodeExtractor.getResponseCode(imapResponse);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void getResponseCode_withoutSingleItemResponse() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x NO");
+
+ String result = ResponseCodeExtractor.getResponseCode(imapResponse);
+
+ assertNull(result);
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseTextExtractorTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseTextExtractorTest.kt
new file mode 100644
index 0000000..f40e77f
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ResponseTextExtractorTest.kt
@@ -0,0 +1,45 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNull
+import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse
+import org.junit.Test
+
+class ResponseTextExtractorTest {
+ @Test
+ fun `response with response code and response text`() {
+ val imapResponse: ImapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED] Authentication error #23")
+
+ val result = ResponseTextExtractor.getResponseText(imapResponse)
+
+ assertThat(result).isEqualTo("Authentication error #23")
+ }
+
+ @Test
+ fun `response with only response text`() {
+ val imapResponse: ImapResponse = createImapResponse("x NO AUTHENTICATE failed")
+
+ val result = ResponseTextExtractor.getResponseText(imapResponse)
+
+ assertThat(result).isEqualTo("AUTHENTICATE failed")
+ }
+
+ @Test
+ fun `response without response code or text`() {
+ val imapResponse: ImapResponse = createImapResponse("x NO")
+
+ val result = ResponseTextExtractor.getResponseText(imapResponse)
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `response with only a response code`() {
+ val imapResponse: ImapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED]")
+
+ val result = ResponseTextExtractor.getResponseText(imapResponse)
+
+ assertThat(result).isNull()
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SearchResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SearchResponseTest.java
new file mode 100644
index 0000000..ec7bc2c
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SearchResponseTest.java
@@ -0,0 +1,89 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.Test;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+
+public class SearchResponseTest {
+ @Test
+ public void parse_withSingleSearchResponse_shouldExtractNumbers() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList(
+ "* SEARCH 1 2 3",
+ "* 23 EXISTS",
+ "* SEARCH 4",
+ "1 OK SEARCH completed");
+
+ SearchResponse result = SearchResponse.parse(imapResponses);
+
+ assertNotNull(result);
+ assertEquals(asList(1L, 2L, 3L, 4L), result.getNumbers());
+ }
+
+ @Test
+ public void parse_withMultipleSearchResponses_shouldExtractNumbers() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList(
+ "* SEARCH 1 2 3",
+ "* 23 EXISTS",
+ "* SEARCH 4",
+ "1 OK SEARCH completed",
+ "* SEARCH 5 6",
+ "* 19 EXPUNGED",
+ "* SEARCH 7",
+ "2 OK SEARCH completed",
+ "* SEARCH 8",
+ "3 OK SEARCH completed");
+
+ SearchResponse result = SearchResponse.parse(imapResponses);
+
+ assertNotNull(result);
+ assertEquals(asList(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L), result.getNumbers());
+ }
+
+ @Test
+ public void parse_withSingleTaggedSearchResponse_shouldReturnEmptyList() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x SEARCH 7 8 9");
+
+ SearchResponse result = SearchResponse.parse(imapResponses);
+
+ assertNotNull(result);
+ assertEquals(Collections.emptyList(), result.getNumbers());
+ }
+
+ @Test
+ public void parse_withSingleTooShortResponse_shouldReturnEmptyList() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("* SEARCH");
+
+ SearchResponse result = SearchResponse.parse(imapResponses);
+
+ assertNotNull(result);
+ assertEquals(Collections.emptyList(), result.getNumbers());
+ }
+
+ @Test
+ public void parse_withSingleNoSearchResponse_shouldReturnEmptyList() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("* 23 EXPUNGE");
+
+ SearchResponse result = SearchResponse.parse(imapResponses);
+
+ assertNotNull(result);
+ assertEquals(Collections.emptyList(), result.getNumbers());
+ }
+
+ @Test
+ public void parse_withSingleSearchResponseContainingInvalidNumber_shouldReturnEmptyList() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("* SEARCH A");
+
+ SearchResponse result = SearchResponse.parse(imapResponses);
+
+ assertNotNull(result);
+ assertEquals(Collections.emptyList(), result.getNumbers());
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponseTest.java
new file mode 100644
index 0000000..568f35c
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponseTest.java
@@ -0,0 +1,96 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import org.junit.Test;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+
+public class SelectOrExamineResponseTest {
+ @Test
+ public void parse_withSelectResponse_shouldReturnOpenModeReadWrite() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x OK [READ-WRITE] Select completed.");
+
+ SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
+
+ assertNotNull(result);
+ assertEquals(true, result.hasOpenMode());
+ assertEquals(OpenMode.READ_WRITE, result.getOpenMode());
+ }
+
+ @Test
+ public void parse_withExamineResponse_shouldReturnOpenModeReadOnly() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x OK [READ-ONLY] Examine completed.");
+
+ SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
+
+ assertNotNull(result);
+ assertEquals(true, result.hasOpenMode());
+ assertEquals(OpenMode.READ_ONLY, result.getOpenMode());
+ }
+
+ @Test
+ public void parse_withoutResponseCode_shouldReturnHasOpenModeFalse() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x OK Select completed.");
+
+ SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
+
+ assertNotNull(result);
+ assertEquals(false, result.hasOpenMode());
+ }
+
+ @Test
+ public void getOpenMode_withoutResponseCode_shouldThrow() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x OK Select completed.");
+ SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
+ assertNotNull(result);
+
+ try {
+ result.getOpenMode();
+ fail("Expected exception");
+ } catch (IllegalStateException ignored) {
+ }
+ }
+
+ @Test
+ public void parse_withInvalidResponseText_shouldReturnHasOpenModeFalse() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x OK [()] Examine completed.");
+
+ SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
+
+ assertNotNull(result);
+ assertEquals(false, result.hasOpenMode());
+ }
+
+ @Test
+ public void parse_withUnknownResponseText_shouldReturnHasOpenModeFalse() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x OK [FUNKY] Examine completed.");
+
+ SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
+
+ assertNotNull(result);
+ assertEquals(false, result.hasOpenMode());
+ }
+
+ @Test
+ public void parse_withUntaggedResponse_shouldReturnNull() throws Exception {
+ ImapResponse imapResponse = createImapResponse("* OK [READ-WRITE] Select completed.");
+
+ SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutOkResponse_shouldReturnNull() throws Exception {
+ ImapResponse imapResponse = createImapResponse("x BYE");
+
+ SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
+
+ assertNull(result);
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt
new file mode 100644
index 0000000..5e75c23
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt
@@ -0,0 +1,21 @@
+package com.fsck.k9.mail.store.imap
+
+import com.fsck.k9.mail.AuthType
+import com.fsck.k9.mail.ConnectionSecurity
+
+internal class SimpleImapSettings(
+ override val host: String,
+ override val port: Int = 0,
+ override val connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE,
+ override val authType: AuthType,
+ override val username: String,
+ override val password: String? = null,
+ override val useCompression: Boolean = false
+) : ImapSettings {
+ override val clientCertificateAlias: String? = null
+
+ override var pathPrefix: String? = null
+ override var pathDelimiter: String? = null
+
+ override fun setCombinedPrefix(prefix: String?) = Unit
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestIdleRefreshManager.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestIdleRefreshManager.kt
new file mode 100644
index 0000000..ce764be
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestIdleRefreshManager.kt
@@ -0,0 +1,35 @@
+package com.fsck.k9.mail.store.imap
+
+class TestIdleRefreshManager : IdleRefreshManager {
+ private val timers = mutableListOf<TestIdleRefreshTimer>()
+
+ @Synchronized
+ override fun startTimer(timeout: Long, callback: () -> Unit): TestIdleRefreshTimer {
+ return TestIdleRefreshTimer(timeout, callback).also { timer -> timers.add(timer) }
+ }
+
+ @Synchronized
+ override fun resetTimers() {
+ for (timer in timers) {
+ timer.trigger()
+ }
+ timers.clear()
+ }
+
+ fun getTimeoutValue(): Long? = timers.map { it.timeout }.minOrNull()
+}
+
+class TestIdleRefreshTimer(val timeout: Long, private val callback: () -> Unit) : IdleRefreshTimer {
+ override var isWaiting: Boolean = true
+ private set
+
+ @Synchronized
+ override fun cancel() {
+ isWaiting = false
+ }
+
+ fun trigger() {
+ isWaiting = false
+ callback()
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapConnection.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapConnection.kt
new file mode 100644
index 0000000..5bfd672
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapConnection.kt
@@ -0,0 +1,135 @@
+package com.fsck.k9.mail.store.imap
+
+import java.io.OutputStream
+import java.util.concurrent.LinkedBlockingDeque
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+
+internal open class TestImapConnection(val timeout: Long, override val connectionGeneration: Int = 1) : ImapConnection {
+ override val logId: String = "testConnection"
+ override var isConnected: Boolean = false
+ protected set
+ override val outputStream: OutputStream
+ get() = TODO("Not yet implemented")
+ override val isUidPlusCapable: Boolean = true
+ override var isIdleCapable: Boolean = true
+ protected set
+
+ val defaultSocketReadTimeout = 30 * 1000
+ var currentSocketReadTimeout = defaultSocketReadTimeout
+ protected set
+
+ @Volatile
+ private var tag: Int = 0
+
+ private val receivedCommands = LinkedBlockingDeque<String>()
+ private val responses = LinkedBlockingDeque<Response>()
+
+ private val readResponseLock = ReentrantLock()
+ private val readResponseLockCondition = readResponseLock.newCondition()
+
+ override fun open() {
+ isConnected = true
+ }
+
+ override fun close() {
+ isConnected = false
+ }
+
+ override fun hasCapability(capability: String): Boolean {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun executeSimpleCommand(command: String): List<ImapResponse> {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun executeCommandWithIdSet(
+ commandPrefix: String,
+ commandSuffix: String,
+ ids: Set<Long>
+ ): List<ImapResponse> {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun sendCommand(command: String, sensitive: Boolean): String {
+ val tag = ++tag
+ println(">>> $tag $command")
+
+ receivedCommands.add(command)
+ return tag.toString()
+ }
+
+ override fun sendContinuation(continuation: String) {
+ println(">>> $continuation")
+ receivedCommands.add(continuation)
+ }
+
+ override fun readResponse(): ImapResponse {
+ readResponseLock.withLock {
+ readResponseLockCondition.signal()
+ }
+ val imapResponse = when (val response = responses.take()) {
+ is Response.Continuation -> ImapResponseHelper.createImapResponse("+ ${response.text}")
+ is Response.Tagged -> ImapResponseHelper.createImapResponse("$tag ${response.response}")
+ is Response.Untagged -> ImapResponseHelper.createImapResponse("* ${response.response}")
+ is Response.Action -> response.action()
+ }
+
+ println("<<< $imapResponse")
+
+ return imapResponse
+ }
+
+ override fun readResponse(callback: ImapResponseCallback?): ImapResponse {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun setSocketDefaultReadTimeout() {
+ currentSocketReadTimeout = defaultSocketReadTimeout
+ }
+
+ override fun setSocketReadTimeout(timeout: Int) {
+ currentSocketReadTimeout = timeout
+ }
+
+ fun waitForCommand(command: String) {
+ do {
+ val receivedCommand = receivedCommands.poll(timeout, TimeUnit.SECONDS) ?: throw AssertionError("Timeout")
+ } while (receivedCommand != command)
+ }
+
+ fun waitForBlockingRead() {
+ readResponseLock.withLock {
+ readResponseLockCondition.await(timeout, TimeUnit.SECONDS)
+ }
+ }
+
+ fun throwOnRead(block: () -> Nothing) {
+ responses.add(Response.Action(block))
+ }
+
+ fun enqueueTaggedServerResponse(response: String) {
+ responses.add(Response.Tagged(response))
+ }
+
+ fun enqueueUntaggedServerResponse(response: String) {
+ responses.add(Response.Untagged(response))
+ }
+
+ fun enqueueContinuationServerResponse(text: String = "") {
+ responses.add(Response.Continuation(text))
+ }
+
+ fun setIdleNotSupported() {
+ isIdleCapable = false
+ }
+}
+
+private sealed class Response {
+ class Tagged(val response: String) : Response()
+ class Untagged(val response: String) : Response()
+ class Continuation(val text: String) : Response()
+ class Action(val action: () -> ImapResponse) : Response()
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapFolder.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapFolder.kt
new file mode 100644
index 0000000..495280a
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapFolder.kt
@@ -0,0 +1,126 @@
+package com.fsck.k9.mail.store.imap
+
+import com.fsck.k9.mail.BodyFactory
+import com.fsck.k9.mail.FetchProfile
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.Message
+import com.fsck.k9.mail.MessageRetrievalListener
+import com.fsck.k9.mail.Part
+import java.util.Date
+
+internal open class TestImapFolder(
+ override val serverId: String,
+ val connection: TestImapConnection
+) : ImapFolder {
+ override var mode: OpenMode? = null
+ protected set
+
+ override var messageCount: Int = 0
+ protected set
+
+ var isOpen: Boolean = false
+ protected set
+
+ private var openAction: () -> Unit = {}
+
+ override fun exists(): Boolean {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun open(mode: OpenMode) {
+ openAction()
+ this.mode = mode
+ connection.open()
+ isOpen = true
+ }
+
+ override fun close() {
+ connection.close()
+ isOpen = false
+ mode = null
+ }
+
+ override fun getUidValidity(): Long? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun getMessage(uid: String): ImapMessage {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun getUidFromMessageId(messageId: String): String? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun getMessages(
+ start: Int,
+ end: Int,
+ earliestDate: Date?,
+ listener: MessageRetrievalListener<ImapMessage>?
+ ): List<ImapMessage> {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun fetch(
+ messages: List<ImapMessage>,
+ fetchProfile: FetchProfile,
+ listener: FetchListener?,
+ maxDownloadSize: Int
+ ) {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun fetchPart(
+ message: ImapMessage,
+ part: Part,
+ bodyFactory: BodyFactory,
+ maxDownloadSize: Int
+ ) {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun search(
+ queryString: String?,
+ requiredFlags: Set<Flag>?,
+ forbiddenFlags: Set<Flag>?,
+ performFullTextSearch: Boolean
+ ): List<ImapMessage> {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun appendMessages(messages: List<Message>): Map<String, String>? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun setFlags(flags: Set<Flag>, value: Boolean) {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun setFlags(messages: List<ImapMessage>, flags: Set<Flag>, value: Boolean) {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun copyMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun moveMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun expunge() {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun expungeUids(uids: List<String>) {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ fun throwOnOpen(block: () -> Nothing) {
+ openAction = block
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapStore.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapStore.kt
new file mode 100644
index 0000000..a347ead
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestImapStore.kt
@@ -0,0 +1,24 @@
+package com.fsck.k9.mail.store.imap
+
+internal class TestImapStore(private val folder: ImapFolder) : ImapStore, ImapConnectionProvider {
+ override fun checkSettings() {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun getFolder(name: String): ImapFolder {
+ return folder
+ }
+
+ override fun getFolders(): List<FolderListItem> {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun getConnection(folder: ImapFolder): ImapConnection {
+ if (folder !is TestImapFolder) throw AssertionError("getConnection() called with unknown ImapFolder instance")
+ return folder.connection
+ }
+
+ override fun closeAllConnections() {
+ throw UnsupportedOperationException("not implemented")
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestWakeLock.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestWakeLock.kt
new file mode 100644
index 0000000..4beb1e5
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/TestWakeLock.kt
@@ -0,0 +1,47 @@
+package com.fsck.k9.mail.store.imap
+
+import com.fsck.k9.mail.power.WakeLock
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+
+class TestWakeLock(private val timeoutSeconds: Long, isHeld: Boolean = false) : WakeLock {
+ var isHeld = isHeld
+ private set
+
+ private val lock = ReentrantLock()
+ private val lockCondition = lock.newCondition()
+
+ override fun acquire() {
+ lock.withLock {
+ if (isHeld) throw AssertionError("Tried to acquire wakelock we're already holding")
+
+ isHeld = true
+ }
+ }
+
+ override fun release() {
+ lock.withLock {
+ if (!isHeld) throw AssertionError("Tried to release a wakelock we're not holding")
+
+ isHeld = false
+ lockCondition.signal()
+ }
+ }
+
+ override fun acquire(timeout: Long) {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun setReferenceCounted(counted: Boolean) {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ fun waitForRelease() {
+ lock.withLock {
+ if (isHeld) {
+ lockCondition.await(timeoutSeconds, TimeUnit.SECONDS)
+ }
+ }
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java
new file mode 100644
index 0000000..cb929b8
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java
@@ -0,0 +1,139 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Test;
+
+import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+
+public class UidCopyResponseTest {
+ @Test
+ public void parse_withCopyUidResponse_shouldCreateUidMapping() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID 1 1,3:5 7:10] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNotNull(result);
+ assertEquals(createUidMapping("1=7", "3=8", "4=9", "5=10"), result.getUidMapping());
+ }
+
+ @Test
+ public void parse_withUntaggedResponse_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponse = createImapResponseList("* OK [COPYUID 1 1,3:5 7:10] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponse);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withTooShortResponse_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutOkResponse_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x BYE Logout");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutResponseTextList_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withResponseTextListTooShort_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK [A B C] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withoutCopyUidResponse_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK [A B C D] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withNonStringCopyUidArgumentOne_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID () C D] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withNonStringCopyUidArgumentTwo_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID B () D] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withNonStringCopyUidArgumentThree_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID B C ()] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withNonNumberCopyUidArguments_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID B C D] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void parse_withUnbalancedCopyUidArguments_shouldReturnNull() throws Exception {
+ List<ImapResponse> imapResponses = createImapResponseList("x OK [COPYUID B 1 1,2] Success");
+
+ UidCopyResponse result = UidCopyResponse.parse(imapResponses);
+
+ assertNull(result);
+ }
+
+
+ private Map<String, String> createUidMapping(String... values) {
+ Map<String, String> mapping = new HashMap<>(values.length);
+
+ for (String value : values) {
+ String[] parts = value.split("=");
+ String oldUid = parts[0];
+ String newUid = parts[1];
+ mapping.put(oldUid, newUid);
+ }
+
+ return mapping;
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java
new file mode 100644
index 0000000..1d78cea
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilderTest.java
@@ -0,0 +1,38 @@
+package com.fsck.k9.mail.store.imap;
+
+
+import java.util.Collections;
+
+import com.fsck.k9.mail.Flag;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+
+public class UidSearchCommandBuilderTest {
+
+ @Test
+ public void build_withFullTextSearch() {
+ String command = new UidSearchCommandBuilder()
+ .performFullTextSearch(true)
+ .requiredFlags(Collections.singleton(Flag.FLAGGED))
+ .forbiddenFlags(Collections.singleton(Flag.DELETED))
+ .queryString("query")
+ .build();
+
+ assertEquals("UID SEARCH TEXT \"query\" FLAGGED NOT DELETED", command);
+ }
+
+ @Test
+ public void build_withoutFullTextSearch() {
+ String command = new UidSearchCommandBuilder()
+ .performFullTextSearch(false)
+ .requiredFlags(null)
+ .forbiddenFlags(Collections.singleton(Flag.DELETED))
+ .queryString("query")
+ .build();
+
+ assertEquals("UID SEARCH OR OR OR OR SUBJECT \"query\" FROM \"query\" TO \"query\" CC \"query\"" +
+ " BCC \"query\" NOT DELETED", command);
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidValidityResponseTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidValidityResponseTest.kt
new file mode 100644
index 0000000..9d6f842
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidValidityResponseTest.kt
@@ -0,0 +1,63 @@
+package com.fsck.k9.mail.store.imap
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class UidValidityResponseTest {
+ @Test
+ fun validResponseWithText() {
+ val response = ImapResponseHelper.createImapResponse("* OK [UIDVALIDITY 23] UIDs valid")
+
+ val result = UidValidityResponse.parse(response)
+
+ assertNotNull(result)
+ assertEquals(23, result!!.uidValidity)
+ }
+
+ @Test
+ fun validResponseWithoutText() {
+ val response = ImapResponseHelper.createImapResponse("* OK [UIDVALIDITY 42]")
+
+ val result = UidValidityResponse.parse(response)
+
+ assertNotNull(result)
+ assertEquals(42, result!!.uidValidity)
+ }
+
+ @Test
+ fun taggedResponse_shouldReturnNull() {
+ assertNotValid("99 OK [UIDVALIDITY 42]")
+ }
+
+ @Test
+ fun noResponse_shouldReturnNull() {
+ assertNotValid("* NO [UIDVALIDITY 42]")
+ }
+
+ @Test
+ fun responseTextWithOnlyOneItem_shouldReturnNull() {
+ assertNotValid("* OK [UIDVALIDITY]")
+ }
+
+ @Test
+ fun uidValidityIsNotANumber_shouldReturnNull() {
+ assertNotValid("* OK [UIDVALIDITY fourtytwo]")
+ }
+
+ @Test
+ fun negativeUidValidity_shouldReturnNull() {
+ assertNotValid("* OK [UIDVALIDITY -1]")
+ }
+
+ @Test
+ fun uidValidityOutsideRange_shouldReturnNull() {
+ assertNotValid("* OK [UIDVALIDITY 4294967296]")
+ }
+
+ private fun assertNotValid(response: String) {
+ val result = UidValidityResponse.parse(ImapResponseHelper.createImapResponse(response))
+ assertNull(result)
+ }
+}
diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/mockserver/MockImapServer.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/mockserver/MockImapServer.java
new file mode 100644
index 0000000..140fa61
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/mockserver/MockImapServer.java
@@ -0,0 +1,420 @@
+package com.fsck.k9.mail.store.imap.mockserver;
+
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.security.KeyManagementException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.CertificateException;
+import java.util.Deque;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.zip.Inflater;
+import java.util.zip.InflaterInputStream;
+
+import com.fsck.k9.mail.helpers.KeyStoreProvider;
+import com.jcraft.jzlib.JZlib;
+import com.jcraft.jzlib.ZOutputStream;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLSocketFactory;
+import okio.BufferedSink;
+import okio.BufferedSource;
+import okio.Okio;
+import org.apache.commons.io.IOUtils;
+
+
+public class MockImapServer {
+ private static final byte[] CRLF = { '\r', '\n' };
+
+
+ private final Deque<ImapInteraction> interactions = new ConcurrentLinkedDeque<>();
+ private final CountDownLatch waitForConnectionClosed = new CountDownLatch(1);
+ private final CountDownLatch waitForAllExpectedCommands = new CountDownLatch(1);
+ private final KeyStoreProvider keyStoreProvider;
+ private final Logger logger;
+
+ private MockServerThread mockServerThread;
+ private String host;
+ private int port;
+
+
+ public MockImapServer() {
+ this(KeyStoreProvider.getInstance(), new DefaultLogger());
+ }
+
+ public MockImapServer(KeyStoreProvider keyStoreProvider, Logger logger) {
+ this.keyStoreProvider = keyStoreProvider;
+ this.logger = logger;
+ }
+
+ public void output(String response) {
+ checkServerNotRunning();
+ interactions.add(new CannedResponse(response));
+ }
+
+ public void expect(String command) {
+ checkServerNotRunning();
+ interactions.add(new ExpectedCommand(command));
+ }
+
+ public void startTls() {
+ checkServerNotRunning();
+ interactions.add(new UpgradeToTls());
+ }
+
+ public void enableCompression() {
+ checkServerNotRunning();
+ interactions.add(new EnableCompression());
+ }
+
+ public void closeConnection() {
+ checkServerNotRunning();
+ interactions.add(new CloseConnection());
+ }
+
+ public void start() throws IOException {
+ checkServerNotRunning();
+
+ InetAddress localAddress = InetAddress.getByName(null);
+ ServerSocket serverSocket = new ServerSocket(0, 1, localAddress);
+ InetSocketAddress localSocketAddress = (InetSocketAddress) serverSocket.getLocalSocketAddress();
+ host = localSocketAddress.getHostString();
+ port = serverSocket.getLocalPort();
+
+ mockServerThread = new MockServerThread(serverSocket, interactions, waitForConnectionClosed,
+ waitForAllExpectedCommands, logger, keyStoreProvider);
+ mockServerThread.start();
+ }
+
+ public void shutdown() {
+ checkServerRunning();
+
+ mockServerThread.shouldStop();
+ waitForMockServerThread();
+ }
+
+ private void waitForMockServerThread() {
+ try {
+ mockServerThread.join(500L);
+ } catch (InterruptedException ignored) {
+ }
+ }
+
+ public String getHost() {
+ checkServerRunning();
+
+ return host;
+ }
+
+ public int getPort() {
+ checkServerRunning();
+
+ return port;
+ }
+
+ public void waitForInteractionToComplete() {
+ checkServerRunning();
+
+ try {
+ waitForAllExpectedCommands.await(1000L, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException ignored) {
+ }
+ }
+
+ public void verifyInteractionCompleted() {
+ shutdown();
+
+ if (!interactions.isEmpty()) {
+ throw new AssertionError("Interactions left: " + interactions.size());
+ }
+
+ UnexpectedCommandException unexpectedCommandException = mockServerThread.getUnexpectedCommandException();
+ if (unexpectedCommandException != null) {
+ throw new AssertionError(unexpectedCommandException.getMessage(), unexpectedCommandException);
+ }
+ }
+
+ public void verifyConnectionStillOpen() {
+ checkServerRunning();
+
+ if (mockServerThread.isClientConnectionClosed()) {
+ throw new AssertionError("Connection closed when it shouldn't be");
+ }
+ }
+
+ public void verifyConnectionClosed() {
+ checkServerRunning();
+
+ try {
+ waitForConnectionClosed.await(300L, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException ignored) {
+ }
+
+ if (!mockServerThread.isClientConnectionClosed()) {
+ throw new AssertionError("Connection open when is shouldn't be");
+ }
+ }
+
+ private void checkServerRunning() {
+ if (mockServerThread == null) {
+ throw new IllegalStateException("Server was never started");
+ }
+ }
+
+ private void checkServerNotRunning() {
+ if (mockServerThread != null) {
+ throw new IllegalStateException("Server was already started");
+ }
+ }
+
+
+ public interface Logger {
+ void log(String message);
+ void log(String format, Object... args);
+ }
+
+ private interface ImapInteraction {}
+
+ private static class ExpectedCommand implements ImapInteraction {
+ private final String command;
+
+
+ public ExpectedCommand(String command) {
+ this.command = command;
+ }
+
+ public String getCommand() {
+ return command;
+ }
+ }
+
+ private static class CannedResponse implements ImapInteraction {
+ private final String response;
+
+
+ public CannedResponse(String response) {
+ this.response = response;
+ }
+
+ public String getResponse() {
+ return response;
+ }
+ }
+
+ private static class CloseConnection implements ImapInteraction {
+ }
+
+ private static class EnableCompression implements ImapInteraction {
+ }
+
+ private static class UpgradeToTls implements ImapInteraction {
+ }
+
+ private static class UnexpectedCommandException extends Exception {
+ public UnexpectedCommandException(String expectedCommand, String receivedCommand) {
+ super("Expected <" + expectedCommand + ">, but received <" + receivedCommand + ">");
+ }
+ }
+
+ private static class MockServerThread extends Thread {
+ private final ServerSocket serverSocket;
+ private final Deque<ImapInteraction> interactions;
+ private final CountDownLatch waitForConnectionClosed;
+ private final CountDownLatch waitForAllExpectedCommands;
+ private final Logger logger;
+ private final KeyStoreProvider keyStoreProvider;
+
+ private volatile boolean shouldStop = false;
+ private volatile Socket clientSocket;
+
+ private BufferedSource input;
+ private BufferedSink output;
+ private volatile UnexpectedCommandException unexpectedCommandException;
+
+
+ public MockServerThread(ServerSocket serverSocket, Deque<ImapInteraction> interactions,
+ CountDownLatch waitForConnectionClosed, CountDownLatch waitForAllExpectedCommands, Logger logger,
+ KeyStoreProvider keyStoreProvider) {
+ super("MockImapServer");
+ this.serverSocket = serverSocket;
+ this.interactions = interactions;
+ this.waitForConnectionClosed = waitForConnectionClosed;
+ this.waitForAllExpectedCommands = waitForAllExpectedCommands;
+ this.logger = logger;
+ this.keyStoreProvider = keyStoreProvider;
+ }
+
+ @Override
+ public void run() {
+ String hostAddress = serverSocket.getInetAddress().getHostAddress();
+ int port = serverSocket.getLocalPort();
+ logger.log("Listening on %s:%d", hostAddress, port);
+
+ Socket socket = null;
+ try {
+ socket = acceptConnectionAndCloseServerSocket();
+ clientSocket = socket;
+
+ String remoteHostAddress = socket.getInetAddress().getHostAddress();
+ int remotePort = socket.getPort();
+ logger.log("Accepted connection from %s:%d", remoteHostAddress, remotePort);
+
+ input = Okio.buffer(Okio.source(socket));
+ output = Okio.buffer(Okio.sink(socket));
+
+ while (!shouldStop && !interactions.isEmpty()) {
+ handleInteractions(socket);
+ }
+
+ waitForAllExpectedCommands.countDown();
+
+ while (!shouldStop) {
+ readAdditionalCommands();
+ }
+
+ waitForConnectionClosed.countDown();
+ } catch (UnexpectedCommandException e) {
+ unexpectedCommandException = e;
+ } catch (IOException e) {
+ if (!shouldStop) {
+ logger.log("Exception: %s", e);
+ }
+ } catch (KeyStoreException | CertificateException | UnrecoverableKeyException |
+ NoSuchAlgorithmException | KeyManagementException e) {
+ throw new RuntimeException(e);
+ }
+
+ IOUtils.closeQuietly(socket);
+
+ logger.log("Exiting");
+ }
+
+ private void handleInteractions(Socket socket) throws IOException, KeyStoreException,
+ NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException, KeyManagementException,
+ UnexpectedCommandException {
+
+ ImapInteraction interaction = interactions.pop();
+ if (interaction instanceof ExpectedCommand) {
+ readExpectedCommand((ExpectedCommand) interaction);
+ } else if (interaction instanceof CannedResponse) {
+ writeCannedResponse((CannedResponse) interaction);
+ } else if (interaction instanceof CloseConnection) {
+ clientSocket.close();
+ } else if (interaction instanceof EnableCompression) {
+ enableCompression(socket);
+ } else if (interaction instanceof UpgradeToTls) {
+ upgradeToTls(socket);
+ }
+ }
+
+ private void readExpectedCommand(ExpectedCommand expectedCommand) throws IOException,
+ UnexpectedCommandException {
+
+ String command = input.readUtf8Line();
+ if (command == null) {
+ throw new EOFException();
+ }
+
+ logger.log("C: %s", command);
+
+ String expected = expectedCommand.getCommand();
+ if (!command.equals(expected)) {
+ logger.log("EXPECTED: %s", expected);
+ throw new UnexpectedCommandException(expected, command);
+ }
+ }
+
+ private void writeCannedResponse(CannedResponse cannedResponse) throws IOException {
+ String response = cannedResponse.getResponse();
+ logger.log("S: %s", response);
+
+ output.writeUtf8(response);
+ output.write(CRLF);
+ output.flush();
+ }
+
+ private void enableCompression(Socket socket) throws IOException {
+ InputStream inputStream = new InflaterInputStream(socket.getInputStream(), new Inflater(true));
+ input = Okio.buffer(Okio.source(inputStream));
+
+ ZOutputStream outputStream = new ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true);
+ outputStream.setFlushMode(JZlib.Z_PARTIAL_FLUSH);
+ output = Okio.buffer(Okio.sink(outputStream));
+ }
+
+ private void upgradeToTls(Socket socket) throws KeyStoreException, IOException, NoSuchAlgorithmException,
+ CertificateException, UnrecoverableKeyException, KeyManagementException {
+
+ KeyStore keyStore = keyStoreProvider.getKeyStore();
+
+ String defaultAlgorithm = KeyManagerFactory.getDefaultAlgorithm();
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(defaultAlgorithm);
+ keyManagerFactory.init(keyStore, keyStoreProvider.getPassword());
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(keyManagerFactory.getKeyManagers(), null, null);
+ SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
+
+ SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(
+ socket, socket.getInetAddress().getHostAddress(), socket.getPort(), true);
+ sslSocket.setUseClientMode(false);
+ sslSocket.startHandshake();
+
+ input = Okio.buffer(Okio.source(sslSocket.getInputStream()));
+ output = Okio.buffer(Okio.sink(sslSocket.getOutputStream()));
+ }
+
+ private void readAdditionalCommands() throws IOException {
+ String command = input.readUtf8Line();
+ if (command == null) {
+ throw new EOFException();
+ }
+
+ logger.log("Received additional command: %s", command);
+ }
+
+ private Socket acceptConnectionAndCloseServerSocket() throws IOException {
+ Socket socket = serverSocket.accept();
+ serverSocket.close();
+
+ return socket;
+ }
+
+ public void shouldStop() {
+ shouldStop = true;
+
+ IOUtils.closeQuietly(clientSocket);
+ }
+
+ public boolean isClientConnectionClosed() {
+ return clientSocket.isClosed();
+ }
+
+ public UnexpectedCommandException getUnexpectedCommandException() {
+ return unexpectedCommandException;
+ }
+ }
+
+ private static class DefaultLogger implements Logger {
+ @Override
+ public void log(String message) {
+ System.out.println("MockImapServer: " + message);
+ }
+
+ @Override
+ public void log(String format, Object... args) {
+ log(String.format(format, args));
+ }
+ }
+}