Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
After a message is deleted, it does not immediately appear in the Trash folder
### Checklist

- [X] I have used the search function to see if someone else has already submitted the same bug report.
- [X] I will describe the problem with as much detail as possible.

### App version

6.712

### Where did you get the app from?

Other

### Android version

13

### Device model

_No response_

### Steps to reproduce

1. Select an email in the Inbox.
2. Click on the trash can icon.
3. View the list of messages in the Trash folder.

### Expected behavior

The deleted email should immediately appear in the list of messages in the Trash folder.

### Actual behavior

The deleted email does not appear in the list of messages in the Trash folder until they are refreshed by swiping down.

### Logs

[k9mail-logs.txt](https://github.com/thunderbird/thunderbird-android/files/13530716/k9mail-logs.txt)



Discussion hints:
nan

Pull request: IMAP: Add support for untagged COPYUID responses
Fixes #7401

Test command: ./gradlew :mail:protocols:imap:test --tests "com.fsck.k9.mail.store.imap.UidCopyResponseTest" --tests "com.fsck.k9.mail.store.imap.RealImapFolderTest" --tests "com.fsck.k9.mail.store.imap.UidCopyResponseTest"

Fix patch:
diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt
index 8a54f68b763..84262d4e2f1 100644
--- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt
+++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt
@@ -343,7 +343,7 @@ internal class RealImapFolder(
                 uids,
             )
 
-            UidCopyResponse.parse(imapResponses)?.uidMapping
+            UidCopyResponse.parse(imapResponses, allowUntaggedResponse = true)?.uidMapping
         } catch (ioe: IOException) {
             throw ioExceptionHandler(connection, ioe)
         }
diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.java
deleted file mode 100644
index 9d17389a3e5..00000000000
--- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.fsck.k9.mail.store.imap;
-
-
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase;
-import static com.fsck.k9.mail.store.imap.ImapUtility.getImapSequenceValues;
-
-
-class UidCopyResponse {
-    private final Map<String, String> uidMapping;
-
-
-    private UidCopyResponse(Map<String, String> uidMapping) {
-        this.uidMapping = uidMapping;
-    }
-
-    public static UidCopyResponse parse(List<ImapResponse> imapResponses) {
-        Map<String, String> uidMapping = new LinkedHashMap<>();
-        for (ImapResponse imapResponse : imapResponses) {
-            parseUidCopyResponse(imapResponse, uidMapping);
-        }
-
-        return uidMapping.isEmpty() ? null : new UidCopyResponse(uidMapping);
-    }
-
-    private static void parseUidCopyResponse(ImapResponse response, Map<String, String> uidMappingOutput) {
-        if (!response.isTagged() || response.size() < 2 || !equalsIgnoreCase(response.get(0), Responses.OK) ||
-                !response.isList(1)) {
-            return;
-        }
-
-        ImapList responseTextList = response.getList(1);
-        if (responseTextList.size() < 4 || !equalsIgnoreCase(responseTextList.get(0), Responses.COPYUID) ||
-                !responseTextList.isString(1) || !responseTextList.isString(2) || !responseTextList.isString(3)) {
-            return;
-        }
-
-        List<String> sourceUids = getImapSequenceValues(responseTextList.getString(2));
-        List<String> destinationUids = getImapSequenceValues(responseTextList.getString(3));
-
-        int size = sourceUids.size();
-        if (size == 0 || size != destinationUids.size()) {
-            return;
-        }
-
-        for (int i = 0; i < size; i++) {
-            String sourceUid = sourceUids.get(i);
-            String destinationUid = destinationUids.get(i);
-            uidMappingOutput.put(sourceUid, destinationUid);
-        }
-    }
-
-    public Map<String, String> getUidMapping() {
-        return uidMapping;
-    }
-}
diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.kt
new file mode 100644
index 00000000000..bc9881f96e2
--- /dev/null
+++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.kt
@@ -0,0 +1,54 @@
+package com.fsck.k9.mail.store.imap
+
+internal class UidCopyResponse private constructor(val uidMapping: Map<String, String>) {
+
+    companion object {
+        fun parse(imapResponses: List<ImapResponse>, allowUntaggedResponse: Boolean = false): UidCopyResponse? {
+            val uidMapping = mutableMapOf<String, String>()
+            for (imapResponse in imapResponses) {
+                parseUidCopyResponse(imapResponse, allowUntaggedResponse, uidMapping)
+            }
+
+            return if (uidMapping.isNotEmpty()) {
+                UidCopyResponse(uidMapping)
+            } else {
+                null
+            }
+        }
+
+        @Suppress("ReturnCount", "ComplexCondition", "MagicNumber")
+        private fun parseUidCopyResponse(
+            response: ImapResponse,
+            allowUntaggedResponse: Boolean,
+            uidMappingOutput: MutableMap<String, String>,
+        ) {
+            if (!(allowUntaggedResponse || response.isTagged) || response.size < 2 ||
+                !ImapResponseParser.equalsIgnoreCase(response[0], Responses.OK) || !response.isList(1)
+            ) {
+                return
+            }
+
+            val responseTextList = response.getList(1)
+            if (responseTextList.size < 4 ||
+                !ImapResponseParser.equalsIgnoreCase(responseTextList[0], Responses.COPYUID) ||
+                !responseTextList.isString(1) || !responseTextList.isString(2) || !responseTextList.isString(3)
+            ) {
+                return
+            }
+
+            val sourceUids = ImapUtility.getImapSequenceValues(responseTextList.getString(2))
+            val destinationUids = ImapUtility.getImapSequenceValues(responseTextList.getString(3))
+
+            val size = sourceUids.size
+            if (size == 0 || size != destinationUids.size) {
+                return
+            }
+
+            for (i in 0 until size) {
+                val sourceUid = sourceUids[i]
+                val destinationUid = destinationUids[i]
+                uidMappingOutput[sourceUid] = destinationUid
+            }
+        }
+    }
+}


