diff --git a/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderFetcher.kt b/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderFetcher.kt new file mode 100644 index 00000000000..066a985a11c --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderFetcher.kt @@ -0,0 +1,16 @@ +package com.fsck.k9.mail.folders + +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.AuthStateStorage + +/** + * Fetches the list of folders from a server. + * + * @throws FolderFetcherException in case of an error + */ +fun interface FolderFetcher { + fun getFolders( + serverSettings: ServerSettings, + authStateStorage: AuthStateStorage?, + ): List +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderFetcherException.kt b/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderFetcherException.kt new file mode 100644 index 00000000000..7442c8d727e --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderFetcherException.kt @@ -0,0 +1,9 @@ +package com.fsck.k9.mail.folders + +/** + * Thrown by [FolderFetcher] in case of an error. + */ +class FolderFetcherException( + cause: Throwable, + val messageFromServer: String? = null, +) : RuntimeException(cause.message, cause) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderServerId.kt b/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderServerId.kt new file mode 100644 index 00000000000..e73a104afaf --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/folders/FolderServerId.kt @@ -0,0 +1,4 @@ +package com.fsck.k9.mail.folders + +@JvmInline +value class FolderServerId(val serverId: String) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/folders/RemoteFolder.kt b/mail/common/src/main/java/com/fsck/k9/mail/folders/RemoteFolder.kt new file mode 100644 index 00000000000..49a57f51c40 --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/folders/RemoteFolder.kt @@ -0,0 +1,9 @@ +package com.fsck.k9.mail.folders + +import com.fsck.k9.mail.FolderType + +data class RemoteFolder( + val serverId: FolderServerId, + val displayName: String, + val type: FolderType, +) diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolderFetcher.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolderFetcher.kt new file mode 100644 index 00000000000..11c32bd475f --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolderFetcher.kt @@ -0,0 +1,77 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.folders.FolderFetcher +import com.fsck.k9.mail.folders.FolderFetcherException +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.folders.RemoteFolder +import com.fsck.k9.mail.oauth.AuthStateStorage +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory +import com.fsck.k9.mail.ssl.TrustedSocketFactory + +/** + * Fetches the list of folders from an IMAP server. + */ +class ImapFolderFetcher internal constructor( + private val trustedSocketFactory: TrustedSocketFactory, + private val oAuth2TokenProviderFactory: OAuth2TokenProviderFactory?, + private val clientIdAppName: String, + private val clientIdAppVersion: String, + private val imapStoreFactory: ImapStoreFactory, +) : FolderFetcher { + constructor( + trustedSocketFactory: TrustedSocketFactory, + oAuth2TokenProviderFactory: OAuth2TokenProviderFactory?, + clientIdAppName: String, + clientIdAppVersion: String, + ) : this( + trustedSocketFactory, + oAuth2TokenProviderFactory, + clientIdAppName, + clientIdAppVersion, + imapStoreFactory = ImapStore.Companion, + ) + + @Suppress("TooGenericExceptionCaught") + override fun getFolders(serverSettings: ServerSettings, authStateStorage: AuthStateStorage?): List { + require(serverSettings.type == "imap") + + val config = object : ImapStoreConfig { + override val logLabel = "folder-fetcher" + override fun isSubscribedFoldersOnly() = false + override fun clientId() = ImapClientId(appName = clientIdAppName, appVersion = clientIdAppVersion) + } + val oAuth2TokenProvider = createOAuth2TokenProviderOrNull(authStateStorage) + val store = imapStoreFactory.create(serverSettings, config, trustedSocketFactory, oAuth2TokenProvider) + + return try { + store.getFolders() + .asSequence() + .filterNot { it.oldServerId == null } + .map { folder -> + RemoteFolder( + serverId = FolderServerId(folder.oldServerId!!), + displayName = folder.name, + type = folder.type, + ) + } + .toList() + } catch (e: AuthenticationFailedException) { + throw FolderFetcherException(messageFromServer = e.messageFromServer, cause = e) + } catch (e: NegativeImapResponseException) { + throw FolderFetcherException(messageFromServer = e.responseText, cause = e) + } catch (e: Exception) { + throw FolderFetcherException(cause = e) + } finally { + store.closeAllConnections() + } + } + + private fun createOAuth2TokenProviderOrNull(authStateStorage: AuthStateStorage?): OAuth2TokenProvider? { + return authStateStorage?.let { + oAuth2TokenProviderFactory?.create(it) + } + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt index af1a98053f8..f42f0b72ed3 100644 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt @@ -16,8 +16,8 @@ interface ImapStore { fun closeAllConnections() - companion object { - fun create( + companion object : ImapStoreFactory { + override fun create( serverSettings: ServerSettings, config: ImapStoreConfig, trustedSocketFactory: TrustedSocketFactory, diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreFactory.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreFactory.kt new file mode 100644 index 00000000000..39c423fd609 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreFactory.kt @@ -0,0 +1,14 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.ssl.TrustedSocketFactory + +internal fun interface ImapStoreFactory { + fun create( + serverSettings: ServerSettings, + config: ImapStoreConfig, + trustedSocketFactory: TrustedSocketFactory, + oauthTokenProvider: OAuth2TokenProvider?, + ): ImapStore +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FakeImapStore.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FakeImapStore.kt new file mode 100644 index 00000000000..d6a7f541c84 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FakeImapStore.kt @@ -0,0 +1,28 @@ +package com.fsck.k9.mail.store.imap + +import kotlin.test.fail + +class FakeImapStore : ImapStore { + private var openConnectionCount = 0 + + var getFoldersAction: () -> List = { fail("getFoldersAction not set") } + val hasOpenConnections: Boolean + get() = openConnectionCount != 0 + + override fun checkSettings() { + throw UnsupportedOperationException("not implemented") + } + + override fun getFolder(name: String): ImapFolder { + throw UnsupportedOperationException("not implemented") + } + + override fun getFolders(): List { + openConnectionCount++ + return getFoldersAction() + } + + override fun closeAllConnections() { + openConnectionCount = 0 + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapFolderFetcherTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapFolderFetcherTest.kt new file mode 100644 index 00000000000..69bdd063e0c --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapFolderFetcherTest.kt @@ -0,0 +1,233 @@ +package com.fsck.k9.mail.store.imap + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import assertk.assertions.prop +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.folders.FolderFetcherException +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.folders.RemoteFolder +import com.fsck.k9.mail.helpers.FakeTrustManager +import com.fsck.k9.mail.helpers.SimpleTrustedSocketFactory +import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList +import kotlin.test.Test + +class ImapFolderFetcherTest { + private val fakeTrustManager = FakeTrustManager() + private val trustedSocketFactory = SimpleTrustedSocketFactory(fakeTrustManager) + private val fakeImapStore = FakeImapStore() + private val folderFetcher = ImapFolderFetcher( + trustedSocketFactory = trustedSocketFactory, + oAuth2TokenProviderFactory = null, + clientIdAppName = "irrelevant", + clientIdAppVersion = "irrelevant", + imapStoreFactory = { _, _, _, _ -> + fakeImapStore + }, + ) + private val serverSettings = ServerSettings( + type = "imap", + host = "irrelevant", + port = 9999, + connectionSecurity = ConnectionSecurity.NONE, + authenticationType = AuthType.PLAIN, + username = "irrelevant", + password = "irrelevant", + clientCertificateAlias = null, + extra = ImapStoreSettings.createExtra( + autoDetectNamespace = true, + pathPrefix = null, + useCompression = false, + sendClientId = false, + ), + ) + + @Suppress("LongMethod") + @Test + fun `regular folder list`() { + fakeImapStore.getFoldersAction = { + listOf( + FolderListItem( + serverId = "INBOX", + name = "INBOX", + type = FolderType.INBOX, + oldServerId = "INBOX", + ), + FolderListItem( + serverId = "[Gmail]/All Mail", + name = "[Gmail]/All Mail", + type = FolderType.ARCHIVE, + oldServerId = "[Gmail]/All Mail", + ), + FolderListItem( + serverId = "[Gmail]/Drafts", + name = "[Gmail]/Drafts", + type = FolderType.DRAFTS, + oldServerId = "[Gmail]/Drafts", + ), + FolderListItem( + serverId = "[Gmail]/Important", + name = "[Gmail]/Important", + type = FolderType.REGULAR, + oldServerId = "[Gmail]/Important", + ), + FolderListItem( + serverId = "[Gmail]/Sent Mail", + name = "[Gmail]/Sent Mail", + type = FolderType.SENT, + oldServerId = "[Gmail]/Sent Mail", + ), + FolderListItem( + serverId = "[Gmail]/Spam", + name = "[Gmail]/Spam", + type = FolderType.SPAM, + oldServerId = "[Gmail]/Spam", + ), + FolderListItem( + serverId = "[Gmail]/Starred", + name = "[Gmail]/Starred", + type = FolderType.REGULAR, + oldServerId = "[Gmail]/Starred", + ), + FolderListItem( + serverId = "[Gmail]/Trash", + name = "[Gmail]/Trash", + type = FolderType.TRASH, + oldServerId = "[Gmail]/Trash", + ), + ) + } + + val folders = folderFetcher.getFolders(serverSettings, authStateStorage = null) + + assertThat(folders).containsExactly( + RemoteFolder( + serverId = FolderServerId("INBOX"), + displayName = "INBOX", + type = FolderType.INBOX, + ), + RemoteFolder( + serverId = FolderServerId("[Gmail]/All Mail"), + displayName = "[Gmail]/All Mail", + type = FolderType.ARCHIVE, + ), + RemoteFolder( + serverId = FolderServerId("[Gmail]/Drafts"), + displayName = "[Gmail]/Drafts", + type = FolderType.DRAFTS, + ), + RemoteFolder( + serverId = FolderServerId("[Gmail]/Important"), + displayName = "[Gmail]/Important", + type = FolderType.REGULAR, + ), + RemoteFolder( + serverId = FolderServerId("[Gmail]/Sent Mail"), + displayName = "[Gmail]/Sent Mail", + type = FolderType.SENT, + ), + RemoteFolder( + serverId = FolderServerId("[Gmail]/Spam"), + displayName = "[Gmail]/Spam", + type = FolderType.SPAM, + ), + RemoteFolder( + serverId = FolderServerId("[Gmail]/Starred"), + displayName = "[Gmail]/Starred", + type = FolderType.REGULAR, + ), + RemoteFolder( + serverId = FolderServerId("[Gmail]/Trash"), + displayName = "[Gmail]/Trash", + type = FolderType.TRASH, + ), + ) + assertThat(fakeImapStore.hasOpenConnections).isFalse() + } + + @Test + fun `folder without oldServerId should be ignored`() { + fakeImapStore.getFoldersAction = { + listOf( + FolderListItem( + serverId = "ünicode", + name = "ünicode", + type = FolderType.REGULAR, + oldServerId = null, + ), + FolderListItem( + serverId = "INBOX", + name = "INBOX", + type = FolderType.INBOX, + oldServerId = "INBOX", + ), + ) + } + + val folders = folderFetcher.getFolders(serverSettings, authStateStorage = null) + + assertThat(folders).containsExactly( + RemoteFolder( + serverId = FolderServerId("INBOX"), + displayName = "INBOX", + type = FolderType.INBOX, + ), + ) + + assertThat(fakeImapStore.hasOpenConnections).isFalse() + } + + @Test + fun `authentication error should throw FolderFetcherException with server message`() { + fakeImapStore.getFoldersAction = { + throw AuthenticationFailedException(message = "Authentication failed", messageFromServer = "Server error") + } + + assertFailure { + folderFetcher.getFolders(serverSettings, authStateStorage = null) + }.isInstanceOf() + .prop(FolderFetcherException::messageFromServer).isEqualTo("Server error") + + assertThat(fakeImapStore.hasOpenConnections).isFalse() + } + + @Test + fun `NegativeImapResponseException should throw FolderFetcherException with reply text as messageFromServer`() { + fakeImapStore.getFoldersAction = { + throw NegativeImapResponseException( + message = "irrelevant", + responses = createImapResponseList("x NO [NOPERM] Access denied"), + ) + } + + assertFailure { + folderFetcher.getFolders(serverSettings, authStateStorage = null) + }.isInstanceOf() + .prop(FolderFetcherException::messageFromServer).isEqualTo("Access denied") + + assertThat(fakeImapStore.hasOpenConnections).isFalse() + } + + @Test + fun `unexpected exception should throw FolderFetcherException`() { + fakeImapStore.getFoldersAction = { + error("unexpected") + } + + assertFailure { + folderFetcher.getFolders(serverSettings, authStateStorage = null) + }.isInstanceOf() + .prop(FolderFetcherException::messageFromServer).isNull() + + assertThat(fakeImapStore.hasOpenConnections).isFalse() + } +}