Add FolderFetcher

This commit is contained in:
cketti 2023-11-10 21:15:13 +01:00
parent 8f06c24bd3
commit 1de41d9177
9 changed files with 392 additions and 2 deletions

View file

@ -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<RemoteFolder>
}

View file

@ -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)

View file

@ -0,0 +1,4 @@
package com.fsck.k9.mail.folders
@JvmInline
value class FolderServerId(val serverId: String)

View file

@ -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,
)

View file

@ -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<RemoteFolder> {
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)
}
}
}

View file

@ -16,8 +16,8 @@ interface ImapStore {
fun closeAllConnections()
companion object {
fun create(
companion object : ImapStoreFactory {
override fun create(
serverSettings: ServerSettings,
config: ImapStoreConfig,
trustedSocketFactory: TrustedSocketFactory,

View file

@ -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
}

View file

@ -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<FolderListItem> = { 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<FolderListItem> {
openConnectionCount++
return getFoldersAction()
}
override fun closeAllConnections() {
openConnectionCount = 0
}
}

View file

@ -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<FolderFetcherException>()
.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<FolderFetcherException>()
.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<FolderFetcherException>()
.prop(FolderFetcherException::messageFromServer).isNull()
assertThat(fakeImapStore.hasOpenConnections).isFalse()
}
}