Test patch:
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
index ee97f2902d1..acb4b80c57d 100644
--- 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
@@ -25,6 +25,7 @@ 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 com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList
 import java.io.File
 import java.io.IOException
 import java.nio.file.Files
@@ -300,7 +301,7 @@ class RealImapFolderTest {
     }
 
     @Test
-    fun `moveMessages() with MOVE extension`() {
+    fun `moveMessages() with MOVE extension and tagged COPYUID response`() {
         val sourceFolder = createFolder("Folder")
         prepareImapFolderForOpen(OpenMode.READ_WRITE)
         imapConnection.stub {
@@ -308,7 +309,29 @@ class RealImapFolderTest {
         }
         val destinationFolder = createFolder("Destination")
         val messages = listOf(createImapMessage("1"))
-        setupMoveResponse("x OK [COPYUID 23 1 101] Success")
+        setupMoveResponses("x OK [COPYUID 23 1 101] Success")
+        sourceFolder.open(OpenMode.READ_WRITE)
+
+        val uidMapping = sourceFolder.moveMessages(messages, destinationFolder)
+
+        assertCommandWithIdsIssued("UID MOVE 1 \"Destination\"")
+        assertThat(uidMapping).isNotNull().containsOnly("1" to "101")
+    }
+
+    @Test
+    fun `moveMessages() with MOVE extension and untagged COPYUID response`() {
+        val sourceFolder = createFolder("Folder")
+        prepareImapFolderForOpen(OpenMode.READ_WRITE)
+        imapConnection.stub {
+            on { hasCapability(Capabilities.MOVE) } doReturn true
+        }
+        val destinationFolder = createFolder("Destination")
+        val messages = listOf(createImapMessage("1"))
+        setupMoveResponses(
+            "* OK [COPYUID 23 1 101]",
+            "* 1 EXPUNGE",
+            "x OK MOVE completed",
+        )
         sourceFolder.open(OpenMode.READ_WRITE)
 
         val uidMapping = sourceFolder.moveMessages(messages, destinationFolder)
@@ -1223,8 +1246,8 @@ class RealImapFolderTest {
     }
 
     @Suppress("SameParameterValue")
-    private fun setupMoveResponse(response: String) {
-        val imapResponses = listOf(createImapResponse(response))
+    private fun setupMoveResponses(vararg responses: String) {
+        val imapResponses = createImapResponseList(*responses)
         whenever(imapConnection.executeCommandWithIdSet(eq(Commands.UID_MOVE), anyString(), anySet()))
             .thenReturn(imapResponses)
     }
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
deleted file mode 100644
index cb929b81b29..00000000000
--- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.java
+++ /dev/null
@@ -1,139 +0,0 @@
-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/UidCopyResponseTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.kt
new file mode 100644
index 00000000000..90e55206124
--- /dev/null
+++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/UidCopyResponseTest.kt
@@ -0,0 +1,146 @@
+package com.fsck.k9.mail.store.imap
+
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNotNull
+import assertk.assertions.isNull
+import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList
+import kotlin.test.Test
+
+class UidCopyResponseTest {
+    @Test
+    fun `parse() with COPYUID response should return UID mapping`() {
+        val imapResponses = createImapResponseList("x OK [COPYUID 1 1,3:5 7:10] Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNotNull()
+            .transform { it.uidMapping }
+            .isEqualTo(
+                mapOf(
+                    "1" to "7",
+                    "3" to "8",
+                    "4" to "9",
+                    "5" to "10",
+                ),
+            )
+    }
+
+    @Test
+    fun `parse() with allowed untagged COPYUID response should return UID mapping`() {
+        val imapResponses = createImapResponseList(
+            "* OK [COPYUID 1 1,3 10:11]",
+            "* 1 EXPUNGE",
+            "* 1 EXPUNGE",
+            "x OK MOVE completed",
+        )
+
+        val result = UidCopyResponse.parse(imapResponses, allowUntaggedResponse = true)
+
+        assertThat(result).isNotNull()
+            .transform { it.uidMapping }
+            .isEqualTo(
+                mapOf(
+                    "1" to "10",
+                    "3" to "11",
+                ),
+            )
+    }
+
+    @Test
+    fun `parse() with untagged response when not allowed should return null`() {
+        val imapResponses = createImapResponseList("* OK [COPYUID 1 1,3:5 7:10] Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() with response containing too few items should return null`() {
+        val imapResponses = createImapResponseList("x OK")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() without OK response should return null`() {
+        val imapResponses = createImapResponseList("x BYE Logout")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() without response text list should return null`() {
+        val imapResponses = createImapResponseList("x OK Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() with response text list containing too few items should return null`() {
+        val imapResponses = createImapResponseList("x OK [A B C] Success")
+        val result = UidCopyResponse.parse(imapResponses)
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() without COPYUID response should return null`() {
+        val imapResponses = createImapResponseList("x OK [A B C D] Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() with first COPYUID argument not being a string should return null`() {
+        val imapResponses = createImapResponseList("x OK [COPYUID () C D] Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() with second COPYUID argument not being a string should return null`() {
+        val imapResponses = createImapResponseList("x OK [COPYUID B () D] Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() with third COPYUID argument not being a string should return null`() {
+        val imapResponses = createImapResponseList("x OK [COPYUID B C ()] Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() with non-number COPYUID arguments should return null`() {
+        val imapResponses = createImapResponseList("x OK [COPYUID B C D] Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+
+    @Test
+    fun `parse() with unbalanced COPYUID arguments should return null`() {
+        val imapResponses = createImapResponseList("x OK [COPYUID B 1 1,2] Success")
+
+        val result = UidCopyResponse.parse(imapResponses)
+
+        assertThat(result).isNull()
+    }
+}