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

Issue:
Not prompting for new password when changed elsewhere
### 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.509

### Where did you get the app from?

Google Play

### Android version

12

### Device model

_No response_

### Steps to reproduce

1. have different mailboxes on k9
2. change the password of one of them outside k9 (ie web or thunderbird on windows)
3. try to access to new emails on the mailbox

### Expected behavior

realise the password has changed and prompting a request for writing a new one.

### Actual behavior

nothing, it just stops dowloading new messages without any notification or error message

### Logs

_No response_


Discussion hints:
K-9 Mail should display an error notification in that case. Can you record a debug log while trying to sync a folder after changing the password outside of K-9 Mail.

See https://github.com/thundernest/k-9/wiki/LoggingErrors
Hi, thanks for having a look. It does not display anything.  Log attached, the mailbox with changed pass is "ifca"
[k9mail-logs.txt](https://github.com/thundernest/k-9/files/10972070/k9mail-logs.txt)


Pull request: IMAP: Ignore errors during LOGIN fallback
From the log in #6744 I reconstructed that the communication with the IMAP server went like this (simplified):

```text
…
C: 2 AUTHENTICATE PLAIN [credentials]
S: 2 NO AUTHENTICATE failed
C: 3 LOGIN [credentials]
S: * BYE IMAP server terminating connection
S: [server closes connection]
```

We have code to fall back to using the `LOGIN` command when `AUTHENTICATE PLAIN` fails. In this case the server closed the connection instead of sending a negative response to the `LOGIN` command. K-9 Mail treated this as an intermittent connection failure and ignored it.

This change throws the original `AuthenticationFailedException` in case the `LOGIN` command throws anything but an `AuthenticationFailedException`. This makes sure an authentication error notification will be created.

Fixes #6744

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

Fix patch:
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.kt
index 1ac11bffd19..96dc2f9cb06 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.kt
@@ -415,7 +415,7 @@ class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListen
                 finish()
             } catch (e: AuthenticationFailedException) {
                 Timber.e(e, "Error while testing settings")
-                showErrorDialog(R.string.account_setup_failed_dlg_auth_message_fmt, e.message.orEmpty())
+                showErrorDialog(R.string.account_setup_failed_dlg_auth_message_fmt, e.messageFromServer.orEmpty())
             } catch (e: CertificateValidationException) {
                 handleCertificateValidationException(e)
             } catch (e: Exception) {
diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt
index 67adcece22b..22dc8eb62f0 100644
--- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt
+++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt
@@ -359,7 +359,11 @@ internal class RealImapConnection(
     private fun handlePermanentOAuthFailure(e: NegativeImapResponseException): AuthenticationFailedException {
         Timber.v(e, "Permanent failure during authentication using OAuth token")
 
-        return AuthenticationFailedException(message = e.message!!, throwable = e, messageFromServer = e.alertText)
+        return AuthenticationFailedException(
+            message = "Authentication failed",
+            throwable = e,
+            messageFromServer = ResponseTextExtractor.getResponseText(e.lastResponse),
+        )
     }
 
     private fun handleTemporaryOAuthFailure(method: OAuthMethod, e: NegativeImapResponseException): List<ImapResponse> {
@@ -445,7 +449,22 @@ internal class RealImapConnection(
                 throw e
             }
 
+            loginOrThrow(e)
+        }
+    }
+
+    @Suppress("ThrowsCount")
+    private fun loginOrThrow(originalException: AuthenticationFailedException): List<ImapResponse> {
+        return try {
             login()
+        } catch (e: AuthenticationFailedException) {
+            throw e
+        } catch (e: IOException) {
+            Timber.d(e, "LOGIN fallback failed")
+            throw originalException
+        } catch (e: MessagingException) {
+            Timber.d(e, "LOGIN fallback failed")
+            throw originalException
         }
     }
 
@@ -524,7 +543,11 @@ internal class RealImapConnection(
                 close()
             }
 
-            AuthenticationFailedException(negativeResponseException.message!!)
+            AuthenticationFailedException(
+                message = "Authentication failed",
+                throwable = negativeResponseException,
+                messageFromServer = ResponseTextExtractor.getResponseText(lastResponse),
+            )
         } else {
             close()
 
diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseTextExtractor.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseTextExtractor.kt
new file mode 100644
index 00000000000..9c4911818f7
--- /dev/null
+++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseTextExtractor.kt
@@ -0,0 +1,27 @@
+package com.fsck.k9.mail.store.imap
+
+/**
+ * Extracts the response text from a (negative) status response.
+ */
+internal object ResponseTextExtractor {
+    private const val MINIMUM_RESPONSE_SIZE = 2
+    private const val RESPONSE_CODE_INDEX = 1
+    private const val SIMPLE_RESPONSE_TEXT_INDEX = 1
+    private const val EXTENDED_RESPONSE_TEXT_INDEX = 2
+
+    fun getResponseText(response: ImapResponse): String? {
+        if (response.size < MINIMUM_RESPONSE_SIZE) return null
+
+        val responseTextIndex = if (response.isList(RESPONSE_CODE_INDEX)) {
+            EXTENDED_RESPONSE_TEXT_INDEX
+        } else {
+            SIMPLE_RESPONSE_TEXT_INDEX
+        }
+
+        return if (response.isString(responseTextIndex)) {
+            response.getString(responseTextIndex)
+        } else {
+            null
+        }
+    }
+}


Test patch:
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
index 2cebadbaf5f..d5b019df723 100644
--- 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
@@ -182,8 +182,7 @@ class RealImapConnectionTest {
             imapConnection.open()
             fail("Expected exception")
         } catch (e: AuthenticationFailedException) {
-            // FIXME: improve exception message
-            assertThat(e).hasMessageThat().contains("Go away")
+            assertThat(e.messageFromServer).isEqualTo("Go away")
         }
 
         server.verifyConnectionClosed()
@@ -231,8 +230,7 @@ class RealImapConnectionTest {
             imapConnection.open()
             fail("Expected exception")
         } catch (e: AuthenticationFailedException) {
-            // FIXME: improve exception message
-            assertThat(e).hasMessageThat().contains("Login Failure")
+            assertThat(e.messageFromServer).isEqualTo("Login Failure")
         }
 
         server.verifyConnectionClosed()
@@ -288,8 +286,7 @@ class RealImapConnectionTest {
             imapConnection.open()
             fail("Expected exception")
         } catch (e: AuthenticationFailedException) {
-            // FIXME: improve exception message
-            assertThat(e).hasMessageThat().contains("Who are you?")
+            assertThat(e.messageFromServer).isEqualTo("Who are you?")
         }
 
         server.verifyConnectionClosed()
@@ -395,8 +392,7 @@ class RealImapConnectionTest {
             imapConnection.open()
             fail()
         } catch (e: AuthenticationFailedException) {
-            assertThat(e).hasMessageThat()
-                .isEqualTo("Command: AUTHENTICATE XOAUTH2; response: #2# [NO, SASL authentication failed]")
+            assertThat(e.messageFromServer).isEqualTo("SASL authentication failed")
         }
     }
 
@@ -482,8 +478,7 @@ class RealImapConnectionTest {
             imapConnection.open()
             fail()
         } catch (e: AuthenticationFailedException) {
-            assertThat(e).hasMessageThat()
-                .isEqualTo("Command: AUTHENTICATE XOAUTH2; response: #3# [NO, SASL authentication failed]")
+            assertThat(e.messageFromServer).isEqualTo("SASL authentication failed")
         }
     }
 
@@ -995,6 +990,33 @@ class RealImapConnectionTest {
         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)
+
+        try {
+            imapConnection.open()
+            fail("Expected exception")
+        } catch (e: AuthenticationFailedException) {
+            assertThat(e.messageFromServer).isEqualTo("AUTHENTICATE failed")
+        }
+
+        server.verifyInteractionCompleted()
+    }
+
     private fun createImapConnection(
         settings: ImapSettings,
         socketFactory: TrustedSocketFactory,
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 00000000000..f40e77f425d
--- /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()
+    }
+}