Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
12
backend/api/build.gradle.kts
Normal file
12
backend/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.outcome)
|
||||
implementation(projects.feature.mail.account.api)
|
||||
implementation(projects.feature.mail.folder.api)
|
||||
api(projects.mail.common)
|
||||
}
|
||||
94
backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt
Normal file
94
backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Part
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
|
||||
interface Backend {
|
||||
val supportsFlags: Boolean
|
||||
val supportsExpunge: Boolean
|
||||
val supportsMove: Boolean
|
||||
val supportsCopy: Boolean
|
||||
val supportsUpload: Boolean
|
||||
val supportsTrashFolder: Boolean
|
||||
val supportsSearchByDate: Boolean
|
||||
val supportsFolderSubscriptions: Boolean
|
||||
val isPushCapable: Boolean
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun refreshFolderList(): FolderPathDelimiter?
|
||||
|
||||
// TODO: Add a way to cancel the sync process
|
||||
fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun downloadMessageStructure(folderServerId: String, messageServerId: String)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun downloadCompleteMessage(folderServerId: String, messageServerId: String)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun markAllAsRead(folderServerId: String)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun expunge(folderServerId: String)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun deleteMessages(folderServerId: String, messageServerIds: List<String>)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun deleteAllMessages(folderServerId: String)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun moveMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>?
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun moveMessagesAndMarkAsRead(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>?
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun copyMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>?
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun search(
|
||||
folderServerId: String,
|
||||
query: String?,
|
||||
requiredFlags: Set<Flag>?,
|
||||
forbiddenFlags: Set<Flag>?,
|
||||
performFullTextSearch: Boolean,
|
||||
): List<String>
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun findByMessageId(folderServerId: String, messageId: String): String?
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun uploadMessage(folderServerId: String, message: Message): String?
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun sendMessage(message: Message)
|
||||
|
||||
fun createPusher(callback: BackendPusherCallback): BackendPusher
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import java.util.Date
|
||||
|
||||
// FIXME: add documentation
|
||||
interface BackendFolder {
|
||||
val name: String
|
||||
val visibleLimit: Int
|
||||
|
||||
fun getMessageServerIds(): Set<String>
|
||||
fun getAllMessagesAndEffectiveDates(): Map<String, Long?>
|
||||
fun destroyMessages(messageServerIds: List<String>)
|
||||
fun clearAllMessages()
|
||||
fun getMoreMessages(): MoreMessages
|
||||
fun setMoreMessages(moreMessages: MoreMessages)
|
||||
fun setLastChecked(timestamp: Long)
|
||||
fun setStatus(status: String?)
|
||||
fun isMessagePresent(messageServerId: String): Boolean
|
||||
fun getMessageFlags(messageServerId: String): Set<Flag>
|
||||
fun setMessageFlag(messageServerId: String, flag: Flag, value: Boolean)
|
||||
fun saveMessage(message: Message, downloadState: MessageDownloadState)
|
||||
fun getOldestMessageDate(): Date?
|
||||
fun getFolderExtraString(name: String): String?
|
||||
fun setFolderExtraString(name: String, value: String?)
|
||||
fun getFolderExtraNumber(name: String): Long?
|
||||
fun setFolderExtraNumber(name: String, value: Long)
|
||||
|
||||
enum class MoreMessages {
|
||||
UNKNOWN,
|
||||
FALSE,
|
||||
TRUE,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
interface BackendPusher {
|
||||
fun start()
|
||||
fun updateFolders(folderServerIds: Collection<String>)
|
||||
fun stop()
|
||||
fun reconnect()
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
interface BackendPusherCallback {
|
||||
fun onPushEvent(folderServerId: String)
|
||||
fun onPushError(exception: Exception)
|
||||
fun onPushNotSupported()
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import java.io.Closeable
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
interface BackendStorage {
|
||||
fun getFolder(folderServerId: String): BackendFolder
|
||||
|
||||
fun getFolderServerIds(): List<String>
|
||||
|
||||
fun createFolderUpdater(): BackendFolderUpdater
|
||||
|
||||
fun getExtraString(name: String): String?
|
||||
fun setExtraString(name: String, value: String)
|
||||
fun getExtraNumber(name: String): Long?
|
||||
fun setExtraNumber(name: String, value: Long)
|
||||
}
|
||||
|
||||
interface BackendFolderUpdater : Closeable {
|
||||
@Throws(MessagingException::class)
|
||||
fun createFolders(folders: List<FolderInfo>): Set<Long>
|
||||
fun deleteFolders(folderServerIds: List<String>)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun changeFolder(folderServerId: String, name: String, type: FolderType)
|
||||
}
|
||||
|
||||
fun BackendFolderUpdater.createFolder(folder: FolderInfo): Long? = createFolders(listOf(folder)).firstOrNull()
|
||||
|
||||
inline fun <T> BackendStorage.updateFolders(block: BackendFolderUpdater.() -> T): T {
|
||||
return createFolderUpdater().use { it.block() }
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
|
||||
data class FolderInfo(
|
||||
val serverId: String,
|
||||
val name: String,
|
||||
val type: FolderType,
|
||||
val folderPathDelimiter: FolderPathDelimiter? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
import java.util.Date
|
||||
|
||||
data class SyncConfig(
|
||||
val expungePolicy: ExpungePolicy,
|
||||
val earliestPollDate: Date?,
|
||||
val syncRemoteDeletions: Boolean,
|
||||
val maximumAutoDownloadMessageSize: Int,
|
||||
val defaultVisibleLimit: Int,
|
||||
val syncFlags: Set<Flag>,
|
||||
) {
|
||||
enum class ExpungePolicy {
|
||||
IMMEDIATELY,
|
||||
MANUALLY,
|
||||
ON_POLL,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
interface SyncListener {
|
||||
fun syncStarted(folderServerId: String)
|
||||
|
||||
fun syncAuthenticationSuccess()
|
||||
|
||||
fun syncHeadersStarted(folderServerId: String)
|
||||
fun syncHeadersProgress(folderServerId: String, completed: Int, total: Int)
|
||||
fun syncHeadersFinished(folderServerId: String, totalMessagesInMailbox: Int, numNewMessages: Int)
|
||||
|
||||
fun syncProgress(folderServerId: String, completed: Int, total: Int)
|
||||
fun syncNewMessage(folderServerId: String, messageServerId: String, isOldMessage: Boolean)
|
||||
fun syncRemovedMessage(folderServerId: String, messageServerId: String)
|
||||
fun syncFlagChanged(folderServerId: String, messageServerId: String)
|
||||
|
||||
fun syncFinished(folderServerId: String)
|
||||
fun syncFailed(folderServerId: String, message: String, exception: Exception?)
|
||||
|
||||
fun folderStatusChanged(folderServerId: String)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package net.thunderbird.backend.api
|
||||
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import net.thunderbird.feature.mail.account.api.BaseAccount
|
||||
|
||||
interface BackendFactory<TAccount : BaseAccount> {
|
||||
fun createBackend(account: TAccount): Backend
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package net.thunderbird.backend.api
|
||||
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import net.thunderbird.feature.mail.account.api.BaseAccount
|
||||
|
||||
interface BackendStorageFactory<in TAccount : BaseAccount> {
|
||||
fun createBackendStorage(account: TAccount): BackendStorage
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package net.thunderbird.backend.api.folder
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.folders.FolderServerId
|
||||
import net.thunderbird.core.outcome.Outcome
|
||||
import net.thunderbird.feature.mail.account.api.BaseAccount
|
||||
|
||||
interface RemoteFolderCreator {
|
||||
/**
|
||||
* Creates a folder on the remote server. If the folder already exists and [mustCreate] is `false`,
|
||||
* the operation will succeed returning [RemoteFolderCreationOutcome.Success.AlreadyExists].
|
||||
*
|
||||
* @param folderServerId The folder server ID.
|
||||
* @param mustCreate If `true`, the folder must be created returning
|
||||
* [RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder]. If `false`, the folder will be created
|
||||
* only if it doesn't exist.
|
||||
* @param folderType The folder type. This requires special handling for some servers. Default [FolderType.REGULAR].
|
||||
* @return The result of the operation.
|
||||
* @see RemoteFolderCreationOutcome.Success
|
||||
* @see RemoteFolderCreationOutcome.Error
|
||||
*/
|
||||
suspend fun create(
|
||||
folderServerId: FolderServerId,
|
||||
mustCreate: Boolean,
|
||||
folderType: FolderType = FolderType.REGULAR,
|
||||
): Outcome<RemoteFolderCreationOutcome.Success, RemoteFolderCreationOutcome.Error>
|
||||
|
||||
interface Factory {
|
||||
fun create(account: BaseAccount): RemoteFolderCreator
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface RemoteFolderCreationOutcome {
|
||||
sealed interface Success : RemoteFolderCreationOutcome {
|
||||
/**
|
||||
* Used to flag that the folder was created successfully.
|
||||
*/
|
||||
data object Created : Success
|
||||
|
||||
/**
|
||||
* Used to flag that the folder creation was skipped because the folder already exists and
|
||||
* the creation is NOT mandatory.
|
||||
*/
|
||||
data object AlreadyExists : Success
|
||||
}
|
||||
|
||||
sealed interface Error : RemoteFolderCreationOutcome {
|
||||
/**
|
||||
* Used to flag that the folder creation has failed because the folder already exists and
|
||||
* the creation is mandatory.
|
||||
*/
|
||||
data object AlreadyExists : Error
|
||||
|
||||
/**
|
||||
* Used to flag that the folder creation failed on the remote server.
|
||||
* @param reason The reason why the folder creation failed.
|
||||
*/
|
||||
data class FailedToCreateRemoteFolder(
|
||||
val reason: String,
|
||||
) : Error
|
||||
|
||||
/**
|
||||
* Used to flag that the Create Folder operation is not supported by the server.
|
||||
* E.g. POP3 servers don't support creating archive folders.
|
||||
*/
|
||||
data object NotSupportedOperation : Error
|
||||
}
|
||||
}
|
||||
14
backend/demo/build.gradle.kts
Normal file
14
backend/demo/build.gradle.kts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.backend.api)
|
||||
implementation(projects.feature.mail.folder.api)
|
||||
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
testImplementation(projects.mail.testing)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.backend.api.updateFolders
|
||||
import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
|
||||
internal class CommandRefreshFolderList(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val demoStore: DemoStore,
|
||||
) {
|
||||
|
||||
fun refreshFolderList(): FolderPathDelimiter? {
|
||||
val localFolderServerIds = backendStorage.getFolderServerIds().toSet()
|
||||
|
||||
backendStorage.updateFolders {
|
||||
val remoteFolderServerIds = demoStore.getFolderIds()
|
||||
val foldersServerIdsToCreate = remoteFolderServerIds - localFolderServerIds
|
||||
val foldersToCreate = foldersServerIdsToCreate.mapNotNull { folderServerId ->
|
||||
demoStore.getFolder(folderServerId)?.let { folderData ->
|
||||
FolderInfo(folderServerId, folderData.name, folderData.type)
|
||||
}
|
||||
}
|
||||
createFolders(foldersToCreate)
|
||||
|
||||
val folderServerIdsToRemove = (localFolderServerIds - remoteFolderServerIds).toList()
|
||||
deleteFolders(folderServerIdsToRemove)
|
||||
}
|
||||
|
||||
return FOLDER_DEFAULT_PATH_DELIMITER
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import app.k9mail.backend.demo.DemoHelper.createNewServerId
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import com.fsck.k9.mail.internet.MimeMessage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
internal class CommandSendMessage(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val demoStore: DemoStore,
|
||||
) {
|
||||
|
||||
fun sendMessage(message: Message) {
|
||||
val inboxServerId = demoStore.getInboxFolderId()
|
||||
val backendFolder = backendStorage.getFolder(inboxServerId)
|
||||
|
||||
val newMessage = message.copy(uid = createNewServerId())
|
||||
backendFolder.saveMessage(newMessage, MessageDownloadState.FULL)
|
||||
}
|
||||
|
||||
private fun Message.copy(uid: String): MimeMessage {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
writeTo(outputStream)
|
||||
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
|
||||
return MimeMessage.parseMimeMessage(inputStream, false).apply {
|
||||
this.uid = uid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import com.fsck.k9.backend.api.BackendFolder.MoreMessages
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
|
||||
internal class CommandSync(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val demoStore: DemoStore,
|
||||
) {
|
||||
|
||||
fun sync(folderServerId: String, listener: SyncListener) {
|
||||
listener.syncStarted(folderServerId)
|
||||
|
||||
val folder = demoStore.getFolder(folderServerId)
|
||||
if (folder == null) {
|
||||
listener.syncFailed(folderServerId, "Folder $folderServerId doesn't exist", null)
|
||||
return
|
||||
}
|
||||
|
||||
val backendFolder = backendStorage.getFolder(folderServerId)
|
||||
|
||||
val localMessageServerIds = backendFolder.getMessageServerIds()
|
||||
if (localMessageServerIds.isNotEmpty()) {
|
||||
listener.syncFinished(folderServerId)
|
||||
return
|
||||
}
|
||||
|
||||
for (messageServerId in folder.messageServerIds) {
|
||||
val message = demoStore.getMessage(folderServerId, messageServerId)
|
||||
backendFolder.saveMessage(message, MessageDownloadState.FULL)
|
||||
listener.syncNewMessage(folderServerId, messageServerId, isOldMessage = false)
|
||||
}
|
||||
|
||||
backendFolder.setMoreMessages(MoreMessages.FALSE)
|
||||
|
||||
listener.syncFinished(folderServerId)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import app.k9mail.backend.demo.DemoHelper.createNewServerId
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.backend.api.BackendPusher
|
||||
import com.fsck.k9.backend.api.BackendPusherCallback
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Part
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
|
||||
class DemoBackend(
|
||||
private val backendStorage: BackendStorage,
|
||||
) : Backend {
|
||||
private val demoStore by lazy { DemoStore() }
|
||||
|
||||
private val commandSync by lazy { CommandSync(backendStorage, demoStore) }
|
||||
private val commandRefreshFolderList by lazy { CommandRefreshFolderList(backendStorage, demoStore) }
|
||||
private val commandSendMessage by lazy { CommandSendMessage(backendStorage, demoStore) }
|
||||
|
||||
override val supportsFlags: Boolean = true
|
||||
override val supportsExpunge: Boolean = false
|
||||
override val supportsMove: Boolean = true
|
||||
override val supportsCopy: Boolean = true
|
||||
override val supportsUpload: Boolean = true
|
||||
override val supportsTrashFolder: Boolean = true
|
||||
override val supportsSearchByDate: Boolean = false
|
||||
override val supportsFolderSubscriptions: Boolean = false
|
||||
override val isPushCapable: Boolean = false
|
||||
|
||||
override fun refreshFolderList(): FolderPathDelimiter? {
|
||||
return commandRefreshFolderList.refreshFolderList()
|
||||
}
|
||||
|
||||
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
commandSync.sync(folderServerId, listener)
|
||||
}
|
||||
|
||||
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) = Unit
|
||||
|
||||
override fun markAllAsRead(folderServerId: String) = Unit
|
||||
|
||||
override fun expunge(folderServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) = Unit
|
||||
|
||||
override fun deleteAllMessages(folderServerId: String) = Unit
|
||||
|
||||
override fun moveMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String> {
|
||||
// We do just enough to simulate a successful operation on the server.
|
||||
return messageServerIds.associateWith { createNewServerId() }
|
||||
}
|
||||
|
||||
override fun moveMessagesAndMarkAsRead(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String> {
|
||||
// We do just enough to simulate a successful operation on the server.
|
||||
return messageServerIds.associateWith { createNewServerId() }
|
||||
}
|
||||
|
||||
override fun copyMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String> {
|
||||
// We do just enough to simulate a successful operation on the server.
|
||||
return messageServerIds.associateWith { createNewServerId() }
|
||||
}
|
||||
|
||||
override fun search(
|
||||
folderServerId: String,
|
||||
query: String?,
|
||||
requiredFlags: Set<Flag>?,
|
||||
forbiddenFlags: Set<Flag>?,
|
||||
performFullTextSearch: Boolean,
|
||||
): List<String> {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun findByMessageId(folderServerId: String, messageId: String): String? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun uploadMessage(folderServerId: String, message: Message): String {
|
||||
return createNewServerId()
|
||||
}
|
||||
|
||||
override fun sendMessage(message: Message) {
|
||||
commandSendMessage.sendMessage(message)
|
||||
}
|
||||
|
||||
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.internet.MimeMessage
|
||||
import java.io.InputStream
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
|
||||
internal class DemoDataLoader {
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun loadFolders(): DemoFolders {
|
||||
return getResourceAsStream("/contents.json").use { inputStream ->
|
||||
Json.decodeFromStream<DemoFolders>(inputStream)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMessage(folderServerId: String, messageServerId: String): Message {
|
||||
return getResourceAsStream("/$folderServerId/$messageServerId.eml").use { inputStream ->
|
||||
MimeMessage.parseMimeMessage(inputStream, false).apply {
|
||||
uid = messageServerId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getResourceAsStream(name: String): InputStream {
|
||||
return this.javaClass.getResourceAsStream(name) ?: error("Resource '$name' not found")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class DemoFolder(
|
||||
val name: String,
|
||||
val type: FolderType,
|
||||
val messageServerIds: List<String>,
|
||||
val subFolders: DemoFolders? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
internal typealias DemoFolders = Map<String, DemoFolder>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
internal object DemoHelper {
|
||||
fun createNewServerId() = UUID.randomUUID().toString()
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.Message
|
||||
|
||||
internal class DemoStore(
|
||||
private val demoDataLoader: DemoDataLoader = DemoDataLoader(),
|
||||
) {
|
||||
private val demoFolders: DemoFolders by lazy { flattenDemoFolders(demoDataLoader.loadFolders()) }
|
||||
|
||||
fun getFolder(folderServerId: String): DemoFolder? {
|
||||
return demoFolders[folderServerId]
|
||||
}
|
||||
|
||||
fun getFolderIds(): Set<String> {
|
||||
return demoFolders.keys
|
||||
}
|
||||
|
||||
fun getInboxFolderId(): String {
|
||||
return demoFolders.filterValues { it.type == FolderType.INBOX }.keys.first()
|
||||
}
|
||||
|
||||
fun getMessage(folderServerId: String, messageServerId: String): Message {
|
||||
return demoDataLoader.loadMessage(folderServerId, messageServerId)
|
||||
}
|
||||
|
||||
// This is a workaround for the fact that the backend doesn't support nested folders
|
||||
private fun flattenDemoFolders(
|
||||
demoFolders: DemoFolders,
|
||||
parentName: String = "",
|
||||
parentServerId: String = "",
|
||||
): DemoFolders {
|
||||
val flatFolders = mutableMapOf<String, DemoFolder>()
|
||||
for ((folderServerId, demoFolder) in demoFolders) {
|
||||
val fullName = if (parentName.isEmpty()) {
|
||||
demoFolder.name
|
||||
} else {
|
||||
"$parentName/${demoFolder.name}"
|
||||
}
|
||||
val fullServerId = if (parentServerId.isEmpty()) {
|
||||
folderServerId
|
||||
} else {
|
||||
"$parentServerId/$folderServerId"
|
||||
}
|
||||
flatFolders[fullServerId] = demoFolder.copy(name = fullName)
|
||||
|
||||
val subFolders = demoFolder.subFolders
|
||||
if (subFolders != null) {
|
||||
flatFolders.putAll(flattenDemoFolders(demoFolder.subFolders, fullName, fullServerId))
|
||||
}
|
||||
}
|
||||
return flatFolders
|
||||
}
|
||||
}
|
||||
88
backend/demo/src/main/resources/contents.json
Normal file
88
backend/demo/src/main/resources/contents.json
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"inbox": {
|
||||
"name": "Inbox",
|
||||
"type": "INBOX",
|
||||
"messageServerIds": [
|
||||
"intro",
|
||||
"many_recipients",
|
||||
"thread_1",
|
||||
"thread_2",
|
||||
"inline_image_data_uri",
|
||||
"inline_image_attachment",
|
||||
"localpart_exceeds_length_limit"
|
||||
]
|
||||
},
|
||||
"trash": {
|
||||
"name": "Trash",
|
||||
"type": "TRASH",
|
||||
"messageServerIds": []
|
||||
},
|
||||
"drafts": {
|
||||
"name": "Drafts",
|
||||
"type": "DRAFTS",
|
||||
"messageServerIds": []
|
||||
},
|
||||
"sent": {
|
||||
"name": "Sent",
|
||||
"type": "SENT",
|
||||
"messageServerIds": []
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive",
|
||||
"type": "ARCHIVE",
|
||||
"messageServerIds": []
|
||||
},
|
||||
"spam": {
|
||||
"name": "Spam",
|
||||
"type": "SPAM",
|
||||
"messageServerIds": []
|
||||
},
|
||||
"turing": {
|
||||
"name": "Turing Awards",
|
||||
"type": "REGULAR",
|
||||
"messageServerIds": [
|
||||
"turing_award_1966",
|
||||
"turing_award_1967",
|
||||
"turing_award_1968",
|
||||
"turing_award_1970",
|
||||
"turing_award_1971",
|
||||
"turing_award_1972",
|
||||
"turing_award_1975",
|
||||
"turing_award_1977",
|
||||
"turing_award_1978",
|
||||
"turing_award_1979",
|
||||
"turing_award_1981",
|
||||
"turing_award_1983",
|
||||
"turing_award_1987",
|
||||
"turing_award_1991",
|
||||
"turing_award_1996"
|
||||
]
|
||||
},
|
||||
"nested": {
|
||||
"name": "Nested",
|
||||
"type": "REGULAR",
|
||||
"messageServerIds": [
|
||||
"nested_1"
|
||||
],
|
||||
"subFolders": {
|
||||
"nested_level_1": {
|
||||
"name": "Nested Level 1",
|
||||
"type": "REGULAR",
|
||||
"messageServerIds": [
|
||||
"nested_level_1_1",
|
||||
"nested_level_1_2"
|
||||
],
|
||||
"subFolders": {
|
||||
"nested_level_2": {
|
||||
"name": "Nested Level 2",
|
||||
"type": "REGULAR",
|
||||
"messageServerIds": [
|
||||
"nested_level_2_1",
|
||||
"nested_level_2_2"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Test data" <data@example.com>
|
||||
Date: Tue, 14 Feb 2023 15:00:00 +0100
|
||||
Message-ID: <inbox-6@example.com>
|
||||
Subject: Inline image attachment
|
||||
To: User <user@example.com>
|
||||
Content-Type: multipart/related; boundary=BOUNDARY
|
||||
|
||||
--BOUNDARY
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<html>
|
||||
<body>
|
||||
<p>Inline image using a cid: URI to reference an attached image:</p>
|
||||
|
||||
<img src="cid:part1@example.com">
|
||||
</body>
|
||||
</html>
|
||||
--BOUNDARY
|
||||
Content-Type: image/png; name="thunderbird.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <part1@example.com>
|
||||
Content-Disposition: inline; filename="thunderbird.png"
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAEtWlUWHRYTUw6Y29tLmFkb2JlLnht
|
||||
cAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQi
|
||||
Pz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUg
|
||||
NS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIy
|
||||
LXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1s
|
||||
bnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJo
|
||||
dHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDov
|
||||
L25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFk
|
||||
b2JlLmNvbS94YXAvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hh
|
||||
cC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9z
|
||||
VHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjEyOCIKICAgZXhp
|
||||
ZjpQaXhlbFlEaW1lbnNpb249IjEyOCIKICAgZXhpZjpDb2xvclNwYWNlPSIxIgogICB0aWZmOklt
|
||||
YWdlV2lkdGg9IjEyOCIKICAgdGlmZjpJbWFnZUxlbmd0aD0iMTI4IgogICB0aWZmOlJlc29sdXRp
|
||||
b25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9u
|
||||
PSI3Mi8xIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmls
|
||||
ZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDI1LTAzLTA0VDE5OjEy
|
||||
OjU5KzAxOjAwIgogICB4bXA6TWV0YWRhdGFEYXRlPSIyMDI1LTAzLTA0VDE5OjEyOjU5KzAxOjAw
|
||||
Ij4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0
|
||||
RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5
|
||||
IFBob3RvIDIgMi42LjAiCiAgICAgIHN0RXZ0OndoZW49IjIwMjUtMDMtMDRUMTk6MTI6NTkrMDE6
|
||||
MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0
|
||||
aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PkBqBbkAAAGB
|
||||
aUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRzytEURTHPzNDxAixUCwmYTU0fjSxUUZCSdMYZbCZ
|
||||
eTNvRs2P13szabJVtooSG78W/AVslbVSRErWbIkNes6bUTPJnNu553O/957TveeCPZhUUkaVB1Lp
|
||||
rB6Y9LkWQouumhccVNHMEO1hxdDG/P4ZKtrHHTYr3vRatSqf+9fqozFDAVut8Kii6VnhKeGZ1axm
|
||||
8bZwq5IIR4VPhd26XFD41tIjRX62OF7kL4v1YGAc7E3CrngZR8pYSegpYXk5XalkTvm9j/USZyw9
|
||||
PyexU7wDgwCT+HAxzQTjeOlnRGYvvQzQJysq5HsK+bNkJFeRWSOPzgpxEmRxi5qT6jGJqugxGUny
|
||||
Vv//9tVQBweK1Z0+qH4yzbduqNmC703T/Dw0ze8jcDzCRbqUnzmA4XfRN0ta1z40rsPZZUmL7MD5
|
||||
BrQ9aGE9XJAc4nZVhdcTaAhByzXULRV79rvP8T0E1+SrrmB3D3rkfOPyD2DqZ+MT1h/FAAAACXBI
|
||||
WXMAAAsTAAALEwEAmpwYAAAgAElEQVR4nO29ebwmRXkv/q3qftfznn3OmY0BZoYBAdnRxKDmZxIQ
|
||||
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
|
||||
--BOUNDARY--
|
||||
File diff suppressed because one or more lines are too long
9
backend/demo/src/main/resources/inbox/intro.eml
Normal file
9
backend/demo/src/main/resources/inbox/intro.eml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Thunderbird" <thunderbird@example.com>
|
||||
Date: Thu, 23 Sep 2021 23:42:00 +0200
|
||||
Message-ID: <hello-1-2-3@example.com>
|
||||
Subject: Welcome to Thunderbird for Android
|
||||
To: User <user@example.com>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Congratulations, you have managed to set up Thunderbird for Android's demo account. Have fun exploring the app.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
MIME-Version: 1.0
|
||||
From: Sender <aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffggggg@example.com> (local part exceeds maximum length)
|
||||
Date: Thu, 15 Jun 2023 18:00:00 +0200
|
||||
Message-ID: <localpart@example.com>
|
||||
Subject: Localpart of email address exceeds 64 characters
|
||||
To: User <user@example.com>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
You should still be able to read this message.
|
||||
42
backend/demo/src/main/resources/inbox/many_recipients.eml
Normal file
42
backend/demo/src/main/resources/inbox/many_recipients.eml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Alice" <from1@example.com>, "Bob" <from2@example.com>
|
||||
Sender: "Bernd" <sender@example.com>
|
||||
Reply-To: <reply-to@example.com>
|
||||
Date: Mon, 23 Jan 2023 12:00:00 +0100
|
||||
Message-ID: <inbox-2@example.com>
|
||||
Subject: Message details demo
|
||||
To: "User 1" <to1@example.com>,
|
||||
"User 2" <to2@example.com>,
|
||||
"User 3" <to3@example.com>,
|
||||
"User 4" <to4@example.com>,
|
||||
"User 5" <to5@example.com>,
|
||||
"User 6" <to6@example.com>,
|
||||
"User 7" <to7@example.com>,
|
||||
"User 8" <to8@example.com>,
|
||||
"User 9" <to9@example.com>,
|
||||
"User 10" <to10@example.com>,
|
||||
"User 11" <to11@example.com>,
|
||||
"User 12" <to12@example.com>,
|
||||
"User 13" <to13@example.com>,
|
||||
"User 14" <to14@example.com>,
|
||||
"User 15" <to15@example.com>,
|
||||
"User 16" <to16@example.com>,
|
||||
"User 17" <to17@example.com>,
|
||||
"User 18" <to18@e10.example.com>,
|
||||
"User 19" <to19@e11.example.com>,
|
||||
"User 20" <to20@e12.example.com>
|
||||
Cc: "Copy 1" <cc1@example.com>,
|
||||
"Copy 2" <cc2@example.com>,
|
||||
"Copy 3" <cc3@example.com>
|
||||
Bcc: "Blind 1" <bcc1@example.com>,
|
||||
"Blind 2" <bcc2@example.com>,
|
||||
"Blind 3" <bcc3@example.com>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This message contains…
|
||||
- multiple addresses in the From: header
|
||||
- a Sender: header
|
||||
- a Reply-To: header
|
||||
- multiple addresses in the To: header
|
||||
- multiple addresses in the Cc: header
|
||||
- multiple addresses in the Bcc: header
|
||||
9
backend/demo/src/main/resources/inbox/thread_1.eml
Normal file
9
backend/demo/src/main/resources/inbox/thread_1.eml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
MIME-Version: 1.0
|
||||
From: Alice <alice@example.com>
|
||||
Date: Fri, 10 Feb 2023 10:00:00 +0100
|
||||
Message-ID: <thread-1@example.com>
|
||||
Subject: Thread
|
||||
To: Bob <bob@example.com>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This is the first message in this thread.
|
||||
11
backend/demo/src/main/resources/inbox/thread_2.eml
Normal file
11
backend/demo/src/main/resources/inbox/thread_2.eml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
MIME-Version: 1.0
|
||||
From: Bob <bob@example.com>
|
||||
Date: Fri, 10 Feb 2023 10:05:00 +0100
|
||||
Message-ID: <thread-2@example.com>
|
||||
Subject: Re: Thread
|
||||
To: Alice <alice@example.com>
|
||||
In-Reply-To: <thread-1@example.com>
|
||||
References: <thread-2@example.com>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This is the second message in this thread.
|
||||
20
backend/demo/src/main/resources/nested/nested_1.eml
Normal file
20
backend/demo/src/main/resources/nested/nested_1.eml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Nested User" <nested@example.com>
|
||||
Date: Mon, 01 Jan 2024 12:00:00 -0400
|
||||
Message-ID: <nested_1@example.com>
|
||||
Subject: Nested Message
|
||||
To: User <user@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This is a message in the Nested folder.
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>This is a message in the Nested folder.</div></div>
|
||||
|
||||
--047d7b450b100959e604d85a5320--
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Nested Level 1 User" <nested.level1@example.com>
|
||||
Date: Mon, 01 Jan 2024 12:00:00 -0400
|
||||
Message-ID: <nested_level_1_1@example.com>
|
||||
Subject: Nested Level 1 Message
|
||||
To: Nested Level 2 User <nested.level2@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This is a message 1 in the Nested Level 1 folder.
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>This is a message in the Nested Level 1 folder.</div></div>
|
||||
|
||||
--047d7b450b100959e604d85a5320--
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Nested Level 1 User" <nested.level1@example.com>
|
||||
Date: Mon, 01 Jan 2024 12:00:00 -0400
|
||||
Message-ID: <nested_level_1_2@example.com>
|
||||
Subject: Nested Level 1 Message
|
||||
To: Nested Level 2 User <nested.level2@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This is a message 2 in the Nested Level 1 folder.
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>This is a message in the Nested Level 1 folder.</div></div>
|
||||
|
||||
--047d7b450b100959e604d85a5320--
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Nested Level 2 User" <nested.level2@example.com>
|
||||
Date: Mon, 01 Jan 2024 12:00:00 -0400
|
||||
Message-ID: <nested_level_2_1@example.com>
|
||||
Subject: Nested Level 2 Message
|
||||
To: Nested Level 1 User <nested.level1@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This is a message 1 in the Nested Level 2 folder.
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>This is a message in the Nested Level 2 folder.</div></div>
|
||||
|
||||
--047d7b450b100959e604d85a5320--
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Nested Level 2 User" <nested.level2@example.com>
|
||||
Date: Mon, 01 Jan 2024 12:00:00 -0400
|
||||
Message-ID: <nested_level_2_2@example.com>
|
||||
Subject: Nested Level 2 Message
|
||||
To: Nested Level 1 User <nested.level1@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This is a message 2 in the Nested Level 2 folder.
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>This is a message in the Nested Level 2 folder.</div></div>
|
||||
|
||||
--047d7b450b100959e604d85a5320--
|
||||
84
backend/demo/src/main/resources/turing/turing_award_1966.eml
Normal file
84
backend/demo/src/main/resources/turing/turing_award_1966.eml
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Alan J. Perlis" <alan.perlis@example.com>
|
||||
Date: Sat, 01 Jan 1966 12:00:00 -0400
|
||||
Message-ID: <turing1966@example.com>
|
||||
Subject: The Synthesis of Algorithmic Systems
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Both knowledge and wisdom extend man's reach. Knowledge led to computers,
|
||||
wisdom to chopsticks. Unfortunately our association is overinvolved with
|
||||
the former. The latter will have to wait for a more sublime day.
|
||||
On what does and will the fame of Turing rest? That he proved a theorem
|
||||
showing that for a general computing device--later dubbed a "Turing
|
||||
machine"--there existed functions which it could not compute? I doubt it.
|
||||
More likely it rests on the model he invented and employed: his formal
|
||||
mechanism.
|
||||
This model has captured the imagination and mobilized the thoughts of a
|
||||
generation of scientists. It has provided a basis for arguments leading to
|
||||
theories. His model has proved so useful that its generated activity has
|
||||
been distributed not only in mathematics, but through several technologies
|
||||
as well. The arguments that have been employed are not always formal and
|
||||
the consequent creations not all abstract.
|
||||
Indeed a most fruitful consequence of the Turing machine has been with the
|
||||
creation, study and computation of functions which are computable, i.e., in
|
||||
computer programming. This is not surprising since computers can compute so
|
||||
much more than we yet know how to specify.
|
||||
I am sure that all will agree that this model has been enormously valuable.
|
||||
History will forgive me for not devoting any attention in this lecture to
|
||||
the effect which Turing had on the development of the general-purpose
|
||||
digital computer, which has further accelerated our involvement with the
|
||||
theory and practice of computation.
|
||||
Since the appearance of Turing's model there have, of course, been others
|
||||
which have concerned and benefited us in computing. I think, however, that
|
||||
only one has had an effect as great as Turing's: the formal mechanism
|
||||
called ALGOL Many will immediately disagree, pointing out that too few of
|
||||
us have understood it or used it.
|
||||
While such has, unhappily, been the case, it is not the point. The impulse
|
||||
given by ALGOL to the development of research in computer science is
|
||||
relevant while the number of adherents is not. ALGOL, too, has mobilized
|
||||
our thoughts and has provided us with a basis for our arguments.
|
||||
|
||||
--047d7b450b100959e604d85a5320
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>Both knowledge and wisdom extend man's reach. Kno=
|
||||
wledge led to computers, wisdom to chopsticks. Unfortunately our associatio=
|
||||
n is overinvolved with the former. The latter will have to wait for a more =
|
||||
sublime day.=C2=A0</div>
|
||||
<div>On what does and will the fame of Turing rest? That he proved a theore=
|
||||
m showing that for a general computing device--later dubbed a "Turing =
|
||||
machine"--there existed functions which it could not compute? I doubt =
|
||||
it. More likely it rests on the model he invented and employed: his formal =
|
||||
mechanism.=C2=A0</div>
|
||||
<div>This model has captured the imagination and mobilized the thoughts of =
|
||||
a generation of scientists. It has provided a basis for arguments leading t=
|
||||
o theories. His model has proved so useful that its generated activity has =
|
||||
been distributed not only in mathematics, but through several technologies =
|
||||
as well. The arguments that have been employed are not always formal and th=
|
||||
e consequent creations not all abstract.=C2=A0</div>
|
||||
<div>Indeed a most fruitful consequence of the Turing machine has been with=
|
||||
the creation, study and computation of functions which are computable, i.e=
|
||||
., in computer programming. This is not surprising since computers can comp=
|
||||
ute so much more than we yet know how to specify.=C2=A0</div>
|
||||
<div>I am sure that all will agree that this model has been enormously valu=
|
||||
able. History will forgive me for not devoting any attention in this lectur=
|
||||
e to the effect which Turing had on the development of the general-purpose =
|
||||
digital computer, which has further accelerated our involvement with the th=
|
||||
eory and practice of computation.=C2=A0</div>
|
||||
<div>Since the appearance of Turing's model there have, of course, been=
|
||||
others which have concerned and benefited us in computing. I think, howeve=
|
||||
r, that only one has had an effect as great as Turing's: the formal mec=
|
||||
hanism called ALGOL Many will immediately disagree, pointing out that too f=
|
||||
ew of us have understood it or used it.=C2=A0</div>
|
||||
<div>While such has, unhappily, been the case, it is not the point. The imp=
|
||||
ulse given by ALGOL to the development of research in computer science is r=
|
||||
elevant while the number of adherents is not. ALGOL, too, has mobilized our=
|
||||
thoughts and has provided us with a basis for our arguments.=C2=A0</div>
|
||||
</div>
|
||||
|
||||
--047d7b450b100959e604d85a5320--
|
||||
35
backend/demo/src/main/resources/turing/turing_award_1967.eml
Normal file
35
backend/demo/src/main/resources/turing/turing_award_1967.eml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Maurice V. Wilkes" <maurice.wilkes@example.com>
|
||||
Date: Wed, 30 Aug 1967 12:00:00 -0400
|
||||
Message-ID: <turing1967@example.com>
|
||||
Subject: Computers Then and Now
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b5d9bdd0d571a04d85aec30
|
||||
|
||||
--047d7b5d9bdd0d571a04d85aec30
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
I do not imagine that many of the Turing lecturers who will follow me will
|
||||
be people who were acquainted with Alan Turing. The work on computable
|
||||
numbers, for which he is famous, was published in 1936 before digital
|
||||
computers existed. Later he became one of the first of a distinguished
|
||||
succession of able mathematicians who have made contributions to the
|
||||
computer field. He was a colorful figure in the early days of digital
|
||||
computer development in England, and I would find it difficult to speak of
|
||||
that period without making some references to him.
|
||||
|
||||
--047d7b5d9bdd0d571a04d85aec30
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>I do not imagine that many of the Turing lecturers wh=
|
||||
o will follow me will be people who were acquainted with Alan Turing. The w=
|
||||
ork on computable numbers, for which he is famous, was published in 1936 be=
|
||||
fore digital computers existed. Later he became one of the first of a disti=
|
||||
nguished succession of able mathematicians who have made contributions to t=
|
||||
he computer field. He was a colorful figure in the early days of digital co=
|
||||
mputer development in England, and I would find it difficult to speak of th=
|
||||
at period without making some references to him.</div>
|
||||
</div>
|
||||
|
||||
--047d7b5d9bdd0d571a04d85aec30--
|
||||
40
backend/demo/src/main/resources/turing/turing_award_1968.eml
Normal file
40
backend/demo/src/main/resources/turing/turing_award_1968.eml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
MIME-Version: 1.0
|
||||
From: Richard Hamming <richard.hamming@example.com>
|
||||
Date: Tue, 27 Aug 1968 12:00:00 -0400
|
||||
Message-ID: <turing1968@example.com>
|
||||
Subject: One Man's View of Computer Science
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=089e01227b30f6f60004d85af2ae
|
||||
|
||||
--089e01227b30f6f60004d85af2ae
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Let me begin with a few personal words. When one is notified that he has
|
||||
been elected the ACM Turing lecturer for the year, he is at first
|
||||
surprised--especially is the nonacademic person surprised by an ACM award.
|
||||
After a little while the surprise is replaced by a feeling of pleasure.
|
||||
Still later comes a feeling of "Why me?" With all that has been done and is
|
||||
being done in computing, why single out me and my work? Well, I suppose
|
||||
that it has to happen to someone each year, and this
|
||||
time I am the lucky person. Anyway, let me thank you for the honor you have
|
||||
given to me and by inference to the Bell Telephone Laboratories where I
|
||||
work and which has made possible so much of what I have done.
|
||||
|
||||
--089e01227b30f6f60004d85af2ae
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>Let me begin with a few personal words. When one is n=
|
||||
otified that he has been elected the ACM Turing lecturer for the year, he i=
|
||||
s at first surprised--especially is the nonacademic person surprised by an =
|
||||
ACM award. After a little while the surprise is replaced by a feeling of pl=
|
||||
easure. Still later comes a feeling of "Why me?" With all that ha=
|
||||
s been done and is being done in computing, why single out me and my work? =
|
||||
Well, I suppose that it has to happen to someone each year, and this=C2=A0<=
|
||||
/div>
|
||||
<div>time I am the lucky person. Anyway, let me thank you for the honor you=
|
||||
have given to me and by inference to the Bell Telephone Laboratories where=
|
||||
I work and which has made possible so much of what I have done.</div></div=
|
||||
>
|
||||
|
||||
--089e01227b30f6f60004d85af2ae--
|
||||
35
backend/demo/src/main/resources/turing/turing_award_1970.eml
Normal file
35
backend/demo/src/main/resources/turing/turing_award_1970.eml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
MIME-Version: 1.0
|
||||
From: "James H. Wilkinson" <james.wilkinson@example.com>
|
||||
Date: Tue, 01 Sep 1970 12:00:00 -0400
|
||||
Message-ID: <turing1970@example.com>
|
||||
Subject: Some Comments from a Numerical Analyst
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b5d9bdd9697d504d85ac65f
|
||||
|
||||
--047d7b5d9bdd9697d504d85ac65f
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
When at last I recovered from the feeling of shocked elation at being
|
||||
invited to give the 1970 Turing Award Lecture, I became aware that I must
|
||||
indeed prepare an appropriate lecture. There appears to be a tradition that
|
||||
a Turing Lecturer should decide for himself what is expected from him, and
|
||||
probably for this reason previous lectures have differed considerably in
|
||||
style and content. However, it was made quite clear that I was to give an
|
||||
after-luncheon speech and that I would not have the benefit of an overhead
|
||||
projector or a blackboard.
|
||||
|
||||
--047d7b5d9bdd9697d504d85ac65f
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>When at last I recovered from the feeling of shocked =
|
||||
elation at being invited to give the 1970 Turing Award Lecture, I became aw=
|
||||
are that I must indeed prepare an appropriate lecture. There appears to be =
|
||||
a tradition that a Turing Lecturer should decide for himself what is expect=
|
||||
ed from him, and probably for this reason previous lectures have differed c=
|
||||
onsiderably in style and content. However, it was made quite clear that I w=
|
||||
as to give an after-luncheon speech and that I would not have the benefit o=
|
||||
f an overhead projector or a blackboard.</div>
|
||||
</div>
|
||||
|
||||
--047d7b5d9bdd9697d504d85ac65f--
|
||||
32
backend/demo/src/main/resources/turing/turing_award_1971.eml
Normal file
32
backend/demo/src/main/resources/turing/turing_award_1971.eml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
MIME-Version: 1.0
|
||||
From: John McCarthy <john.mccarthy@example.com>
|
||||
Date: Fri, 01 Jan 1971 12:00:00 -0400
|
||||
Message-ID: <turing1971@example.com>
|
||||
Subject: Generality in Artificial Intelligence
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=089e01030106b6942904d85ad870
|
||||
|
||||
--089e01030106b6942904d85ad870
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Postscript
|
||||
My 1971 Turing Award Lecture was entitled "Generality in Artificial
|
||||
Intelligence." The topic turned out to have been overambitious in that I
|
||||
discovered that I was unable to put my thoughts on the subject in a
|
||||
satisfactory written form at that time. It would have been better to have
|
||||
reviewed previous work rather than attempt something new, but such wasn't
|
||||
my custom at that time.
|
||||
|
||||
--089e01030106b6942904d85ad870
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>Postscript</div><div>My 1971 Turing Award Lecture was=
|
||||
entitled "Generality in Artificial Intelligence." The topic turn=
|
||||
ed out to have been overambitious in that I discovered that I was unable to=
|
||||
put my thoughts on the subject in a satisfactory written form at that time=
|
||||
. It would have been better to have reviewed previous work rather than atte=
|
||||
mpt something new, but such wasn't my custom at that time.</div>
|
||||
</div>
|
||||
|
||||
--089e01030106b6942904d85ad870--
|
||||
27
backend/demo/src/main/resources/turing/turing_award_1972.eml
Normal file
27
backend/demo/src/main/resources/turing/turing_award_1972.eml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Edsger W. Dijkstra" <edsger.dijkstra@example.com>
|
||||
Date: Mon, 02 Aug 1972 12:00:00 -0500
|
||||
Message-ID: <turing1972@example.com>
|
||||
Subject: The Humble Programmer
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: text/plain; charset=UTF-8; format=flowed
|
||||
|
||||
As a result of a long sequence of coincidences I entered the programming
|
||||
profession officially on the first spring morning of 1952, and as far as
|
||||
I have been able to trace, I was the first Dutchman to do so in my
|
||||
country. In retrospect the most amazing thing is the slowness with which,
|
||||
at least in my part of the world, the programming profession emerged, a
|
||||
slowness which is now hard to believe. But I am grateful for two vivid
|
||||
recollections from that period that establish that slowness beyond any
|
||||
doubt.
|
||||
|
||||
After having programmed for some three years, I had a discussion with
|
||||
van Wijngaarden, who was then my boss at the Mathematical Centre in
|
||||
Amsterdam - a discussion for which I shall remain grateful to him
|
||||
as long as I live. The point was that I was supposed to study theoretical
|
||||
physics at the University of Leiden simultaneously, and as I found the
|
||||
two activities harder and harder to combine, I had to make up my
|
||||
mind, either to stop programming and become a real, respectable theoretical
|
||||
physicist, or to carry my study of physics to a formal completion only,
|
||||
with a minimum of effort, and to become..., yes what? A programmer?
|
||||
But was that a respectable profession? After all, what was programming?
|
||||
30
backend/demo/src/main/resources/turing/turing_award_1975.eml
Normal file
30
backend/demo/src/main/resources/turing/turing_award_1975.eml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
MIME-Version: 1.0
|
||||
From: Allen Newell <allen.newell@example.com>
|
||||
Cc: Herbert Simon <herbert.simon@example.com>
|
||||
Date: Mon, 20 Oct 1975 12:00:00 -0500
|
||||
Message-ID: <turing1975@example.com>
|
||||
Subject: Computer Science as Empirical Inquiry: Symbols and Search
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b450b1092035304d85abf33
|
||||
|
||||
--047d7b450b1092035304d85abf33
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Computer science is the study of the phenomena surrounding computers. The
|
||||
founders of this society understood this very well when they called
|
||||
themselves the Association for Computing Machinery. The machine---not just
|
||||
the hardware, but the programmed, living machine--is the organism we study.
|
||||
|
||||
--047d7b450b1092035304d85abf33
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr">Computer science is the study of the phenomena surrounding=
|
||||
computers. The founders of this society understood this very well when the=
|
||||
y called themselves the Association for Computing Machinery. The machine---=
|
||||
not just the hardware, but the programmed, living machine--is the organism =
|
||||
we study.<br>
|
||||
|
||||
</div>
|
||||
|
||||
--047d7b450b1092035304d85abf33--
|
||||
39
backend/demo/src/main/resources/turing/turing_award_1977.eml
Normal file
39
backend/demo/src/main/resources/turing/turing_award_1977.eml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
MIME-Version: 1.0
|
||||
From: "John W. Backus" <john.backus@example.com>
|
||||
Date: Mon, 17 Oct 1977 12:00:00 -0700
|
||||
Message-ID: <turing1977@example.com>
|
||||
Subject: Can Programming Be Liberated from the von Neumann Style? A Functional
|
||||
Style and Its Algebra of Programs
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b5d9bdd8a36e804d85ade47
|
||||
|
||||
--047d7b5d9bdd8a36e804d85ade47
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Conventional programming languages are growing ever more enormous, but not
|
||||
stronger. Inherent defects at the most basic level cause them to be both
|
||||
fat and weak: their primitive word-at-a-time style of programming inherited
|
||||
from their common ancestor--the von Neumann computer, their close coupling
|
||||
of semantics to state transitions, their division of programming into a
|
||||
world of expressions and a world of statements, their inability to
|
||||
effectively use powerful combining forms for building new programs from
|
||||
existing ones, and their lack of useful mathematical properties for
|
||||
reasoning about
|
||||
programs.
|
||||
|
||||
--047d7b5d9bdd8a36e804d85ade47
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>Conventional programming languages are growing ever m=
|
||||
ore enormous, but not stronger. Inherent defects at the most basic level ca=
|
||||
use them to be both fat and weak: their primitive word-at-a-time style of p=
|
||||
rogramming inherited from their common ancestor--the von Neumann computer, =
|
||||
their close coupling of semantics to state transitions, their division of p=
|
||||
rogramming into a world of expressions and a world of statements, their ina=
|
||||
bility to effectively use powerful combining forms for building new program=
|
||||
s from existing ones, and their lack of useful mathematical properties for =
|
||||
reasoning about=C2=A0</div>
|
||||
<div>programs.</div></div>
|
||||
|
||||
--047d7b5d9bdd8a36e804d85ade47--
|
||||
36
backend/demo/src/main/resources/turing/turing_award_1978.eml
Normal file
36
backend/demo/src/main/resources/turing/turing_award_1978.eml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
MIME-Version: 1.0
|
||||
From: Robert Floyd <robert.floyd@example.com>
|
||||
Date: Mon, 04 Dec 1978 12:00:00 -0500
|
||||
Message-ID: <turing1978@example.com>
|
||||
Subject: The Paradigms of Programming
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=089e0118419206e64304d85af860
|
||||
|
||||
--089e0118419206e64304d85af860
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Today I want to talk about the paradigms of programming, how they affect
|
||||
our success as designers of computer programs, how they should be taught,
|
||||
and how they should be embodied in our programming languages.
|
||||
A familiar example of a paradigm of programming is the technique of
|
||||
structured programming, which appears to be the dominant paradigm in most
|
||||
current treatments of programming methodology. Structured programming, as
|
||||
formulated by Dijkstra, Wirth, and Parnas, among others, consists of two
|
||||
phases.
|
||||
|
||||
--089e0118419206e64304d85af860
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>Today I want to talk about the paradigms of programmi=
|
||||
ng, how they affect our success as designers of computer programs, how they=
|
||||
should be taught, and how they should be embodied in our programming langu=
|
||||
ages.=C2=A0</div>
|
||||
<div>A familiar example of a paradigm of programming is the technique of st=
|
||||
ructured programming, which appears to be the dominant paradigm in most cur=
|
||||
rent treatments of programming methodology. Structured programming, as form=
|
||||
ulated by Dijkstra, Wirth, and Parnas, among others, consists of two phases=
|
||||
.=C2=A0</div>
|
||||
</div>
|
||||
|
||||
--089e0118419206e64304d85af860--
|
||||
33
backend/demo/src/main/resources/turing/turing_award_1979.eml
Normal file
33
backend/demo/src/main/resources/turing/turing_award_1979.eml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Kenneth E. Iverson" <kenneth.iverson@example.com>
|
||||
Date: Mon, 29 Oct 1979 12:00:00 -0500
|
||||
Message-ID: <turing1979@example.com>
|
||||
Subject: Notation as a Tool of Thought
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=20cf30549cad76254e04d85ae4df
|
||||
|
||||
--20cf30549cad76254e04d85ae4df
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
The importance of nomenclature, notation, and language as tools of thought
|
||||
has long been recognized. In chemistry and in botany, for example, the
|
||||
establishment of systems of nomenclature by Lavoisier and Linnaeus did much
|
||||
to stimulate and to channel later investigation. Concerning language,
|
||||
George Boole in his Laws off Thought asserted "That language is an
|
||||
instrument of human reason, and not merely a medium for the expression of
|
||||
thought, is a truth generally admitted."
|
||||
|
||||
--20cf30549cad76254e04d85ae4df
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>The importance of nomenclature, notation, and languag=
|
||||
e as tools of thought has long been recognized. In chemistry and in botany,=
|
||||
for example, the establishment of systems of nomenclature by Lavoisier and=
|
||||
Linnaeus did much to stimulate and to channel later investigation. Concern=
|
||||
ing language, George Boole in his Laws off Thought asserted "That lang=
|
||||
uage is an instrument of human reason, and not merely a medium for the expr=
|
||||
ession of thought, is a truth generally admitted."</div>
|
||||
</div>
|
||||
|
||||
--20cf30549cad76254e04d85ae4df--
|
||||
51
backend/demo/src/main/resources/turing/turing_award_1981.eml
Normal file
51
backend/demo/src/main/resources/turing/turing_award_1981.eml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Edgar F. Codd" <edgar.codd@example.com>
|
||||
Date: Wed, 11 Nov 1981 12:00:00 -0800
|
||||
Message-ID: <turing1981@example.com>
|
||||
Subject: Relational Database: A Practical Foundation for Productivity
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7bfd026c782f2404d85ab4b8
|
||||
|
||||
--047d7bfd026c782f2404d85ab4b8
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
It is well known that the growth in demands from end users for new
|
||||
applications is outstripping the capability of data processing departments
|
||||
to implement the corresponding application programs. There are two
|
||||
complementary approaches to attacking this problem (and both approaches are
|
||||
needed): one is to put end users into direct touch with the information
|
||||
stored in computers; the other is to increase the productivity of data
|
||||
processing professionals in the development of application programs. It is
|
||||
less well known that a single technology, relational database management,
|
||||
provides a practical foundation for both approaches. It is explained why
|
||||
this
|
||||
is so.
|
||||
While developing this productivity theme, it is noted that the time has
|
||||
come to draw a very sharp line between relational and non-relational
|
||||
database systems, so that the label "relational" will not be used in
|
||||
misleading ways.
|
||||
The key to drawing this line is something called a "relational processing
|
||||
capability."
|
||||
|
||||
--047d7bfd026c782f2404d85ab4b8
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>It is well known that the growth in demands from end =
|
||||
users for new applications is outstripping the capability of data processin=
|
||||
g departments to implement the corresponding application programs. There ar=
|
||||
e two complementary approaches to attacking this problem (and both approach=
|
||||
es are needed): one is to put end users into direct touch with the informat=
|
||||
ion stored in computers; the other is to increase the productivity of data =
|
||||
processing professionals in the development of application programs. It is =
|
||||
less well known that a single technology, relational database management, p=
|
||||
rovides a practical foundation for both approaches. It is explained why thi=
|
||||
s=C2=A0</div>
|
||||
<div><div>is so.=C2=A0</div><div>While developing this productivity theme, =
|
||||
it is noted that the time has come to draw a very sharp line between relati=
|
||||
onal and non-relational database systems, so that the label "relationa=
|
||||
l" will not be used in misleading ways.=C2=A0</div>
|
||||
<div>The key to drawing this line is something called a "relational pr=
|
||||
ocessing capability."</div></div></div>
|
||||
|
||||
--047d7bfd026c782f2404d85ab4b8--
|
||||
46
backend/demo/src/main/resources/turing/turing_award_1983.eml
Normal file
46
backend/demo/src/main/resources/turing/turing_award_1983.eml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
MIME-Version: 1.0
|
||||
From: Dennis Ritchie <dennis.ritchie@example.com>
|
||||
Date: Mon, 24 Oct 1983 12:00:00 -0400
|
||||
Message-ID: <turing1983@example.com>
|
||||
Subject: Reflections on Software Research
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=bcaec54fbb2250035a04d85aabcd
|
||||
|
||||
--bcaec54fbb2250035a04d85aabcd
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
The UNIX operating system has suddenly become news, but it is not new. It
|
||||
began in 1969 when Ken Thompson discovered a little-used PDP-7 computer and
|
||||
set out to fashion a computing environment that he liked, His work soon
|
||||
attracted me; I joined in the enterprise, though most of the ideas, and
|
||||
most of the work for that matter, were his. Before long, others from our
|
||||
group in the research area of AT&T Bell Laboratories were using the system;
|
||||
Joe Ossanna, Doug Mcllroy, and
|
||||
Bob Morris were especially enthusiastic critics and contributors, tn 1971,
|
||||
we acquired a PDP-11, and by the end of that year we were supporting our
|
||||
first real users: three typists entering patent applications. In 1973, the
|
||||
system was rewritten in the C language, and in that year, too, it was first
|
||||
described publicly at the Operating Systems Principles conference; the
|
||||
resulting paper appeared in Communications of the ACM the next year.
|
||||
|
||||
--bcaec54fbb2250035a04d85aabcd
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>The UNIX operating system has suddenly become news, b=
|
||||
ut it is not new. It began in 1969 when Ken Thompson discovered a little-us=
|
||||
ed PDP-7 computer and set out to fashion a computing environment that he li=
|
||||
ked, His work soon attracted me; I joined in the enterprise, though most of=
|
||||
the ideas, and most of the work for that matter, were his. Before long, ot=
|
||||
hers from our group in the research area of AT&T Bell Laboratories were=
|
||||
using the system; Joe Ossanna, Doug Mcllroy, and=C2=A0</div>
|
||||
<div>Bob Morris were especially enthusiastic critics and contributors, tn 1=
|
||||
971, we acquired a PDP-11, and by the end of that year we were supporting o=
|
||||
ur first real users: three typists entering patent applications. In 1973, t=
|
||||
he system was rewritten in the C language, and in that year, too, it was fi=
|
||||
rst described publicly at the Operating Systems Principles conference; the =
|
||||
resulting paper appeared in Communications of the ACM the next year.=C2=A0<=
|
||||
/div>
|
||||
</div>
|
||||
|
||||
--bcaec54fbb2250035a04d85aabcd--
|
||||
42
backend/demo/src/main/resources/turing/turing_award_1987.eml
Normal file
42
backend/demo/src/main/resources/turing/turing_award_1987.eml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
MIME-Version: 1.0
|
||||
From: John Cocke <john.cocke@example.com>
|
||||
Date: Mon, 16 Feb 1987 12:00:00 -0600
|
||||
Message-ID: <turing1987@example.com>
|
||||
Subject: The Search for Performance in Scientific Processors
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7bfd079665fb2c04d85ad0bc
|
||||
|
||||
--047d7bfd079665fb2c04d85ad0bc
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
I am honored and grateful to have been selected to join the ranks of ACM
|
||||
Turing Award winners. I probably have spent too much of my life thinking
|
||||
about computers, but I do not regret it a bit. I was fortunate to enter the
|
||||
field of computing in its infancy and participate in its explosive growth.
|
||||
The rapid evolution of the underlying technologies in the past 30 years has
|
||||
not only provided an exciting environment, but has also presented a
|
||||
constant stream of intellectual challenges to those of us trying to harness
|
||||
this power and squeeze it to the last ounce. I hasten to say, especially to
|
||||
the
|
||||
younger members of the audience, there is no end in sight. As a matter of
|
||||
fact, I believe the next thirty years will be even more exciting and rich
|
||||
with challenges.
|
||||
|
||||
--047d7bfd079665fb2c04d85ad0bc
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>I am honored and grateful to have been selected to jo=
|
||||
in the ranks of ACM Turing Award winners. I probably have spent too much of=
|
||||
my life thinking about computers, but I do not regret it a bit. I was fort=
|
||||
unate to enter the field of computing in its infancy and participate in its=
|
||||
explosive growth. The rapid evolution of the underlying technologies in th=
|
||||
e past 30 years has not only provided an exciting environment, but has also=
|
||||
presented a constant stream of intellectual challenges to those of us tryi=
|
||||
ng to harness this power and squeeze it to the last ounce. I hasten to say,=
|
||||
especially to the=C2=A0</div>
|
||||
<div>younger members of the audience, there is no end in sight. As a matter=
|
||||
of fact, I believe the next thirty years will be even more exciting and ri=
|
||||
ch with challenges.=C2=A0</div></div>
|
||||
|
||||
--047d7bfd079665fb2c04d85ad0bc--
|
||||
44
backend/demo/src/main/resources/turing/turing_award_1991.eml
Normal file
44
backend/demo/src/main/resources/turing/turing_award_1991.eml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
MIME-Version: 1.0
|
||||
From: Robin Milner <robin.milner@example.com>
|
||||
Date: Mon, 18 Nov 1991 12:00:00 -0700
|
||||
Message-ID: <turing1991@example.com>
|
||||
Subject: Elements of Interaction
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=047d7b86e6de64aecb04d85affff
|
||||
|
||||
--047d7b86e6de64aecb04d85affff
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
I am greatly honored to receive this award, bearing the name of Alan
|
||||
Turing. Perhaps Turing would be pleased that it should go to someone
|
||||
educated at his old college, King's College at Cambridge. While there in
|
||||
1956 I wrote my first computer program; it was on the EDSAC. Of course
|
||||
EDSAC made history. But I am ashamed to say it did not lure me into
|
||||
computing, and I ignored computers for four years. In 1960 I thought that
|
||||
computers might be more peaceful to handle than schoolchildren--I was then
|
||||
a teacher--so I applied for a job at Ferranti in London, at the time of
|
||||
Pegasus. I was asked at the interview whether I would like to devote my
|
||||
life to computers. This daunting notion had never crossed my mind. Well,
|
||||
here I am still, and I have had the lucky chance to grow alongside computer
|
||||
science.
|
||||
|
||||
--047d7b86e6de64aecb04d85affff
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>I am greatly honored to receive this award, bearing t=
|
||||
he name of Alan Turing. Perhaps Turing would be pleased that it should go t=
|
||||
o someone educated at his old college, King's College at Cambridge. Whi=
|
||||
le there in 1956 I wrote my first computer program; it was on the EDSAC. Of=
|
||||
course EDSAC made history. But I am ashamed to say it did not lure me into=
|
||||
computing, and I ignored computers for four years. In 1960 I thought that =
|
||||
computers might be more peaceful to handle than schoolchildren--I was then =
|
||||
a teacher--so I applied for a job at Ferranti in London, at the time of=C2=
|
||||
=A0</div>
|
||||
<div>Pegasus. I was asked at the interview whether I would like to devote m=
|
||||
y life to computers. This daunting notion had never crossed my mind. Well, =
|
||||
here I am still, and I have had the lucky chance to grow alongside computer=
|
||||
science.</div>
|
||||
</div>
|
||||
|
||||
--047d7b86e6de64aecb04d85affff--
|
||||
28
backend/demo/src/main/resources/turing/turing_award_1996.eml
Normal file
28
backend/demo/src/main/resources/turing/turing_award_1996.eml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
MIME-Version: 1.0
|
||||
From: Amir Pnueli <amir.pnueli@example.com>
|
||||
Date: Thu, 15 Feb 1996 12:00:00 -0500
|
||||
Message-ID: <turing1996@example.com>
|
||||
Subject: Verification Engineering: A Future Profession
|
||||
To: Alan Turing <alan.turing@example.com>
|
||||
Content-Type: multipart/alternative; boundary=bcaec54fbb222acf6704d85aa523
|
||||
|
||||
--bcaec54fbb222acf6704d85aa523
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
It is time that formal verification (of both software and hardware systems)
|
||||
be demoted from an art practiced by the enlightened few to an activity
|
||||
routinely and mundanely performed by a cadre of Verification Engineers (a
|
||||
new profession), as a standard part of the system development process.
|
||||
|
||||
--bcaec54fbb222acf6704d85aa523
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>It is time that formal verification (of both software=
|
||||
and hardware systems) be demoted from an art practiced by the enlightened =
|
||||
few to an activity routinely and mundanely performed by a cadre of Verifica=
|
||||
tion Engineers (a new profession), as a standard part of the system develop=
|
||||
ment process.</div>
|
||||
</div>
|
||||
|
||||
--bcaec54fbb222acf6704d85aa523--
|
||||
24
backend/imap/build.gradle.kts
Normal file
24
backend/imap/build.gradle.kts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.backend.api)
|
||||
implementation(projects.core.common)
|
||||
api(projects.core.outcome)
|
||||
|
||||
api(projects.feature.mail.account.api)
|
||||
|
||||
api(projects.mail.protocols.imap)
|
||||
api(projects.mail.protocols.smtp)
|
||||
|
||||
implementation(projects.feature.mail.folder.api)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(projects.core.logging.testing)
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(projects.backend.testing)
|
||||
testImplementation(libs.mime4j.dom)
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshManager
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshTimer
|
||||
|
||||
private typealias Callback = () -> Unit
|
||||
|
||||
private const val MIN_TIMER_DELTA = 1 * 60 * 1000L
|
||||
private const val NO_TRIGGER_TIME = 0L
|
||||
|
||||
/**
|
||||
* Timer mechanism to refresh IMAP IDLE connections.
|
||||
*
|
||||
* Triggers timers early if necessary to reduce the number of times the device has to be woken up.
|
||||
*/
|
||||
class BackendIdleRefreshManager(private val alarmManager: SystemAlarmManager) : IdleRefreshManager {
|
||||
private var timers = mutableSetOf<BackendIdleRefreshTimer>()
|
||||
private var currentTriggerTime = NO_TRIGGER_TIME
|
||||
private var minTimeout = Long.MAX_VALUE
|
||||
private var minTimeoutTimestamp = 0L
|
||||
|
||||
@Synchronized
|
||||
override fun startTimer(timeout: Long, callback: Callback): IdleRefreshTimer {
|
||||
require(timeout > MIN_TIMER_DELTA) { "Timeout needs to be greater than $MIN_TIMER_DELTA ms" }
|
||||
|
||||
val now = alarmManager.now()
|
||||
val triggerTime = now + timeout
|
||||
|
||||
updateMinTimeout(timeout, now)
|
||||
setOrUpdateAlarm(triggerTime)
|
||||
|
||||
return BackendIdleRefreshTimer(triggerTime, callback).also { timer ->
|
||||
timers.add(timer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun resetTimers() {
|
||||
synchronized(this) {
|
||||
cancelAlarm()
|
||||
}
|
||||
|
||||
onTimeout()
|
||||
}
|
||||
|
||||
private fun updateMinTimeout(timeout: Long, now: Long) {
|
||||
if (minTimeoutTimestamp + minTimeout * 2 < now) {
|
||||
minTimeout = Long.MAX_VALUE
|
||||
}
|
||||
|
||||
if (timeout <= minTimeout) {
|
||||
minTimeout = timeout
|
||||
minTimeoutTimestamp = now
|
||||
}
|
||||
}
|
||||
|
||||
private fun setOrUpdateAlarm(triggerTime: Long) {
|
||||
if (currentTriggerTime == NO_TRIGGER_TIME) {
|
||||
setAlarm(triggerTime)
|
||||
} else if (currentTriggerTime - triggerTime > MIN_TIMER_DELTA) {
|
||||
adjustAlarm(triggerTime)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAlarm(triggerTime: Long) {
|
||||
currentTriggerTime = triggerTime
|
||||
alarmManager.setAlarm(triggerTime, ::onTimeout)
|
||||
}
|
||||
|
||||
private fun adjustAlarm(triggerTime: Long) {
|
||||
currentTriggerTime = triggerTime
|
||||
alarmManager.cancelAlarm()
|
||||
alarmManager.setAlarm(triggerTime, ::onTimeout)
|
||||
}
|
||||
|
||||
private fun cancelAlarm() {
|
||||
currentTriggerTime = NO_TRIGGER_TIME
|
||||
alarmManager.cancelAlarm()
|
||||
}
|
||||
|
||||
private fun onTimeout() {
|
||||
val triggerTimers = synchronized(this) {
|
||||
currentTriggerTime = NO_TRIGGER_TIME
|
||||
|
||||
if (timers.isEmpty()) return
|
||||
|
||||
val now = alarmManager.now()
|
||||
val minNextTriggerTime = now + minTimeout
|
||||
|
||||
val triggerTimers = timers.filter { it.triggerTime < minNextTriggerTime - MIN_TIMER_DELTA }
|
||||
timers.removeAll(triggerTimers)
|
||||
|
||||
timers.minOfOrNull { it.triggerTime }?.let { nextTriggerTime ->
|
||||
setAlarm(nextTriggerTime)
|
||||
}
|
||||
|
||||
triggerTimers
|
||||
}
|
||||
|
||||
for (timer in triggerTimers) {
|
||||
timer.onTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun removeTimer(timer: BackendIdleRefreshTimer) {
|
||||
timers.remove(timer)
|
||||
|
||||
if (timers.isEmpty()) {
|
||||
cancelAlarm()
|
||||
}
|
||||
}
|
||||
|
||||
internal inner class BackendIdleRefreshTimer(
|
||||
val triggerTime: Long,
|
||||
val callback: Callback,
|
||||
) : IdleRefreshTimer {
|
||||
override var isWaiting: Boolean = true
|
||||
private set
|
||||
|
||||
@Synchronized
|
||||
override fun cancel() {
|
||||
if (isWaiting) {
|
||||
isWaiting = false
|
||||
removeTimer(this)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onTimeout() {
|
||||
synchronized(this) {
|
||||
isWaiting = false
|
||||
}
|
||||
|
||||
callback.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
internal class CommandDelete(private val imapStore: ImapStore) {
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
|
||||
val messages = messageServerIds.map { uid -> remoteFolder.getMessage(uid) }
|
||||
|
||||
remoteFolder.deleteMessages(messages)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
internal class CommandDeleteAll(private val imapStore: ImapStore) {
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun deleteAll(folderServerId: String) {
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
|
||||
remoteFolder.deleteAllMessages()
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.mail.FetchProfile
|
||||
import com.fsck.k9.mail.FetchProfile.Item.BODY
|
||||
import com.fsck.k9.mail.FetchProfile.Item.ENVELOPE
|
||||
import com.fsck.k9.mail.FetchProfile.Item.FLAGS
|
||||
import com.fsck.k9.mail.FetchProfile.Item.STRUCTURE
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import com.fsck.k9.mail.helper.fetchProfileOf
|
||||
import com.fsck.k9.mail.store.imap.ImapFolder
|
||||
import com.fsck.k9.mail.store.imap.ImapMessage
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandDownloadMessage(private val backendStorage: BackendStorage, private val imapStore: ImapStore) {
|
||||
|
||||
fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
|
||||
val folder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
folder.open(OpenMode.READ_ONLY)
|
||||
|
||||
val message = folder.getMessage(messageServerId)
|
||||
|
||||
// fun fact: ImapFolder.fetch can't handle getting STRUCTURE at same time as headers
|
||||
fetchMessage(folder, message, fetchProfileOf(FLAGS, ENVELOPE))
|
||||
fetchMessage(folder, message, fetchProfileOf(STRUCTURE))
|
||||
|
||||
val backendFolder = backendStorage.getFolder(folderServerId)
|
||||
backendFolder.saveMessage(message, MessageDownloadState.ENVELOPE)
|
||||
} finally {
|
||||
folder.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
|
||||
val folder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
folder.open(OpenMode.READ_ONLY)
|
||||
|
||||
val message = folder.getMessage(messageServerId)
|
||||
fetchMessage(folder, message, fetchProfileOf(FLAGS, BODY))
|
||||
|
||||
val backendFolder = backendStorage.getFolder(folderServerId)
|
||||
backendFolder.saveMessage(message, MessageDownloadState.FULL)
|
||||
} finally {
|
||||
folder.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchMessage(remoteFolder: ImapFolder, message: ImapMessage, fetchProfile: FetchProfile) {
|
||||
val maxDownloadSize = 0
|
||||
remoteFolder.fetch(listOf(message), fetchProfile, null, maxDownloadSize)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
internal class CommandExpunge(private val imapStore: ImapStore) {
|
||||
|
||||
fun expunge(folderServerId: String) {
|
||||
Log.d("processPendingExpunge: folder = %s", folderServerId)
|
||||
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
|
||||
remoteFolder.expunge()
|
||||
|
||||
Log.d("processPendingExpunge: complete for folder = %s", folderServerId)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun expungeMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
|
||||
remoteFolder.expungeUids(messageServerIds)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
import com.fsck.k9.mail.Part
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandFetchMessage(private val imapStore: ImapStore) {
|
||||
|
||||
fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
|
||||
val folder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
folder.open(OpenMode.READ_WRITE)
|
||||
|
||||
val message = folder.getMessage(messageServerId)
|
||||
folder.fetchPart(message, part, bodyFactory, -1)
|
||||
} finally {
|
||||
folder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandFindByMessageId(private val imapStore: ImapStore) {
|
||||
|
||||
fun findByMessageId(folderServerId: String, messageId: String): String? {
|
||||
val folder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
folder.open(OpenMode.READ_WRITE)
|
||||
return folder.getUidFromMessageId(messageId)
|
||||
} finally {
|
||||
folder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandMarkAllAsRead(private val imapStore: ImapStore) {
|
||||
|
||||
fun markAllAsRead(folderServerId: String) {
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
|
||||
remoteFolder.setFlagsForAllMessages(setOf(Flag.SEEN), true)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.store.imap.ImapFolder
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
internal class CommandMoveOrCopyMessages(private val imapStore: ImapStore) {
|
||||
|
||||
fun moveMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>? {
|
||||
return moveOrCopyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds, false)
|
||||
}
|
||||
|
||||
fun copyMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>? {
|
||||
return moveOrCopyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds, true)
|
||||
}
|
||||
|
||||
private fun moveOrCopyMessages(
|
||||
srcFolder: String,
|
||||
destFolder: String,
|
||||
uids: Collection<String>,
|
||||
isCopy: Boolean,
|
||||
): Map<String, String>? {
|
||||
var remoteSrcFolder: ImapFolder? = null
|
||||
var remoteDestFolder: ImapFolder? = null
|
||||
|
||||
return try {
|
||||
remoteSrcFolder = imapStore.getFolder(srcFolder)
|
||||
|
||||
if (uids.isEmpty()) {
|
||||
Log.i("moveOrCopyMessages: no remote messages to move, skipping")
|
||||
return null
|
||||
}
|
||||
|
||||
remoteSrcFolder.open(OpenMode.READ_WRITE)
|
||||
|
||||
val messages = uids.map { uid -> remoteSrcFolder.getMessage(uid) }
|
||||
|
||||
Log.d(
|
||||
"moveOrCopyMessages: source folder = %s, %d messages, destination folder = %s, isCopy = %s",
|
||||
srcFolder,
|
||||
messages.size,
|
||||
destFolder,
|
||||
isCopy,
|
||||
)
|
||||
|
||||
remoteDestFolder = imapStore.getFolder(destFolder)
|
||||
if (isCopy) {
|
||||
remoteSrcFolder.copyMessages(messages, remoteDestFolder)
|
||||
} else {
|
||||
remoteSrcFolder.moveMessages(messages, remoteDestFolder)
|
||||
}
|
||||
} finally {
|
||||
remoteSrcFolder?.close()
|
||||
remoteDestFolder?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.backend.api.updateFolders
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.store.imap.FolderListItem
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
|
||||
private const val TAG = "CommandRefreshFolderList"
|
||||
|
||||
internal class CommandRefreshFolderList(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val imapStore: ImapStore,
|
||||
private val logger: Logger = Log,
|
||||
) {
|
||||
|
||||
private val LegacyFolderListItem.normalizedServerId: String
|
||||
get() = imapStore.combinedPrefix?.let {
|
||||
serverId.removePrefix(prefix = it)
|
||||
} ?: serverId
|
||||
|
||||
fun refreshFolderList(): FolderPathDelimiter? {
|
||||
logger.verbose(TAG) { "refreshFolderList() called" }
|
||||
val folders = imapStore.getFolders()
|
||||
val folderPathDelimiter = folders.firstOrNull { it.folderPathDelimiter != null }?.folderPathDelimiter
|
||||
val foldersOnServer = folders.toLegacyFolderList()
|
||||
val oldFolderServerIds = backendStorage.getFolderServerIds()
|
||||
|
||||
backendStorage.updateFolders {
|
||||
val foldersToCreate = mutableListOf<FolderInfo>()
|
||||
for (folder in foldersOnServer) {
|
||||
if (folder.normalizedServerId !in oldFolderServerIds) {
|
||||
foldersToCreate.add(FolderInfo(folder.normalizedServerId, folder.name, folder.type))
|
||||
} else {
|
||||
changeFolder(folder.normalizedServerId, folder.name, folder.type)
|
||||
}
|
||||
}
|
||||
|
||||
logger.verbose(TAG) { "refreshFolderList: foldersToCreate = $foldersToCreate" }
|
||||
createFolders(foldersToCreate)
|
||||
|
||||
val newFolderServerIds = foldersOnServer.map { it.normalizedServerId }
|
||||
val removedFolderServerIds = oldFolderServerIds - newFolderServerIds
|
||||
logger.verbose(TAG) { "refreshFolderList: folders to remove = $removedFolderServerIds" }
|
||||
deleteFolders(removedFolderServerIds)
|
||||
}
|
||||
return folderPathDelimiter
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<FolderListItem>.toLegacyFolderList(): List<LegacyFolderListItem> {
|
||||
return this
|
||||
.map { LegacyFolderListItem(it.serverId, it.name, it.type) }
|
||||
}
|
||||
|
||||
private data class LegacyFolderListItem(
|
||||
val serverId: String,
|
||||
val name: String,
|
||||
val type: FolderType,
|
||||
)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandSearch(private val imapStore: ImapStore) {
|
||||
|
||||
fun search(
|
||||
folderServerId: String,
|
||||
query: String?,
|
||||
requiredFlags: Set<Flag>?,
|
||||
forbiddenFlags: Set<Flag>?,
|
||||
performFullTextSearch: Boolean,
|
||||
): List<String> {
|
||||
val folder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
folder.open(OpenMode.READ_ONLY)
|
||||
|
||||
return folder.search(query, requiredFlags, forbiddenFlags, performFullTextSearch)
|
||||
.sortedWith(UidReverseComparator())
|
||||
.map { it.uid }
|
||||
} finally {
|
||||
folder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandSetFlag(private val imapStore: ImapStore) {
|
||||
|
||||
fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) {
|
||||
if (messageServerIds.isEmpty()) return
|
||||
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
|
||||
val messages = messageServerIds.map { uid -> remoteFolder.getMessage(uid) }
|
||||
|
||||
remoteFolder.setFlags(messages, setOf(flag), newState)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandUploadMessage(private val imapStore: ImapStore) {
|
||||
|
||||
fun uploadMessage(folderServerId: String, message: Message): String? {
|
||||
val folder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
folder.open(OpenMode.READ_WRITE)
|
||||
|
||||
val localUid = message.uid
|
||||
val uidMap = folder.appendMessages(listOf(message))
|
||||
|
||||
return uidMap?.get(localUid)
|
||||
} finally {
|
||||
folder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.backend.api.BackendPusher
|
||||
import com.fsck.k9.backend.api.BackendPusherCallback
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Part
|
||||
import com.fsck.k9.mail.power.PowerManager
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshManager
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.transport.smtp.SmtpTransport
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
|
||||
class ImapBackend(
|
||||
private val accountName: String,
|
||||
backendStorage: BackendStorage,
|
||||
internal val imapStore: ImapStore,
|
||||
private val powerManager: PowerManager,
|
||||
private val idleRefreshManager: IdleRefreshManager,
|
||||
private val pushConfigProvider: ImapPushConfigProvider,
|
||||
private val smtpTransport: SmtpTransport,
|
||||
) : Backend {
|
||||
private val imapSync = ImapSync(accountName, backendStorage, imapStore)
|
||||
private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage, imapStore)
|
||||
private val commandSetFlag = CommandSetFlag(imapStore)
|
||||
private val commandMarkAllAsRead = CommandMarkAllAsRead(imapStore)
|
||||
private val commandExpunge = CommandExpunge(imapStore)
|
||||
private val commandMoveOrCopyMessages = CommandMoveOrCopyMessages(imapStore)
|
||||
private val commandDelete = CommandDelete(imapStore)
|
||||
private val commandDeleteAll = CommandDeleteAll(imapStore)
|
||||
private val commandSearch = CommandSearch(imapStore)
|
||||
private val commandDownloadMessage = CommandDownloadMessage(backendStorage, imapStore)
|
||||
private val commandFetchMessage = CommandFetchMessage(imapStore)
|
||||
private val commandFindByMessageId = CommandFindByMessageId(imapStore)
|
||||
private val commandUploadMessage = CommandUploadMessage(imapStore)
|
||||
|
||||
override val supportsFlags = true
|
||||
override val supportsExpunge = true
|
||||
override val supportsMove = true
|
||||
override val supportsCopy = true
|
||||
override val supportsUpload = true
|
||||
override val supportsTrashFolder = true
|
||||
override val supportsSearchByDate = true
|
||||
override val supportsFolderSubscriptions = true
|
||||
override val isPushCapable = true
|
||||
|
||||
override fun refreshFolderList(): FolderPathDelimiter? {
|
||||
return commandRefreshFolderList.refreshFolderList()
|
||||
}
|
||||
|
||||
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
imapSync.sync(folderServerId, syncConfig, listener)
|
||||
}
|
||||
|
||||
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
|
||||
imapSync.downloadMessage(syncConfig, folderServerId, messageServerId)
|
||||
}
|
||||
|
||||
override fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
|
||||
commandDownloadMessage.downloadMessageStructure(folderServerId, messageServerId)
|
||||
}
|
||||
|
||||
override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
|
||||
commandDownloadMessage.downloadCompleteMessage(folderServerId, messageServerId)
|
||||
}
|
||||
|
||||
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) {
|
||||
commandSetFlag.setFlag(folderServerId, messageServerIds, flag, newState)
|
||||
}
|
||||
|
||||
override fun markAllAsRead(folderServerId: String) {
|
||||
commandMarkAllAsRead.markAllAsRead(folderServerId)
|
||||
}
|
||||
|
||||
override fun expunge(folderServerId: String) {
|
||||
commandExpunge.expunge(folderServerId)
|
||||
}
|
||||
|
||||
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
commandDelete.deleteMessages(folderServerId, messageServerIds)
|
||||
}
|
||||
|
||||
override fun deleteAllMessages(folderServerId: String) {
|
||||
commandDeleteAll.deleteAll(folderServerId)
|
||||
}
|
||||
|
||||
override fun moveMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>? {
|
||||
return commandMoveOrCopyMessages.moveMessages(sourceFolderServerId, targetFolderServerId, messageServerIds)
|
||||
}
|
||||
|
||||
override fun moveMessagesAndMarkAsRead(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>? {
|
||||
val uidMapping = commandMoveOrCopyMessages.moveMessages(
|
||||
sourceFolderServerId,
|
||||
targetFolderServerId,
|
||||
messageServerIds,
|
||||
)
|
||||
if (uidMapping != null) {
|
||||
setFlag(targetFolderServerId, uidMapping.values.toList(), Flag.SEEN, true)
|
||||
}
|
||||
return uidMapping
|
||||
}
|
||||
|
||||
override fun copyMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>? {
|
||||
return commandMoveOrCopyMessages.copyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds)
|
||||
}
|
||||
|
||||
override fun search(
|
||||
folderServerId: String,
|
||||
query: String?,
|
||||
requiredFlags: Set<Flag>?,
|
||||
forbiddenFlags: Set<Flag>?,
|
||||
performFullTextSearch: Boolean,
|
||||
): List<String> {
|
||||
return commandSearch.search(folderServerId, query, requiredFlags, forbiddenFlags, performFullTextSearch)
|
||||
}
|
||||
|
||||
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
|
||||
commandFetchMessage.fetchPart(folderServerId, messageServerId, part, bodyFactory)
|
||||
}
|
||||
|
||||
override fun findByMessageId(folderServerId: String, messageId: String): String? {
|
||||
return commandFindByMessageId.findByMessageId(folderServerId, messageId)
|
||||
}
|
||||
|
||||
override fun uploadMessage(folderServerId: String, message: Message): String? {
|
||||
return commandUploadMessage.uploadMessage(folderServerId, message)
|
||||
}
|
||||
|
||||
override fun sendMessage(message: Message) {
|
||||
smtpTransport.sendMessage(message)
|
||||
}
|
||||
|
||||
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
|
||||
return ImapBackendPusher(imapStore, powerManager, idleRefreshManager, pushConfigProvider, callback, accountName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.backend.api.BackendPusher
|
||||
import com.fsck.k9.backend.api.BackendPusherCallback
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.power.PowerManager
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshManager
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshTimeoutProvider
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshTimer
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
private const val IO_ERROR_TIMEOUT = 5 * 60 * 1000L
|
||||
private const val UNEXPECTED_ERROR_TIMEOUT = 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Manages [ImapFolderPusher] instances that listen for changes to individual folders.
|
||||
*/
|
||||
internal class ImapBackendPusher(
|
||||
private val imapStore: ImapStore,
|
||||
private val powerManager: PowerManager,
|
||||
private val idleRefreshManager: IdleRefreshManager,
|
||||
private val pushConfigProvider: ImapPushConfigProvider,
|
||||
private val callback: BackendPusherCallback,
|
||||
private val accountName: String,
|
||||
backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : BackendPusher, ImapPusherCallback {
|
||||
private val coroutineScope = CoroutineScope(backgroundDispatcher)
|
||||
private val lock = Any()
|
||||
private val pushFolders = mutableMapOf<String, ImapFolderPusher>()
|
||||
private var currentFolderServerIds: Collection<String> = emptySet()
|
||||
private val pushFolderSleeping = mutableMapOf<String, IdleRefreshTimer>()
|
||||
|
||||
private val idleRefreshTimeoutProvider = object : IdleRefreshTimeoutProvider {
|
||||
override val idleRefreshTimeoutMs
|
||||
get() = currentIdleRefreshMs
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var currentMaxPushFolders = 0
|
||||
|
||||
@Volatile
|
||||
private var currentIdleRefreshMs = 15 * 60 * 1000L
|
||||
|
||||
override fun start() {
|
||||
coroutineScope.launch {
|
||||
pushConfigProvider.maxPushFoldersFlow.collect { maxPushFolders ->
|
||||
currentMaxPushFolders = maxPushFolders
|
||||
updateFolders()
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
pushConfigProvider.idleRefreshMinutesFlow.collect { idleRefreshMinutes ->
|
||||
currentIdleRefreshMs = idleRefreshMinutes * 60 * 1000L
|
||||
refreshFolderTimers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshFolderTimers() {
|
||||
synchronized(lock) {
|
||||
for (pushFolder in pushFolders.values) {
|
||||
pushFolder.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateFolders(folderServerIds: Collection<String>) {
|
||||
updateFolders(folderServerIds, currentMaxPushFolders)
|
||||
}
|
||||
|
||||
private fun updateFolders() {
|
||||
val currentFolderServerIds = synchronized(lock) { currentFolderServerIds }
|
||||
updateFolders(currentFolderServerIds, currentMaxPushFolders)
|
||||
}
|
||||
|
||||
private fun updateFolders(folderServerIds: Collection<String>, maxPushFolders: Int) {
|
||||
Log.v("ImapBackendPusher.updateFolders(): %s", folderServerIds)
|
||||
|
||||
val pushFolderServerIds = if (folderServerIds.size > maxPushFolders) {
|
||||
folderServerIds.take(maxPushFolders).also { pushFolderServerIds ->
|
||||
Log.v("..limiting Push to %d folders: %s", maxPushFolders, pushFolderServerIds)
|
||||
}
|
||||
} else {
|
||||
folderServerIds
|
||||
}
|
||||
|
||||
val stopFolderPushers: List<ImapFolderPusher>
|
||||
val startFolderPushers: List<ImapFolderPusher>
|
||||
synchronized(lock) {
|
||||
currentFolderServerIds = folderServerIds
|
||||
|
||||
val oldRunningFolderServerIds = pushFolders.keys
|
||||
val oldFolderServerIds = oldRunningFolderServerIds + pushFolderSleeping.keys
|
||||
val removeFolderServerIds = oldFolderServerIds - pushFolderServerIds
|
||||
stopFolderPushers = removeFolderServerIds
|
||||
.asSequence()
|
||||
.onEach { folderServerId -> cancelRetryTimer(folderServerId) }
|
||||
.map { folderServerId -> pushFolders.remove(folderServerId) }
|
||||
.filterNotNull()
|
||||
.toList()
|
||||
|
||||
val startFolderServerIds = pushFolderServerIds - oldRunningFolderServerIds
|
||||
startFolderPushers = startFolderServerIds
|
||||
.asSequence()
|
||||
.filterNot { folderServerId -> isWaitingForRetry(folderServerId) }
|
||||
.onEach { folderServerId -> pushFolderSleeping.remove(folderServerId) }
|
||||
.map { folderServerId ->
|
||||
createImapFolderPusher(folderServerId).also { folderPusher ->
|
||||
pushFolders[folderServerId] = folderPusher
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
for (folderPusher in stopFolderPushers) {
|
||||
folderPusher.stop()
|
||||
}
|
||||
|
||||
for (folderPusher in startFolderPushers) {
|
||||
folderPusher.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Log.v("ImapBackendPusher.stop()")
|
||||
|
||||
coroutineScope.cancel()
|
||||
|
||||
synchronized(lock) {
|
||||
for (pushFolder in pushFolders.values) {
|
||||
pushFolder.stop()
|
||||
}
|
||||
pushFolders.clear()
|
||||
|
||||
for (retryTimer in pushFolderSleeping.values) {
|
||||
retryTimer.cancel()
|
||||
}
|
||||
pushFolderSleeping.clear()
|
||||
|
||||
currentFolderServerIds = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
override fun reconnect() {
|
||||
Log.v("ImapBackendPusher.reconnect()")
|
||||
|
||||
synchronized(lock) {
|
||||
for (pushFolder in pushFolders.values) {
|
||||
pushFolder.stop()
|
||||
}
|
||||
pushFolders.clear()
|
||||
|
||||
for (retryTimer in pushFolderSleeping.values) {
|
||||
retryTimer.cancel()
|
||||
}
|
||||
pushFolderSleeping.clear()
|
||||
}
|
||||
|
||||
imapStore.closeAllConnections()
|
||||
|
||||
updateFolders()
|
||||
}
|
||||
|
||||
private fun createImapFolderPusher(folderServerId: String): ImapFolderPusher {
|
||||
return ImapFolderPusher(
|
||||
imapStore,
|
||||
powerManager,
|
||||
idleRefreshManager,
|
||||
this,
|
||||
accountName,
|
||||
folderServerId,
|
||||
idleRefreshTimeoutProvider,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPushEvent(folderServerId: String) {
|
||||
callback.onPushEvent(folderServerId)
|
||||
idleRefreshManager.resetTimers()
|
||||
}
|
||||
|
||||
override fun onPushError(folderServerId: String, exception: Exception) {
|
||||
synchronized(lock) {
|
||||
pushFolders.remove(folderServerId)
|
||||
|
||||
when (exception) {
|
||||
is AuthenticationFailedException -> {
|
||||
Log.v(exception, "Authentication failure when attempting to use IDLE")
|
||||
// TODO: This could be happening because of too many connections to the host. Ideally we'd want to
|
||||
// detect this case and use a lower timeout.
|
||||
|
||||
startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT)
|
||||
}
|
||||
is IOException -> {
|
||||
Log.v(exception, "I/O error while trying to use IDLE")
|
||||
|
||||
startRetryTimer(folderServerId, IO_ERROR_TIMEOUT)
|
||||
}
|
||||
is MessagingException -> {
|
||||
Log.v(exception, "MessagingException")
|
||||
|
||||
if (exception.isPermanentFailure) {
|
||||
startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT)
|
||||
} else {
|
||||
startRetryTimer(folderServerId, IO_ERROR_TIMEOUT)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.v(exception, "Unexpected error")
|
||||
startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
if (pushFolders.isEmpty()) {
|
||||
callback.onPushError(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPushNotSupported() {
|
||||
callback.onPushNotSupported()
|
||||
}
|
||||
|
||||
private fun startRetryTimer(folderServerId: String, timeout: Long) {
|
||||
Log.v("ImapBackendPusher for folder %s sleeping for %d ms", folderServerId, timeout)
|
||||
pushFolderSleeping[folderServerId] = idleRefreshManager.startTimer(timeout, ::restartFolderPushers)
|
||||
}
|
||||
|
||||
private fun cancelRetryTimer(folderServerId: String) {
|
||||
Log.v("Canceling ImapBackendPusher retry timer for folder %s", folderServerId)
|
||||
pushFolderSleeping.remove(folderServerId)?.cancel()
|
||||
}
|
||||
|
||||
private fun isWaitingForRetry(folderServerId: String): Boolean {
|
||||
return pushFolderSleeping[folderServerId]?.isWaiting == true
|
||||
}
|
||||
|
||||
private fun restartFolderPushers() {
|
||||
Log.v("Refreshing ImapBackendPusher (at least one retry timer has expired)")
|
||||
|
||||
updateFolders()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.power.PowerManager
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshManager
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshTimeoutProvider
|
||||
import com.fsck.k9.mail.store.imap.IdleResult
|
||||
import com.fsck.k9.mail.store.imap.ImapFolderIdler
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import kotlin.concurrent.thread
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
/**
|
||||
* Listens for changes to an IMAP folder in a dedicated thread.
|
||||
*/
|
||||
class ImapFolderPusher(
|
||||
private val imapStore: ImapStore,
|
||||
private val powerManager: PowerManager,
|
||||
private val idleRefreshManager: IdleRefreshManager,
|
||||
private val callback: ImapPusherCallback,
|
||||
private val accountName: String,
|
||||
private val folderServerId: String,
|
||||
private val idleRefreshTimeoutProvider: IdleRefreshTimeoutProvider,
|
||||
) {
|
||||
@Volatile
|
||||
private var folderIdler: ImapFolderIdler? = null
|
||||
|
||||
@Volatile
|
||||
private var stopPushing = false
|
||||
|
||||
fun start() {
|
||||
Log.v("Starting ImapFolderPusher for %s / %s", accountName, folderServerId)
|
||||
|
||||
thread(name = "ImapFolderPusher-$accountName-$folderServerId") {
|
||||
Log.v("Starting ImapFolderPusher thread for %s / %s", accountName, folderServerId)
|
||||
|
||||
runPushLoop()
|
||||
|
||||
Log.v("Exiting ImapFolderPusher thread for %s / %s", accountName, folderServerId)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
Log.v("Refreshing ImapFolderPusher for %s / %s", accountName, folderServerId)
|
||||
|
||||
folderIdler?.refresh()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Log.v("Stopping ImapFolderPusher for %s / %s", accountName, folderServerId)
|
||||
|
||||
stopPushing = true
|
||||
folderIdler?.stop()
|
||||
}
|
||||
|
||||
private fun runPushLoop() {
|
||||
val wakeLock = powerManager.newWakeLock("ImapFolderPusher-$accountName-$folderServerId")
|
||||
wakeLock.acquire()
|
||||
|
||||
performInitialSync()
|
||||
|
||||
val folderIdler = ImapFolderIdler.create(
|
||||
idleRefreshManager,
|
||||
wakeLock,
|
||||
imapStore,
|
||||
folderServerId,
|
||||
idleRefreshTimeoutProvider,
|
||||
).also {
|
||||
folderIdler = it
|
||||
}
|
||||
|
||||
try {
|
||||
while (!stopPushing) {
|
||||
when (folderIdler.idle()) {
|
||||
IdleResult.SYNC -> {
|
||||
callback.onPushEvent(folderServerId)
|
||||
}
|
||||
IdleResult.STOPPED -> {
|
||||
// ImapFolderIdler only stops when we ask it to.
|
||||
// But it can't hurt to make extra sure we exit the loop.
|
||||
stopPushing = true
|
||||
}
|
||||
IdleResult.NOT_SUPPORTED -> {
|
||||
stopPushing = true
|
||||
callback.onPushNotSupported()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.v(e, "Exception in ImapFolderPusher")
|
||||
|
||||
this.folderIdler = null
|
||||
callback.onPushError(folderServerId, e)
|
||||
}
|
||||
|
||||
wakeLock.release()
|
||||
}
|
||||
|
||||
private fun performInitialSync() {
|
||||
callback.onPushEvent(folderServerId)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ImapPushConfigProvider {
|
||||
val maxPushFoldersFlow: Flow<Int>
|
||||
val idleRefreshMinutesFlow: Flow<Int>
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
interface ImapPusherCallback {
|
||||
fun onPushEvent(folderServerId: String)
|
||||
fun onPushError(folderServerId: String, exception: Exception)
|
||||
fun onPushNotSupported()
|
||||
}
|
||||
732
backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapSync.kt
Normal file
732
backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapSync.kt
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.backend.api.BackendFolder
|
||||
import com.fsck.k9.backend.api.BackendFolder.MoreMessages
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.helper.ExceptionHelper
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
import com.fsck.k9.mail.DefaultBodyFactory
|
||||
import com.fsck.k9.mail.FetchProfile
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import com.fsck.k9.mail.internet.MessageExtractor
|
||||
import com.fsck.k9.mail.store.imap.FetchListener
|
||||
import com.fsck.k9.mail.store.imap.ImapFolder
|
||||
import com.fsck.k9.mail.store.imap.ImapMessage
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
import java.util.Collections
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.max
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
internal class ImapSync(
|
||||
private val accountName: String,
|
||||
private val backendStorage: BackendStorage,
|
||||
private val imapStore: ImapStore,
|
||||
) {
|
||||
fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
synchronizeMailboxSynchronous(folder, syncConfig, listener)
|
||||
}
|
||||
|
||||
private fun synchronizeMailboxSynchronous(folder: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
Log.i("Synchronizing folder %s:%s", accountName, folder)
|
||||
|
||||
var remoteFolder: ImapFolder? = null
|
||||
var backendFolder: BackendFolder? = null
|
||||
var newHighestKnownUid: Long = 0
|
||||
try {
|
||||
Log.v("SYNC: About to get local folder %s", folder)
|
||||
|
||||
backendFolder = backendStorage.getFolder(folder)
|
||||
|
||||
listener.syncStarted(folder)
|
||||
|
||||
Log.v("SYNC: About to get remote folder %s", folder)
|
||||
remoteFolder = imapStore.getFolder(folder)
|
||||
|
||||
/*
|
||||
* Synchronization process:
|
||||
*
|
||||
Open the folder
|
||||
Upload any local messages that are marked as PENDING_UPLOAD (Drafts, Sent, Trash)
|
||||
Get the message count
|
||||
Get the list of the newest K9.DEFAULT_VISIBLE_LIMIT messages
|
||||
getMessages(messageCount - K9.DEFAULT_VISIBLE_LIMIT, messageCount)
|
||||
See if we have each message locally, if not fetch it's flags and envelope
|
||||
Get and update the unread count for the folder
|
||||
Update the remote flags of any messages we have locally with an internal date newer than the remote message.
|
||||
Get the current flags for any messages we have locally but did not just download
|
||||
Update local flags
|
||||
For any message we have locally but not remotely, delete the local message to keep cache clean.
|
||||
Download larger parts of any new messages.
|
||||
(Optional) Download small attachments in the background.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Open the remote folder. This pre-loads certain metadata like message count.
|
||||
*/
|
||||
Log.v("SYNC: About to open remote folder %s", folder)
|
||||
|
||||
if (syncConfig.expungePolicy === ExpungePolicy.ON_POLL) {
|
||||
Log.d("SYNC: Expunging folder %s:%s", accountName, folder)
|
||||
if (!remoteFolder.isOpen || remoteFolder.mode != OpenMode.READ_WRITE) {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
}
|
||||
remoteFolder.expunge()
|
||||
}
|
||||
|
||||
remoteFolder.open(OpenMode.READ_ONLY)
|
||||
|
||||
listener.syncAuthenticationSuccess()
|
||||
|
||||
val uidValidity = remoteFolder.getUidValidity()
|
||||
val oldUidValidity = backendFolder.getFolderExtraNumber(EXTRA_UID_VALIDITY)
|
||||
if (oldUidValidity == null && uidValidity != null) {
|
||||
Log.d("SYNC: Saving UIDVALIDITY for %s", folder)
|
||||
backendFolder.setFolderExtraNumber(EXTRA_UID_VALIDITY, uidValidity)
|
||||
} else if (oldUidValidity != null && oldUidValidity != uidValidity) {
|
||||
Log.d("SYNC: UIDVALIDITY for %s changed; clearing local message cache", folder)
|
||||
backendFolder.clearAllMessages()
|
||||
backendFolder.setFolderExtraNumber(EXTRA_UID_VALIDITY, uidValidity!!)
|
||||
backendFolder.setFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID, 0)
|
||||
}
|
||||
|
||||
/*
|
||||
* Get the message list from the local store and create an index of
|
||||
* the uids within the list.
|
||||
*/
|
||||
|
||||
val highestKnownUid = backendFolder.getFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID) ?: 0
|
||||
var localUidMap: Map<String, Long?>? = backendFolder.getAllMessagesAndEffectiveDates()
|
||||
|
||||
/*
|
||||
* Get the remote message count.
|
||||
*/
|
||||
val remoteMessageCount = remoteFolder.messageCount
|
||||
|
||||
var visibleLimit = backendFolder.visibleLimit
|
||||
if (visibleLimit < 0) {
|
||||
visibleLimit = syncConfig.defaultVisibleLimit
|
||||
}
|
||||
|
||||
val remoteMessages = mutableListOf<ImapMessage>()
|
||||
val remoteUidMap = mutableMapOf<String, ImapMessage>()
|
||||
|
||||
Log.v("SYNC: Remote message count for folder %s is %d", folder, remoteMessageCount)
|
||||
|
||||
val earliestDate = syncConfig.earliestPollDate
|
||||
val earliestTimestamp = earliestDate?.time ?: 0L
|
||||
|
||||
var remoteStart = 1
|
||||
if (remoteMessageCount > 0) {
|
||||
/* Message numbers start at 1. */
|
||||
remoteStart = if (visibleLimit > 0) {
|
||||
max(0, remoteMessageCount - visibleLimit) + 1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
|
||||
Log.v(
|
||||
"SYNC: About to get messages %d through %d for folder %s",
|
||||
remoteStart,
|
||||
remoteMessageCount,
|
||||
folder,
|
||||
)
|
||||
|
||||
val headerProgress = AtomicInteger(0)
|
||||
listener.syncHeadersStarted(folder)
|
||||
|
||||
val remoteMessageArray = remoteFolder.getMessages(remoteStart, remoteMessageCount, earliestDate, null)
|
||||
|
||||
val messageCount = remoteMessageArray.size
|
||||
|
||||
for (thisMess in remoteMessageArray) {
|
||||
headerProgress.incrementAndGet()
|
||||
listener.syncHeadersProgress(folder, headerProgress.get(), messageCount)
|
||||
|
||||
val uid = thisMess.uid.toLong()
|
||||
if (uid > highestKnownUid && uid > newHighestKnownUid) {
|
||||
newHighestKnownUid = uid
|
||||
}
|
||||
|
||||
val localMessageTimestamp = localUidMap!![thisMess.uid]
|
||||
if (localMessageTimestamp == null || localMessageTimestamp >= earliestTimestamp) {
|
||||
remoteMessages.add(thisMess)
|
||||
remoteUidMap[thisMess.uid] = thisMess
|
||||
}
|
||||
}
|
||||
|
||||
Log.v("SYNC: Got %d messages for folder %s", remoteUidMap.size, folder)
|
||||
|
||||
listener.syncHeadersFinished(folder, headerProgress.get(), remoteUidMap.size)
|
||||
} else if (remoteMessageCount < 0) {
|
||||
throw Exception("Message count $remoteMessageCount for folder $folder")
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove any messages that are in the local store but no longer on the remote store or are too old
|
||||
*/
|
||||
var moreMessages = backendFolder.getMoreMessages()
|
||||
if (syncConfig.syncRemoteDeletions) {
|
||||
val destroyMessageUids = mutableListOf<String>()
|
||||
for (localMessageUid in localUidMap!!.keys) {
|
||||
if (remoteUidMap[localMessageUid] == null) {
|
||||
destroyMessageUids.add(localMessageUid)
|
||||
}
|
||||
}
|
||||
|
||||
if (destroyMessageUids.isNotEmpty()) {
|
||||
moreMessages = MoreMessages.UNKNOWN
|
||||
backendFolder.destroyMessages(destroyMessageUids)
|
||||
for (uid in destroyMessageUids) {
|
||||
listener.syncRemovedMessage(folder, uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_VALUE") // free memory early? (better break up the method!)
|
||||
localUidMap = null
|
||||
|
||||
if (moreMessages === MoreMessages.UNKNOWN) {
|
||||
updateMoreMessages(remoteFolder, backendFolder, earliestDate, remoteStart)
|
||||
}
|
||||
|
||||
/*
|
||||
* Now we download the actual content of messages.
|
||||
*/
|
||||
downloadMessages(
|
||||
syncConfig,
|
||||
remoteFolder,
|
||||
backendFolder,
|
||||
remoteMessages,
|
||||
highestKnownUid,
|
||||
listener,
|
||||
)
|
||||
|
||||
listener.folderStatusChanged(folder)
|
||||
|
||||
/* Notify listeners that we're finally done. */
|
||||
|
||||
backendFolder.setLastChecked(System.currentTimeMillis())
|
||||
backendFolder.setStatus(null)
|
||||
|
||||
Log.d("Done synchronizing folder %s:%s @ %tc", accountName, folder, System.currentTimeMillis())
|
||||
|
||||
listener.syncFinished(folder)
|
||||
|
||||
Log.i("Done synchronizing folder %s:%s", accountName, folder)
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
listener.syncFailed(folder, "Authentication failure", e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "synchronizeMailbox")
|
||||
// If we don't set the last checked, it can try too often during
|
||||
// failure conditions
|
||||
val rootMessage = ExceptionHelper.getRootCauseMessage(e)
|
||||
if (backendFolder != null) {
|
||||
try {
|
||||
backendFolder.setStatus(rootMessage)
|
||||
backendFolder.setLastChecked(System.currentTimeMillis())
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Could not set last checked on folder %s:%s", accountName, folder)
|
||||
}
|
||||
}
|
||||
|
||||
listener.syncFailed(folder, rootMessage, e)
|
||||
|
||||
Log.e(
|
||||
"Failed synchronizing folder %s:%s @ %tc",
|
||||
accountName,
|
||||
folder,
|
||||
System.currentTimeMillis(),
|
||||
)
|
||||
} finally {
|
||||
if (newHighestKnownUid > 0 && backendFolder != null) {
|
||||
Log.v("Saving new highest known UID: %d", newHighestKnownUid)
|
||||
backendFolder.setFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID, newHighestKnownUid)
|
||||
}
|
||||
remoteFolder?.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
|
||||
val backendFolder = backendStorage.getFolder(folderServerId)
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_ONLY)
|
||||
val remoteMessage = remoteFolder.getMessage(messageServerId)
|
||||
|
||||
downloadMessages(
|
||||
syncConfig,
|
||||
remoteFolder,
|
||||
backendFolder,
|
||||
listOf(remoteMessage),
|
||||
null,
|
||||
SimpleSyncListener(),
|
||||
)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the messages described by inputMessages from the remote store and writes them to local storage.
|
||||
*
|
||||
* @param remoteFolder
|
||||
* The remote folder to download messages from.
|
||||
* @param backendFolder
|
||||
* The [BackendFolder] instance corresponding to the remote folder.
|
||||
* @param inputMessages
|
||||
* A list of messages objects that store the UIDs of which messages to download.
|
||||
*/
|
||||
private fun downloadMessages(
|
||||
syncConfig: SyncConfig,
|
||||
remoteFolder: ImapFolder,
|
||||
backendFolder: BackendFolder,
|
||||
inputMessages: List<ImapMessage>,
|
||||
highestKnownUid: Long?,
|
||||
listener: SyncListener,
|
||||
) {
|
||||
val folder = remoteFolder.serverId
|
||||
|
||||
val syncFlagMessages = mutableListOf<ImapMessage>()
|
||||
var unsyncedMessages = mutableListOf<ImapMessage>()
|
||||
val downloadedMessageCount = AtomicInteger(0)
|
||||
|
||||
val messages = inputMessages.toMutableList()
|
||||
for (message in messages) {
|
||||
evaluateMessageForDownload(
|
||||
message,
|
||||
backendFolder,
|
||||
unsyncedMessages,
|
||||
syncFlagMessages,
|
||||
)
|
||||
}
|
||||
|
||||
val progress = AtomicInteger(0)
|
||||
val todo = unsyncedMessages.size + syncFlagMessages.size
|
||||
listener.syncProgress(folder, progress.get(), todo)
|
||||
|
||||
Log.d("SYNC: Have %d unsynced messages", unsyncedMessages.size)
|
||||
|
||||
messages.clear()
|
||||
val largeMessages = mutableListOf<ImapMessage>()
|
||||
val smallMessages = mutableListOf<ImapMessage>()
|
||||
if (unsyncedMessages.isNotEmpty()) {
|
||||
Collections.sort(unsyncedMessages, UidReverseComparator())
|
||||
val visibleLimit = backendFolder.visibleLimit
|
||||
val listSize = unsyncedMessages.size
|
||||
|
||||
if (visibleLimit in 1 until listSize) {
|
||||
unsyncedMessages = unsyncedMessages.subList(0, visibleLimit)
|
||||
}
|
||||
|
||||
Log.d("SYNC: About to fetch %d unsynced messages for folder %s", unsyncedMessages.size, folder)
|
||||
|
||||
fetchUnsyncedMessages(
|
||||
syncConfig,
|
||||
remoteFolder,
|
||||
unsyncedMessages,
|
||||
smallMessages,
|
||||
largeMessages,
|
||||
progress,
|
||||
todo,
|
||||
listener,
|
||||
)
|
||||
|
||||
Log.d("SYNC: Synced unsynced messages for folder %s", folder)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
"SYNC: Have %d large messages and %d small messages out of %d unsynced messages",
|
||||
largeMessages.size,
|
||||
smallMessages.size,
|
||||
unsyncedMessages.size,
|
||||
)
|
||||
|
||||
unsyncedMessages.clear()
|
||||
|
||||
/*
|
||||
* Grab the content of the small messages first. This is going to
|
||||
* be very fast and at very worst will be a single up of a few bytes and a single
|
||||
* download of 625k.
|
||||
*/
|
||||
val maxDownloadSize = syncConfig.maximumAutoDownloadMessageSize
|
||||
// TODO: Only fetch small and large messages if we have some
|
||||
downloadSmallMessages(
|
||||
remoteFolder,
|
||||
backendFolder,
|
||||
smallMessages,
|
||||
progress,
|
||||
downloadedMessageCount,
|
||||
todo,
|
||||
highestKnownUid,
|
||||
listener,
|
||||
)
|
||||
smallMessages.clear()
|
||||
|
||||
/*
|
||||
* Now do the large messages that require more round trips.
|
||||
*/
|
||||
downloadLargeMessages(
|
||||
remoteFolder,
|
||||
backendFolder,
|
||||
largeMessages,
|
||||
progress,
|
||||
downloadedMessageCount,
|
||||
todo,
|
||||
highestKnownUid,
|
||||
listener,
|
||||
maxDownloadSize,
|
||||
)
|
||||
largeMessages.clear()
|
||||
|
||||
/*
|
||||
* Refresh the flags for any messages in the local store that we didn't just
|
||||
* download.
|
||||
*/
|
||||
refreshLocalMessageFlags(syncConfig, remoteFolder, backendFolder, syncFlagMessages, progress, todo, listener)
|
||||
|
||||
Log.d("SYNC: Synced remote messages for folder %s, %d new messages", folder, downloadedMessageCount.get())
|
||||
}
|
||||
|
||||
private fun evaluateMessageForDownload(
|
||||
message: ImapMessage,
|
||||
backendFolder: BackendFolder,
|
||||
unsyncedMessages: MutableList<ImapMessage>,
|
||||
syncFlagMessages: MutableList<ImapMessage>,
|
||||
) {
|
||||
val messageServerId = message.uid
|
||||
if (message.isSet(Flag.DELETED)) {
|
||||
Log.v("Message with uid %s is marked as deleted", messageServerId)
|
||||
syncFlagMessages.add(message)
|
||||
return
|
||||
}
|
||||
|
||||
val messagePresentLocally = backendFolder.isMessagePresent(messageServerId)
|
||||
if (!messagePresentLocally) {
|
||||
Log.v("Message with uid %s has not yet been downloaded", messageServerId)
|
||||
unsyncedMessages.add(message)
|
||||
return
|
||||
}
|
||||
|
||||
val messageFlags = backendFolder.getMessageFlags(messageServerId)
|
||||
if (!messageFlags.contains(Flag.DELETED)) {
|
||||
Log.v("Message with uid %s is present in the local store", messageServerId)
|
||||
if (!messageFlags.contains(Flag.X_DOWNLOADED_FULL) && !messageFlags.contains(Flag.X_DOWNLOADED_PARTIAL)) {
|
||||
Log.v("Message with uid %s is not downloaded, even partially; trying again", messageServerId)
|
||||
unsyncedMessages.add(message)
|
||||
} else {
|
||||
syncFlagMessages.add(message)
|
||||
}
|
||||
} else {
|
||||
Log.v("Local copy of message with uid %s is marked as deleted", messageServerId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isOldMessage(messageServerId: String, highestKnownUid: Long?): Boolean {
|
||||
if (highestKnownUid == null) return false
|
||||
|
||||
try {
|
||||
val messageUid = messageServerId.toLong()
|
||||
return messageUid <= highestKnownUid
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.w(e, "Couldn't parse UID: %s", messageServerId)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun fetchUnsyncedMessages(
|
||||
syncConfig: SyncConfig,
|
||||
remoteFolder: ImapFolder,
|
||||
unsyncedMessages: List<ImapMessage>,
|
||||
smallMessages: MutableList<ImapMessage>,
|
||||
largeMessages: MutableList<ImapMessage>,
|
||||
progress: AtomicInteger,
|
||||
todo: Int,
|
||||
listener: SyncListener,
|
||||
) {
|
||||
val folder = remoteFolder.serverId
|
||||
val fetchProfile = FetchProfile().apply {
|
||||
add(FetchProfile.Item.FLAGS)
|
||||
add(FetchProfile.Item.ENVELOPE)
|
||||
}
|
||||
|
||||
remoteFolder.fetch(
|
||||
unsyncedMessages,
|
||||
fetchProfile,
|
||||
object : FetchListener {
|
||||
override fun onFetchResponse(message: ImapMessage, isFirstResponse: Boolean) {
|
||||
try {
|
||||
if (message.isSet(Flag.DELETED)) {
|
||||
Log.v(
|
||||
"Newly downloaded message %s:%s:%s was marked deleted on server, skipping",
|
||||
accountName,
|
||||
folder,
|
||||
message.uid,
|
||||
)
|
||||
|
||||
if (isFirstResponse) {
|
||||
progress.incrementAndGet()
|
||||
}
|
||||
|
||||
// TODO: This might be the source of poll count errors in the UI. Is todo always the same as ofTotal
|
||||
listener.syncProgress(folder, progress.get(), todo)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (syncConfig.maximumAutoDownloadMessageSize > 0 &&
|
||||
message.size > syncConfig.maximumAutoDownloadMessageSize
|
||||
) {
|
||||
largeMessages.add(message)
|
||||
} else {
|
||||
smallMessages.add(message)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Error while storing downloaded message.")
|
||||
}
|
||||
}
|
||||
},
|
||||
syncConfig.maximumAutoDownloadMessageSize,
|
||||
)
|
||||
}
|
||||
|
||||
private fun downloadSmallMessages(
|
||||
remoteFolder: ImapFolder,
|
||||
backendFolder: BackendFolder,
|
||||
smallMessages: List<ImapMessage>,
|
||||
progress: AtomicInteger,
|
||||
downloadedMessageCount: AtomicInteger,
|
||||
todo: Int,
|
||||
highestKnownUid: Long?,
|
||||
listener: SyncListener,
|
||||
) {
|
||||
val folder = remoteFolder.serverId
|
||||
val fetchProfile = FetchProfile().apply {
|
||||
add(FetchProfile.Item.BODY)
|
||||
}
|
||||
|
||||
Log.d("SYNC: Fetching %d small messages for folder %s", smallMessages.size, folder)
|
||||
|
||||
remoteFolder.fetch(
|
||||
smallMessages,
|
||||
fetchProfile,
|
||||
object : FetchListener {
|
||||
override fun onFetchResponse(message: ImapMessage, isFirstResponse: Boolean) {
|
||||
try {
|
||||
// Store the updated message locally
|
||||
backendFolder.saveMessage(message, MessageDownloadState.FULL)
|
||||
|
||||
if (isFirstResponse) {
|
||||
progress.incrementAndGet()
|
||||
downloadedMessageCount.incrementAndGet()
|
||||
}
|
||||
|
||||
val messageServerId = message.uid
|
||||
Log.v(
|
||||
"About to notify listeners that we got a new small message %s:%s:%s",
|
||||
accountName,
|
||||
folder,
|
||||
messageServerId,
|
||||
)
|
||||
|
||||
// Update the listener with what we've found
|
||||
listener.syncProgress(folder, progress.get(), todo)
|
||||
|
||||
val isOldMessage = isOldMessage(messageServerId, highestKnownUid)
|
||||
listener.syncNewMessage(folder, messageServerId, isOldMessage)
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "SYNC: fetch small messages")
|
||||
}
|
||||
}
|
||||
},
|
||||
-1,
|
||||
)
|
||||
|
||||
Log.d("SYNC: Done fetching small messages for folder %s", folder)
|
||||
}
|
||||
|
||||
private fun downloadLargeMessages(
|
||||
remoteFolder: ImapFolder,
|
||||
backendFolder: BackendFolder,
|
||||
largeMessages: List<ImapMessage>,
|
||||
progress: AtomicInteger,
|
||||
downloadedMessageCount: AtomicInteger,
|
||||
todo: Int,
|
||||
highestKnownUid: Long?,
|
||||
listener: SyncListener,
|
||||
maxDownloadSize: Int,
|
||||
) {
|
||||
val folder = remoteFolder.serverId
|
||||
val fetchProfile = FetchProfile().apply {
|
||||
add(FetchProfile.Item.STRUCTURE)
|
||||
}
|
||||
|
||||
Log.d("SYNC: Fetching large messages for folder %s", folder)
|
||||
|
||||
remoteFolder.fetch(largeMessages, fetchProfile, null, maxDownloadSize)
|
||||
for (message in largeMessages) {
|
||||
if (message.body == null) {
|
||||
downloadSaneBody(remoteFolder, backendFolder, message, maxDownloadSize)
|
||||
} else {
|
||||
downloadPartial(remoteFolder, backendFolder, message, maxDownloadSize)
|
||||
}
|
||||
|
||||
val messageServerId = message.uid
|
||||
Log.v(
|
||||
"About to notify listeners that we got a new large message %s:%s:%s",
|
||||
accountName,
|
||||
folder,
|
||||
messageServerId,
|
||||
)
|
||||
|
||||
// Update the listener with what we've found
|
||||
progress.incrementAndGet()
|
||||
downloadedMessageCount.incrementAndGet()
|
||||
|
||||
listener.syncProgress(folder, progress.get(), todo)
|
||||
|
||||
val isOldMessage = isOldMessage(messageServerId, highestKnownUid)
|
||||
listener.syncNewMessage(folder, messageServerId, isOldMessage)
|
||||
}
|
||||
|
||||
Log.d("SYNC: Done fetching large messages for folder %s", folder)
|
||||
}
|
||||
|
||||
private fun refreshLocalMessageFlags(
|
||||
syncConfig: SyncConfig,
|
||||
remoteFolder: ImapFolder,
|
||||
backendFolder: BackendFolder,
|
||||
syncFlagMessages: List<ImapMessage>,
|
||||
progress: AtomicInteger,
|
||||
todo: Int,
|
||||
listener: SyncListener,
|
||||
) {
|
||||
val folder = remoteFolder.serverId
|
||||
Log.d("SYNC: About to sync flags for %d remote messages for folder %s", syncFlagMessages.size, folder)
|
||||
|
||||
val fetchProfile = FetchProfile()
|
||||
fetchProfile.add(FetchProfile.Item.FLAGS)
|
||||
|
||||
val undeletedMessages = mutableListOf<ImapMessage>()
|
||||
for (message in syncFlagMessages) {
|
||||
if (!message.isSet(Flag.DELETED)) {
|
||||
undeletedMessages.add(message)
|
||||
}
|
||||
}
|
||||
|
||||
val maxDownloadSize = syncConfig.maximumAutoDownloadMessageSize
|
||||
remoteFolder.fetch(undeletedMessages, fetchProfile, null, maxDownloadSize)
|
||||
for (remoteMessage in syncFlagMessages) {
|
||||
val messageChanged = syncFlags(syncConfig, backendFolder, remoteMessage)
|
||||
if (messageChanged) {
|
||||
listener.syncFlagChanged(folder, remoteMessage.uid)
|
||||
}
|
||||
progress.incrementAndGet()
|
||||
listener.syncProgress(folder, progress.get(), todo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSaneBody(
|
||||
remoteFolder: ImapFolder,
|
||||
backendFolder: BackendFolder,
|
||||
message: ImapMessage,
|
||||
maxDownloadSize: Int,
|
||||
) {
|
||||
/*
|
||||
* The provider was unable to get the structure of the message, so
|
||||
* we'll download a reasonable portion of the message and mark it as
|
||||
* incomplete so the entire thing can be downloaded later if the user
|
||||
* wishes to download it.
|
||||
*/
|
||||
val fetchProfile = FetchProfile()
|
||||
fetchProfile.add(FetchProfile.Item.BODY_SANE)
|
||||
/*
|
||||
* TODO a good optimization here would be to make sure that all Stores set
|
||||
* the proper size after this fetch and compare the before and after size. If
|
||||
* they equal we can mark this SYNCHRONIZED instead of PARTIALLY_SYNCHRONIZED
|
||||
*/
|
||||
remoteFolder.fetch(listOf(message), fetchProfile, null, maxDownloadSize)
|
||||
|
||||
// Store the updated message locally
|
||||
backendFolder.saveMessage(message, MessageDownloadState.PARTIAL)
|
||||
}
|
||||
|
||||
private fun downloadPartial(
|
||||
remoteFolder: ImapFolder,
|
||||
backendFolder: BackendFolder,
|
||||
message: ImapMessage,
|
||||
maxDownloadSize: Int,
|
||||
) {
|
||||
/*
|
||||
* We have a structure to deal with, from which
|
||||
* we can pull down the parts we want to actually store.
|
||||
* Build a list of parts we are interested in. Text parts will be downloaded
|
||||
* right now, attachments will be left for later.
|
||||
*/
|
||||
val viewables = MessageExtractor.collectTextParts(message)
|
||||
|
||||
/*
|
||||
* Now download the parts we're interested in storing.
|
||||
*/
|
||||
val bodyFactory: BodyFactory = DefaultBodyFactory()
|
||||
for (part in viewables) {
|
||||
remoteFolder.fetchPart(message, part, bodyFactory, maxDownloadSize)
|
||||
}
|
||||
|
||||
// Store the updated message locally
|
||||
backendFolder.saveMessage(message, MessageDownloadState.PARTIAL)
|
||||
}
|
||||
|
||||
private fun syncFlags(syncConfig: SyncConfig, backendFolder: BackendFolder, remoteMessage: ImapMessage): Boolean {
|
||||
val messageServerId = remoteMessage.uid
|
||||
if (!backendFolder.isMessagePresent(messageServerId)) return false
|
||||
|
||||
val localMessageFlags = backendFolder.getMessageFlags(messageServerId)
|
||||
if (localMessageFlags.contains(Flag.DELETED)) return false
|
||||
|
||||
var messageChanged = false
|
||||
if (remoteMessage.isSet(Flag.DELETED)) {
|
||||
if (syncConfig.syncRemoteDeletions) {
|
||||
backendFolder.setMessageFlag(messageServerId, Flag.DELETED, true)
|
||||
messageChanged = true
|
||||
}
|
||||
} else {
|
||||
for (flag in syncConfig.syncFlags) {
|
||||
if (remoteMessage.isSet(flag) != localMessageFlags.contains(flag)) {
|
||||
backendFolder.setMessageFlag(messageServerId, flag, remoteMessage.isSet(flag))
|
||||
messageChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messageChanged
|
||||
}
|
||||
|
||||
private fun updateMoreMessages(
|
||||
remoteFolder: ImapFolder,
|
||||
backendFolder: BackendFolder,
|
||||
earliestDate: Date?,
|
||||
remoteStart: Int,
|
||||
) {
|
||||
if (remoteStart == 1) {
|
||||
backendFolder.setMoreMessages(MoreMessages.FALSE)
|
||||
} else {
|
||||
val moreMessagesAvailable = remoteFolder.areMoreMessagesAvailable(remoteStart, earliestDate)
|
||||
val newMoreMessages = if (moreMessagesAvailable) MoreMessages.TRUE else MoreMessages.FALSE
|
||||
backendFolder.setMoreMessages(newMoreMessages)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_UID_VALIDITY = "imapUidValidity"
|
||||
private const val EXTRA_HIGHEST_KNOWN_UID = "imapHighestKnownUid"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
|
||||
class SimpleSyncListener : SyncListener {
|
||||
override fun syncStarted(folderServerId: String) = Unit
|
||||
override fun syncAuthenticationSuccess() = Unit
|
||||
override fun syncHeadersStarted(folderServerId: String) = Unit
|
||||
override fun syncHeadersProgress(folderServerId: String, completed: Int, total: Int) = Unit
|
||||
override fun syncHeadersFinished(folderServerId: String, totalMessagesInMailbox: Int, numNewMessages: Int) = Unit
|
||||
override fun syncProgress(folderServerId: String, completed: Int, total: Int) = Unit
|
||||
override fun syncNewMessage(folderServerId: String, messageServerId: String, isOldMessage: Boolean) = Unit
|
||||
override fun syncRemovedMessage(folderServerId: String, messageServerId: String) = Unit
|
||||
override fun syncFlagChanged(folderServerId: String, messageServerId: String) = Unit
|
||||
override fun syncFinished(folderServerId: String) = Unit
|
||||
override fun syncFailed(folderServerId: String, message: String, exception: Exception?) = Unit
|
||||
override fun folderStatusChanged(folderServerId: String) = Unit
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
interface SystemAlarmManager {
|
||||
fun setAlarm(triggerTime: Long, callback: () -> Unit)
|
||||
fun cancelAlarm()
|
||||
fun now(): Long
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.Message
|
||||
import java.util.Comparator
|
||||
|
||||
internal class UidReverseComparator : Comparator<Message> {
|
||||
override fun compare(messageLeft: Message, messageRight: Message): Int {
|
||||
val uidLeft = messageLeft.uidOrNull
|
||||
val uidRight = messageRight.uidOrNull
|
||||
if (uidLeft == null && uidRight == null) {
|
||||
return 0
|
||||
} else if (uidLeft == null) {
|
||||
return 1
|
||||
} else if (uidRight == null) {
|
||||
return -1
|
||||
}
|
||||
|
||||
// reverse order
|
||||
return uidRight.compareTo(uidLeft)
|
||||
}
|
||||
|
||||
private val Message.uidOrNull
|
||||
get() = uid?.toLongOrNull()
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package net.thunderbird.backend.imap
|
||||
|
||||
import com.fsck.k9.backend.imap.ImapBackend
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.folders.FolderServerId
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.thunderbird.backend.api.BackendFactory
|
||||
import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome
|
||||
import net.thunderbird.backend.api.folder.RemoteFolderCreator
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.core.outcome.Outcome
|
||||
import net.thunderbird.feature.mail.account.api.BaseAccount
|
||||
|
||||
class ImapRemoteFolderCreator(
|
||||
private val logger: Logger,
|
||||
private val imapStore: ImapStore,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : RemoteFolderCreator {
|
||||
override suspend fun create(
|
||||
folderServerId: FolderServerId,
|
||||
mustCreate: Boolean,
|
||||
folderType: FolderType,
|
||||
): Outcome<RemoteFolderCreationOutcome.Success, RemoteFolderCreationOutcome.Error> = withContext(ioDispatcher) {
|
||||
val remoteFolder = imapStore.getFolder(name = folderServerId.serverId)
|
||||
val outcome = try {
|
||||
val folderExists = remoteFolder.exists()
|
||||
when {
|
||||
folderExists && mustCreate -> Outcome.failure(
|
||||
RemoteFolderCreationOutcome.Error.AlreadyExists,
|
||||
)
|
||||
|
||||
folderExists -> Outcome.success(RemoteFolderCreationOutcome.Success.AlreadyExists)
|
||||
|
||||
!folderExists && remoteFolder.create(folderType = folderType) -> Outcome.success(
|
||||
RemoteFolderCreationOutcome.Success.Created,
|
||||
)
|
||||
|
||||
else -> Outcome.failure(
|
||||
RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder(
|
||||
reason = "Failed to create folder on remote server.",
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: MessagingException) {
|
||||
logger.error(message = { "Failed to create remote folder '${folderServerId.serverId}'" }, throwable = e)
|
||||
Outcome.failure(
|
||||
RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder(
|
||||
reason = e.message ?: "Unhandled exception. Please check the logs.",
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
class ImapRemoteFolderCreatorFactory(
|
||||
private val logger: Logger,
|
||||
private val backendFactory: BackendFactory<BaseAccount>,
|
||||
) : RemoteFolderCreator.Factory {
|
||||
override fun create(account: BaseAccount): RemoteFolderCreator {
|
||||
val backend = backendFactory.createBackend(account) as ImapBackend
|
||||
return ImapRemoteFolderCreator(
|
||||
logger = logger,
|
||||
imapStore = backend.imapStore,
|
||||
ioDispatcher = Dispatchers.IO,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import org.junit.Test
|
||||
|
||||
private const val START_TIME = 100_000_000L
|
||||
|
||||
class BackendIdleRefreshManagerTest {
|
||||
val alarmManager = MockSystemAlarmManager(START_TIME)
|
||||
val idleRefreshManager = BackendIdleRefreshManager(alarmManager)
|
||||
|
||||
@Test
|
||||
fun `single timer`() {
|
||||
val timeout = 15 * 60 * 1000L
|
||||
val callback = RecordingCallback()
|
||||
|
||||
idleRefreshManager.startTimer(timeout, callback::alarm)
|
||||
alarmManager.advanceTime(timeout)
|
||||
|
||||
assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout))
|
||||
assertThat(callback.wasCalled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starting two timers in quick succession`() {
|
||||
val timeout = 15 * 60 * 1000L
|
||||
val callback1 = RecordingCallback()
|
||||
val callback2 = RecordingCallback()
|
||||
|
||||
idleRefreshManager.startTimer(timeout, callback1::alarm)
|
||||
// Advance clock less than MIN_TIMER_DELTA
|
||||
alarmManager.advanceTime(100)
|
||||
idleRefreshManager.startTimer(timeout, callback2::alarm)
|
||||
alarmManager.advanceTime(timeout)
|
||||
|
||||
assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout))
|
||||
assertThat(callback1.wasCalled).isTrue()
|
||||
assertThat(callback2.wasCalled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starting second timer some time after first should trigger both at initial trigger time`() {
|
||||
val timeout = 15 * 60 * 1000L
|
||||
val waitTime = 10 * 60 * 1000L
|
||||
val callback1 = RecordingCallback()
|
||||
val callback2 = RecordingCallback()
|
||||
|
||||
idleRefreshManager.startTimer(timeout, callback1::alarm)
|
||||
// Advance clock by more than MIN_TIMER_DELTA but less than 'timeout'
|
||||
alarmManager.advanceTime(waitTime)
|
||||
|
||||
assertThat(callback1.wasCalled).isFalse()
|
||||
|
||||
idleRefreshManager.startTimer(timeout, callback2::alarm)
|
||||
alarmManager.advanceTime(timeout - waitTime)
|
||||
|
||||
assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout))
|
||||
assertThat(callback1.wasCalled).isTrue()
|
||||
assertThat(callback2.wasCalled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second timer with lower timeout should reschedule alarm`() {
|
||||
val timeout1 = 15 * 60 * 1000L
|
||||
val timeout2 = 10 * 60 * 1000L
|
||||
val callback1 = RecordingCallback()
|
||||
val callback2 = RecordingCallback()
|
||||
|
||||
idleRefreshManager.startTimer(timeout1, callback1::alarm)
|
||||
|
||||
assertThat(alarmManager.triggerTime).isEqualTo(START_TIME + timeout1)
|
||||
|
||||
idleRefreshManager.startTimer(timeout2, callback2::alarm)
|
||||
alarmManager.advanceTime(timeout2)
|
||||
|
||||
assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout1, START_TIME + timeout2))
|
||||
assertThat(callback1.wasCalled).isTrue()
|
||||
assertThat(callback2.wasCalled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `do not trigger timers earlier than necessary`() {
|
||||
val timeout1 = 10 * 60 * 1000L
|
||||
val timeout2 = 23 * 60 * 1000L
|
||||
val callback1 = RecordingCallback()
|
||||
val callback2 = RecordingCallback()
|
||||
val callback3 = RecordingCallback()
|
||||
|
||||
idleRefreshManager.startTimer(timeout1, callback1::alarm)
|
||||
idleRefreshManager.startTimer(timeout2, callback2::alarm)
|
||||
|
||||
alarmManager.advanceTime(timeout1)
|
||||
assertThat(callback1.wasCalled).isTrue()
|
||||
assertThat(callback2.wasCalled).isFalse()
|
||||
|
||||
idleRefreshManager.startTimer(timeout1, callback3::alarm)
|
||||
|
||||
alarmManager.advanceTime(timeout1)
|
||||
|
||||
assertThat(alarmManager.alarmTimes).isEqualTo(
|
||||
listOf(START_TIME + timeout1, START_TIME + timeout2, START_TIME + timeout1 + timeout1),
|
||||
)
|
||||
assertThat(callback2.wasCalled).isTrue()
|
||||
assertThat(callback3.wasCalled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset timers`() {
|
||||
val timeout = 10 * 60 * 1000L
|
||||
val callback = RecordingCallback()
|
||||
|
||||
idleRefreshManager.startTimer(timeout, callback::alarm)
|
||||
|
||||
alarmManager.advanceTime(5 * 60 * 1000L)
|
||||
assertThat(callback.wasCalled).isFalse()
|
||||
|
||||
idleRefreshManager.resetTimers()
|
||||
|
||||
assertThat(alarmManager.triggerTime).isEqualTo(NO_TRIGGER_TIME)
|
||||
assertThat(callback.wasCalled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel timer`() {
|
||||
val timeout = 10 * 60 * 1000L
|
||||
val callback = RecordingCallback()
|
||||
|
||||
val timer = idleRefreshManager.startTimer(timeout, callback::alarm)
|
||||
|
||||
alarmManager.advanceTime(5 * 60 * 1000L)
|
||||
timer.cancel()
|
||||
|
||||
assertThat(alarmManager.triggerTime).isEqualTo(NO_TRIGGER_TIME)
|
||||
assertThat(callback.wasCalled).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingCallback {
|
||||
var wasCalled = false
|
||||
private set
|
||||
|
||||
fun alarm() {
|
||||
wasCalled = true
|
||||
}
|
||||
}
|
||||
|
||||
typealias Callback = () -> Unit
|
||||
private const val NO_TRIGGER_TIME = -1L
|
||||
|
||||
class MockSystemAlarmManager(startTime: Long) : SystemAlarmManager {
|
||||
var now = startTime
|
||||
var triggerTime = NO_TRIGGER_TIME
|
||||
var callback: Callback? = null
|
||||
val alarmTimes = mutableListOf<Long>()
|
||||
|
||||
override fun setAlarm(triggerTime: Long, callback: () -> Unit) {
|
||||
this.triggerTime = triggerTime
|
||||
this.callback = callback
|
||||
alarmTimes.add(triggerTime)
|
||||
}
|
||||
|
||||
override fun cancelAlarm() {
|
||||
this.triggerTime = NO_TRIGGER_TIME
|
||||
this.callback = null
|
||||
}
|
||||
|
||||
override fun now(): Long = now
|
||||
|
||||
fun advanceTime(delta: Long) {
|
||||
now += delta
|
||||
if (now >= triggerTime) {
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
|
||||
private fun trigger() {
|
||||
callback?.invoke().also {
|
||||
triggerTime = NO_TRIGGER_TIME
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import app.k9mail.backend.testing.InMemoryBackendStorage
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsAtLeast
|
||||
import assertk.assertions.containsExactlyInAnyOrder
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.mail.FetchProfile
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import com.fsck.k9.mail.store.imap.FetchListener
|
||||
import com.fsck.k9.mail.store.imap.ImapMessage
|
||||
import com.fsck.k9.mail.testing.message.buildMessage
|
||||
import java.util.Date
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import org.apache.james.mime4j.dom.field.DateTimeField
|
||||
import org.apache.james.mime4j.field.DefaultFieldParser
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.atLeast
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
|
||||
private const val ACCOUNT_NAME = "Account-1"
|
||||
private const val FOLDER_SERVER_ID = "FOLDER_ONE"
|
||||
private const val MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE = 1000
|
||||
private const val DEFAULT_VISIBLE_LIMIT = 25
|
||||
private const val DEFAULT_MESSAGE_DATE = "Tue, 04 Jan 2022 10:00:00 +0100"
|
||||
|
||||
class ImapSyncTest {
|
||||
private val backendStorage = createBackendStorage()
|
||||
private val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
private val imapStore = TestImapStore()
|
||||
private val imapFolder = imapStore.addFolder(FOLDER_SERVER_ID)
|
||||
private val imapSync = ImapSync(ACCOUNT_NAME, backendStorage, imapStore)
|
||||
private val syncListener = mock<SyncListener>()
|
||||
private val defaultSyncConfig = createSyncConfig()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Log.logger = TestLogger()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync of empty folder should notify listener`() {
|
||||
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
|
||||
|
||||
verify(syncListener).syncStarted(FOLDER_SERVER_ID)
|
||||
verify(syncListener).syncAuthenticationSuccess()
|
||||
verify(syncListener).syncFinished(FOLDER_SERVER_ID)
|
||||
verify(syncListener, never()).syncFailed(folderServerId = any(), message = any(), exception = any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync of folder with negative messageCount should return an error`() {
|
||||
imapFolder.messageCount = -1
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
|
||||
|
||||
verify(syncListener).syncFailed(
|
||||
folderServerId = eq(FOLDER_SERVER_ID),
|
||||
message = eq("Exception: Message count -1 for folder $FOLDER_SERVER_ID"),
|
||||
exception = any(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful sync should close folder`() {
|
||||
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
|
||||
|
||||
assertThat(imapFolder.isClosed).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with error should close folder`() {
|
||||
imapFolder.messageCount = -1
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
|
||||
|
||||
assertThat(imapFolder.isClosed).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with ExpungePolicy ON_POLL should expunge remote folder`() {
|
||||
val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.ON_POLL)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(imapFolder.wasExpunged).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with ExpungePolicy MANUALLY should not expunge remote folder`() {
|
||||
val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.MANUALLY)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(imapFolder.wasExpunged).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with ExpungePolicy IMMEDIATELY should not expunge remote folder`() {
|
||||
val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.IMMEDIATELY)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(imapFolder.wasExpunged).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with syncRemoteDeletions=true should remove local messages`() {
|
||||
addMessageToBackendFolder(uid = 42)
|
||||
val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = true)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).isEmpty()
|
||||
verify(syncListener).syncStarted(FOLDER_SERVER_ID)
|
||||
verify(syncListener).syncFinished(FOLDER_SERVER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with syncRemoteDeletions=false should not remove local messages`() {
|
||||
addMessageToBackendFolder(uid = 23)
|
||||
val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = false)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("23")
|
||||
verify(syncListener).syncStarted(FOLDER_SERVER_ID)
|
||||
verify(syncListener).syncFinished(FOLDER_SERVER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync should remove messages older than earliestPollDate`() {
|
||||
addMessageToImapAndBackendFolder(uid = 23, date = "Mon, 03 Jan 2022 10:00:00 +0100")
|
||||
addMessageToImapAndBackendFolder(uid = 42, date = "Wed, 05 Jan 2022 20:00:00 +0100")
|
||||
val syncConfig = defaultSyncConfig.copy(
|
||||
syncRemoteDeletions = true,
|
||||
earliestPollDate = "Tue, 04 Jan 2022 12:00:00 +0100".toDate(),
|
||||
)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with new messages on server should download messages`() {
|
||||
addMessageToImapFolder(uid = 9)
|
||||
addMessageToImapFolder(uid = 13)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("9", "13")
|
||||
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "9", isOldMessage = false)
|
||||
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "13", isOldMessage = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync downloading old messages should notify listener with isOldMessage=true`() {
|
||||
addMessageToBackendFolder(uid = 42)
|
||||
addMessageToImapFolder(uid = 23)
|
||||
addMessageToImapFolder(uid = 42)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("23", "42")
|
||||
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "23", isOldMessage = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determining the highest UID should use numerical ordering`() {
|
||||
addMessageToBackendFolder(uid = 9)
|
||||
addMessageToBackendFolder(uid = 100)
|
||||
// When text ordering is used: "9" > "100" -> highest UID = 9 (when it should be 100)
|
||||
// With 80 > 9 the message on the server is considered a new message, but it shouldn't be (80 < 100)
|
||||
addMessageToImapFolder(uid = 80)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
|
||||
|
||||
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "80", isOldMessage = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync should update flags of existing messages`() {
|
||||
addMessageToBackendFolder(uid = 2)
|
||||
addMessageToImapFolder(uid = 2, flags = setOf(Flag.SEEN, Flag.ANSWERED))
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageFlags(messageServerId = "2")).containsAtLeast(Flag.SEEN, Flag.ANSWERED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with UIDVALIDITY change should clear all messages`() {
|
||||
imapFolder.setUidValidity(1)
|
||||
addMessageToImapFolder(uid = 300)
|
||||
addMessageToImapFolder(uid = 301)
|
||||
val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = false)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("300", "301")
|
||||
|
||||
imapFolder.setUidValidity(9000)
|
||||
imapFolder.removeAllMessages()
|
||||
addMessageToImapFolder(uid = 1)
|
||||
|
||||
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("1")
|
||||
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "1", isOldMessage = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with multiple FETCH responses when downloading small message should report correct progress`() {
|
||||
val folderServerId = "FOLDER_TWO"
|
||||
backendStorage.createBackendFolder(folderServerId)
|
||||
val specialImapFolder = object : TestImapFolder(folderServerId) {
|
||||
override fun fetch(
|
||||
messages: List<ImapMessage>,
|
||||
fetchProfile: FetchProfile,
|
||||
listener: FetchListener?,
|
||||
maxDownloadSize: Int,
|
||||
) {
|
||||
super.fetch(messages, fetchProfile, listener, maxDownloadSize)
|
||||
|
||||
// When fetching the body simulate an additional FETCH response
|
||||
if (FetchProfile.Item.BODY in fetchProfile) {
|
||||
val message = messages.first()
|
||||
listener?.onFetchResponse(message, isFirstResponse = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
specialImapFolder.addMessage(42)
|
||||
imapStore.addFolder(specialImapFolder)
|
||||
|
||||
imapSync.sync(folderServerId, defaultSyncConfig, syncListener)
|
||||
|
||||
verify(syncListener, atLeast(1)).syncProgress(folderServerId, completed = 1, total = 1)
|
||||
verify(syncListener, never()).syncProgress(folderServerId, completed = 2, total = 1)
|
||||
}
|
||||
|
||||
private fun addMessageToBackendFolder(uid: Long, date: String = DEFAULT_MESSAGE_DATE) {
|
||||
val messageServerId = uid.toString()
|
||||
val message = createSimpleMessage(messageServerId, date).apply {
|
||||
setUid(messageServerId)
|
||||
}
|
||||
backendFolder.saveMessage(message, MessageDownloadState.FULL)
|
||||
|
||||
val highestKnownUid = backendFolder.getFolderExtraNumber("imapHighestKnownUid") ?: 0
|
||||
if (uid > highestKnownUid) {
|
||||
backendFolder.setFolderExtraNumber("imapHighestKnownUid", uid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMessageToImapFolder(uid: Long, flags: Set<Flag> = emptySet(), date: String = DEFAULT_MESSAGE_DATE) {
|
||||
imapFolder.addMessage(uid, flags, date)
|
||||
}
|
||||
|
||||
private fun TestImapFolder.addMessage(
|
||||
uid: Long,
|
||||
flags: Set<Flag> = emptySet(),
|
||||
date: String = DEFAULT_MESSAGE_DATE,
|
||||
) {
|
||||
val messageServerId = uid.toString()
|
||||
val message = createSimpleMessage(messageServerId, date)
|
||||
addMessage(uid, message)
|
||||
|
||||
if (flags.isNotEmpty()) {
|
||||
val imapMessage = getMessage(messageServerId)
|
||||
setFlags(listOf(imapMessage), flags, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMessageToImapAndBackendFolder(uid: Long, date: String) {
|
||||
addMessageToBackendFolder(uid, date)
|
||||
addMessageToImapFolder(uid, date = date)
|
||||
}
|
||||
|
||||
private fun createBackendStorage(): InMemoryBackendStorage {
|
||||
return InMemoryBackendStorage().apply {
|
||||
createBackendFolder(FOLDER_SERVER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun InMemoryBackendStorage.createBackendFolder(serverId: String) {
|
||||
createFolderUpdater().use { updater ->
|
||||
val folderInfo = FolderInfo(
|
||||
serverId = serverId,
|
||||
name = "irrelevant",
|
||||
type = FolderType.REGULAR,
|
||||
)
|
||||
updater.createFolders(listOf(folderInfo))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSyncConfig(): SyncConfig {
|
||||
return SyncConfig(
|
||||
expungePolicy = ExpungePolicy.MANUALLY,
|
||||
earliestPollDate = null,
|
||||
syncRemoteDeletions = true,
|
||||
maximumAutoDownloadMessageSize = MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE,
|
||||
defaultVisibleLimit = DEFAULT_VISIBLE_LIMIT,
|
||||
syncFlags = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSimpleMessage(uid: String, date: String, text: String = "UID: $uid"): Message {
|
||||
return buildMessage {
|
||||
header("Subject", "Test Message")
|
||||
header("From", "alice@domain.example")
|
||||
header("To", "Bob <bob@domain.example>")
|
||||
header("Date", date)
|
||||
header("Message-ID", "<msg-$uid@domain.example>")
|
||||
|
||||
textBody(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toDate(): Date {
|
||||
val dateTimeField = DefaultFieldParser.parse("Date: $this") as DateTimeField
|
||||
return dateTimeField.date
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
import com.fsck.k9.mail.FetchProfile
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessageRetrievalListener
|
||||
import com.fsck.k9.mail.Part
|
||||
import com.fsck.k9.mail.store.imap.FetchListener
|
||||
import com.fsck.k9.mail.store.imap.ImapFolder
|
||||
import com.fsck.k9.mail.store.imap.ImapMessage
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
import com.fsck.k9.mail.store.imap.createImapMessage
|
||||
import java.util.Date
|
||||
|
||||
open class TestImapFolder(override val serverId: String) : ImapFolder {
|
||||
override var mode: OpenMode? = null
|
||||
protected set
|
||||
|
||||
override val isOpen: Boolean
|
||||
get() = mode != null
|
||||
|
||||
override var messageCount: Int = 0
|
||||
|
||||
var wasExpunged: Boolean = false
|
||||
private set
|
||||
|
||||
val isClosed: Boolean
|
||||
get() = mode == null
|
||||
|
||||
private val messages = mutableMapOf<Long, Message>()
|
||||
private val messageFlags = mutableMapOf<Long, MutableSet<Flag>>()
|
||||
private var uidValidity: Long? = null
|
||||
|
||||
fun addMessage(uid: Long, message: Message) {
|
||||
require(!messages.containsKey(uid)) {
|
||||
"Folder '$serverId' already contains a message with the UID $uid"
|
||||
}
|
||||
|
||||
messages[uid] = message
|
||||
messageFlags[uid] = mutableSetOf()
|
||||
|
||||
messageCount = messages.size
|
||||
}
|
||||
|
||||
fun removeAllMessages() {
|
||||
messages.clear()
|
||||
messageFlags.clear()
|
||||
}
|
||||
|
||||
fun setUidValidity(value: Long) {
|
||||
uidValidity = value
|
||||
}
|
||||
|
||||
override fun open(mode: OpenMode) {
|
||||
this.mode = mode
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
mode = null
|
||||
}
|
||||
|
||||
override fun exists(): Boolean {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getUidValidity() = uidValidity
|
||||
|
||||
override fun getMessage(uid: String): ImapMessage {
|
||||
return createImapMessage(uid)
|
||||
}
|
||||
|
||||
override fun getUidFromMessageId(messageId: String): String? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getMessages(
|
||||
start: Int,
|
||||
end: Int,
|
||||
earliestDate: Date?,
|
||||
listener: MessageRetrievalListener<ImapMessage>?,
|
||||
): List<ImapMessage> {
|
||||
require(start > 0)
|
||||
require(end >= start)
|
||||
require(end <= messages.size)
|
||||
|
||||
return messages.keys.sortedDescending()
|
||||
.slice((start - 1) until end)
|
||||
.map { createImapMessage(uid = it.toString()) }
|
||||
}
|
||||
|
||||
override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun fetch(
|
||||
messages: List<ImapMessage>,
|
||||
fetchProfile: FetchProfile,
|
||||
listener: FetchListener?,
|
||||
maxDownloadSize: Int,
|
||||
) {
|
||||
if (messages.isEmpty()) return
|
||||
|
||||
for (imapMessage in messages) {
|
||||
val uid = imapMessage.uid.toLong()
|
||||
|
||||
val flags = messageFlags[uid].orEmpty().toSet()
|
||||
imapMessage.setFlags(flags, true)
|
||||
|
||||
val storedMessage = this.messages[uid] ?: error("Message $uid not found")
|
||||
for (header in storedMessage.headers) {
|
||||
imapMessage.addHeader(header.name, header.value)
|
||||
}
|
||||
imapMessage.body = storedMessage.body
|
||||
|
||||
listener?.onFetchResponse(imapMessage, isFirstResponse = true)
|
||||
}
|
||||
}
|
||||
|
||||
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 setFlagsForAllMessages(flags: Set<Flag>, value: Boolean) {
|
||||
if (value) {
|
||||
for (messageFlagSet in messageFlags.values) {
|
||||
messageFlagSet.addAll(flags)
|
||||
}
|
||||
} else {
|
||||
for (messageFlagSet in messageFlags.values) {
|
||||
messageFlagSet.removeAll(flags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setFlags(messages: List<ImapMessage>, flags: Set<Flag>, value: Boolean) {
|
||||
for (message in messages) {
|
||||
val uid = message.uid.toLong()
|
||||
val messageFlagSet = messageFlags[uid] ?: error("Unknown message with UID $uid")
|
||||
if (value) {
|
||||
messageFlagSet.addAll(flags)
|
||||
} else {
|
||||
messageFlagSet.removeAll(flags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 deleteMessages(messages: List<ImapMessage>) {
|
||||
setFlags(messages, setOf(Flag.DELETED), true)
|
||||
}
|
||||
|
||||
override fun deleteAllMessages() {
|
||||
setFlagsForAllMessages(setOf(Flag.DELETED), true)
|
||||
}
|
||||
|
||||
override fun expunge() {
|
||||
mode = OpenMode.READ_WRITE
|
||||
wasExpunged = true
|
||||
}
|
||||
|
||||
override fun expungeUids(uids: List<String>) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun create(folderType: FolderType): Boolean {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.store.imap.FolderListItem
|
||||
import com.fsck.k9.mail.store.imap.ImapFolder
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
|
||||
class TestImapStore : ImapStore {
|
||||
private val folders = mutableMapOf<String, ImapFolder>()
|
||||
|
||||
override val combinedPrefix: String?
|
||||
get() = throw UnsupportedOperationException("not implemented")
|
||||
|
||||
fun addFolder(serverId: String): TestImapFolder {
|
||||
require(!folders.containsKey(serverId)) { "Folder '$serverId' already exists" }
|
||||
|
||||
return TestImapFolder(serverId).also { folder ->
|
||||
folders[serverId] = folder
|
||||
}
|
||||
}
|
||||
|
||||
fun addFolder(folder: ImapFolder) {
|
||||
val serverId = folder.serverId
|
||||
require(!folders.containsKey(serverId)) { "Folder '$serverId' already exists" }
|
||||
|
||||
folders[serverId] = folder
|
||||
}
|
||||
|
||||
override fun getFolder(name: String): ImapFolder {
|
||||
return folders[name] ?: error("Folder '$name' not found")
|
||||
}
|
||||
|
||||
override fun getFolders(): List<FolderListItem> {
|
||||
return folders.values.map { folder ->
|
||||
FolderListItem(
|
||||
serverId = folder.serverId,
|
||||
name = "irrelevant",
|
||||
type = FolderType.REGULAR,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun checkSettings() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun closeAllConnections() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun fetchImapPrefix() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
fun createImapMessage(uid: String) = ImapMessage(uid)
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package net.thunderbird.backend.imap
|
||||
|
||||
import assertk.assertAll
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.assertions.prop
|
||||
import com.fsck.k9.backend.imap.TestImapFolder
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.folders.FolderServerId
|
||||
import com.fsck.k9.mail.store.imap.FolderListItem
|
||||
import com.fsck.k9.mail.store.imap.ImapFolder
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome
|
||||
import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import net.thunderbird.core.outcome.Outcome
|
||||
import org.junit.Test
|
||||
|
||||
class ImapRemoteFolderCreatorTest {
|
||||
private val logger = TestLogger()
|
||||
|
||||
@Test
|
||||
fun `when mustCreate true and folder exists, should return Error AlreadyExists`() = runTest {
|
||||
// Arrange
|
||||
val folderServerId = FolderServerId("New Folder")
|
||||
val fakeFolder = object : TestImapFolder(folderServerId.serverId) {
|
||||
override fun exists(): Boolean = true
|
||||
}
|
||||
val imapStore = FakeImapStore(fakeFolder)
|
||||
val sut = ImapRemoteFolderCreator(logger, imapStore)
|
||||
|
||||
// Act
|
||||
val outcome = sut.create(folderServerId, mustCreate = true)
|
||||
|
||||
// Assert
|
||||
assertAll {
|
||||
assertThat(outcome.isFailure).isTrue()
|
||||
assertThat(outcome)
|
||||
.isInstanceOf<Outcome.Failure<RemoteFolderCreationOutcome.Error>>()
|
||||
.prop("error") { it.error }
|
||||
.isEqualTo(RemoteFolderCreationOutcome.Error.AlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when mustCreate false and folder exists, should return AlreadyExists`() = runTest {
|
||||
// Arrange
|
||||
val folderServerId = FolderServerId("New Folder")
|
||||
val fakeFolder = object : TestImapFolder(folderServerId.serverId) {
|
||||
override fun exists(): Boolean = true
|
||||
}
|
||||
val imapStore = FakeImapStore(fakeFolder)
|
||||
val sut = ImapRemoteFolderCreator(logger, imapStore)
|
||||
|
||||
// Act
|
||||
val outcome = sut.create(folderServerId, mustCreate = false)
|
||||
|
||||
// Assert
|
||||
assertAll {
|
||||
assertThat(outcome.isSuccess).isTrue()
|
||||
assertThat(outcome)
|
||||
.isInstanceOf<Outcome.Success<RemoteFolderCreationOutcome.Success>>()
|
||||
.prop("data") { it.data }
|
||||
.isEqualTo(RemoteFolderCreationOutcome.Success.AlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when folder does not exist and creation succeeds, should return Created`() = runTest {
|
||||
// Arrange
|
||||
val folderServerId = FolderServerId("New Folder")
|
||||
val fakeFolder = object : TestImapFolder(folderServerId.serverId) {
|
||||
override fun exists(): Boolean = false
|
||||
override fun create(folderType: FolderType): Boolean = true
|
||||
}
|
||||
val imapStore = FakeImapStore(fakeFolder)
|
||||
val sut = ImapRemoteFolderCreator(logger, imapStore)
|
||||
|
||||
// Act
|
||||
val outcome = sut.create(folderServerId, mustCreate = true)
|
||||
|
||||
// Assert
|
||||
assertAll {
|
||||
assertThat(outcome.isSuccess).isTrue()
|
||||
assertThat(outcome)
|
||||
.isInstanceOf<Outcome.Success<RemoteFolderCreationOutcome.Success>>()
|
||||
.prop("data") { it.data }
|
||||
.isEqualTo(RemoteFolderCreationOutcome.Success.Created)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when folder does not exist and creation fails, should return FailedToCreateRemoteFolder`() = runTest {
|
||||
// Arrange
|
||||
val folderServerId = FolderServerId("New Folder")
|
||||
val fakeFolder = object : TestImapFolder(folderServerId.serverId) {
|
||||
override fun exists(): Boolean = false
|
||||
override fun create(folderType: FolderType): Boolean = false
|
||||
}
|
||||
val imapStore = FakeImapStore(fakeFolder)
|
||||
val sut = ImapRemoteFolderCreator(logger, imapStore)
|
||||
|
||||
// Act
|
||||
val outcome = sut.create(folderServerId, mustCreate = true)
|
||||
|
||||
// Assert
|
||||
assertAll {
|
||||
assertThat(outcome.isFailure).isTrue()
|
||||
assertThat(outcome)
|
||||
.isInstanceOf<Outcome.Failure<FailedToCreateRemoteFolder>>()
|
||||
.prop("error") { it.error }
|
||||
.isInstanceOf<FailedToCreateRemoteFolder>()
|
||||
.prop(FailedToCreateRemoteFolder::reason)
|
||||
.isEqualTo("Failed to create folder on remote server.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeImapStore(
|
||||
private val folder: TestImapFolder,
|
||||
) : ImapStore {
|
||||
override val combinedPrefix: String?
|
||||
get() = throw NotImplementedError("combinedPrefix not implemented")
|
||||
|
||||
override fun checkSettings() {
|
||||
throw NotImplementedError("checkSettings not implemented")
|
||||
}
|
||||
|
||||
override fun getFolder(name: String): ImapFolder = folder
|
||||
|
||||
override fun getFolders(): List<FolderListItem> {
|
||||
throw NotImplementedError("getFolders not implemented")
|
||||
}
|
||||
|
||||
override fun closeAllConnections() {
|
||||
throw NotImplementedError("closeAllConnections not implemented")
|
||||
}
|
||||
|
||||
override fun fetchImapPrefix() {
|
||||
throw NotImplementedError("fetchImapPrefix not implemented")
|
||||
}
|
||||
}
|
||||
21
backend/jmap/build.gradle.kts
Normal file
21
backend/jmap/build.gradle.kts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.backend.api)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.feature.mail.folder.api)
|
||||
|
||||
api(libs.okhttp)
|
||||
implementation(libs.jmap.client)
|
||||
implementation(libs.moshi)
|
||||
ksp(libs.moshi.kotlin.codegen)
|
||||
|
||||
testImplementation(projects.core.logging.testing)
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(projects.backend.testing)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.common.Request.Invocation.ResultReference
|
||||
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
|
||||
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
|
||||
|
||||
class CommandDelete(
|
||||
private val jmapClient: JmapClient,
|
||||
private val accountId: String,
|
||||
) {
|
||||
fun deleteMessages(messageServerIds: List<String>) {
|
||||
Log.v("Deleting messages %s", messageServerIds)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInSet = session.maxObjectsInSet
|
||||
|
||||
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
|
||||
val setEmailCall = jmapClient.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.destroy(emailIds.toTypedArray())
|
||||
.build(),
|
||||
)
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAllMessages(folderServerId: String) {
|
||||
Log.d("Deleting all messages from %s", folderServerId)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val limit = session.maxObjectsInSet.coerceAtMost(MAX_CHUNK_SIZE).toLong()
|
||||
|
||||
do {
|
||||
Log.v("Trying to delete up to %d messages from %s", limit, folderServerId)
|
||||
val multiCall = jmapClient.newMultiCall()
|
||||
|
||||
val queryEmailCall = multiCall.call(
|
||||
QueryEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.filter(EmailFilterCondition.builder().inMailbox(folderServerId).build())
|
||||
.calculateTotal(true)
|
||||
.limit(limit)
|
||||
.build(),
|
||||
)
|
||||
|
||||
val setEmailCall = multiCall.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.destroyReference(queryEmailCall.createResultReference(ResultReference.Path.IDS))
|
||||
.build(),
|
||||
)
|
||||
|
||||
multiCall.execute()
|
||||
|
||||
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
|
||||
val numberOfReturnedEmails = queryEmailResponse.ids.size
|
||||
val totalNumberOfEmails = queryEmailResponse.total ?: error("Server didn't return property 'total'")
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
|
||||
Log.v("Deleted %d messages from %s", numberOfReturnedEmails, folderServerId)
|
||||
} while (totalNumberOfEmails > numberOfReturnedEmails)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
|
||||
import rs.ltt.jmap.common.util.Patches
|
||||
|
||||
class CommandMove(
|
||||
private val jmapClient: JmapClient,
|
||||
private val accountId: String,
|
||||
) {
|
||||
fun moveMessages(targetFolderServerId: String, messageServerIds: List<String>) {
|
||||
Log.v("Moving %d messages to %s", messageServerIds.size, targetFolderServerId)
|
||||
|
||||
val mailboxPatch = Patches.set("mailboxIds", mapOf(targetFolderServerId to true))
|
||||
updateEmails(messageServerIds, mailboxPatch)
|
||||
}
|
||||
|
||||
fun moveMessagesAndMarkAsRead(targetFolderServerId: String, messageServerIds: List<String>) {
|
||||
Log.v("Moving %d messages to %s and marking them as read", messageServerIds.size, targetFolderServerId)
|
||||
|
||||
val mailboxPatch = Patches.builder()
|
||||
.set("mailboxIds", mapOf(targetFolderServerId to true))
|
||||
.set("keywords/\$seen", true)
|
||||
.build()
|
||||
updateEmails(messageServerIds, mailboxPatch)
|
||||
}
|
||||
|
||||
fun copyMessages(targetFolderServerId: String, messageServerIds: List<String>) {
|
||||
Log.v("Copying %d messages to %s", messageServerIds.size, targetFolderServerId)
|
||||
|
||||
val mailboxPatch = Patches.set("mailboxIds/$targetFolderServerId", true)
|
||||
updateEmails(messageServerIds, mailboxPatch)
|
||||
}
|
||||
|
||||
private fun updateEmails(messageServerIds: List<String>, patch: Map<String, Any>?) {
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInSet = session.maxObjectsInSet
|
||||
|
||||
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
|
||||
val updates = emailIds.map { emailId ->
|
||||
emailId to patch
|
||||
}.toMap()
|
||||
|
||||
val setEmailCall = jmapClient.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.update(updates)
|
||||
.build(),
|
||||
)
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.backend.api.BackendFolderUpdater
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.api.ErrorResponseException
|
||||
import rs.ltt.jmap.client.api.InvalidSessionResourceException
|
||||
import rs.ltt.jmap.client.api.MethodErrorResponseException
|
||||
import rs.ltt.jmap.client.api.UnauthorizedException
|
||||
import rs.ltt.jmap.common.Request.Invocation.ResultReference
|
||||
import rs.ltt.jmap.common.entity.Mailbox
|
||||
import rs.ltt.jmap.common.entity.Role
|
||||
import rs.ltt.jmap.common.method.call.mailbox.ChangesMailboxMethodCall
|
||||
import rs.ltt.jmap.common.method.call.mailbox.GetMailboxMethodCall
|
||||
import rs.ltt.jmap.common.method.response.mailbox.ChangesMailboxMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.mailbox.GetMailboxMethodResponse
|
||||
|
||||
internal class CommandRefreshFolderList(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val jmapClient: JmapClient,
|
||||
private val accountId: String,
|
||||
) {
|
||||
@Suppress("ThrowsCount", "TooGenericExceptionCaught")
|
||||
fun refreshFolderList(): FolderPathDelimiter? {
|
||||
try {
|
||||
backendStorage.createFolderUpdater().use { folderUpdater ->
|
||||
val state = backendStorage.getExtraString(STATE)
|
||||
if (state == null) {
|
||||
fetchMailboxes(folderUpdater)
|
||||
} else {
|
||||
fetchMailboxUpdates(folderUpdater, state)
|
||||
}
|
||||
}
|
||||
} catch (e: UnauthorizedException) {
|
||||
throw AuthenticationFailedException("Authentication failed", e)
|
||||
} catch (e: InvalidSessionResourceException) {
|
||||
throw MessagingException(e.message, true, e)
|
||||
} catch (e: ErrorResponseException) {
|
||||
throw MessagingException(e.message, true, e)
|
||||
} catch (e: MethodErrorResponseException) {
|
||||
throw MessagingException(e.message, e.isPermanentError, e)
|
||||
} catch (e: Exception) {
|
||||
throw MessagingException(e)
|
||||
}
|
||||
return FOLDER_DEFAULT_PATH_DELIMITER
|
||||
}
|
||||
|
||||
private fun fetchMailboxes(folderUpdater: BackendFolderUpdater) {
|
||||
val call = jmapClient.call(
|
||||
GetMailboxMethodCall.builder().accountId(accountId).build(),
|
||||
)
|
||||
val response = call.getMainResponseBlocking<GetMailboxMethodResponse>()
|
||||
val foldersOnServer = response.list
|
||||
|
||||
val oldFolderServerIds = backendStorage.getFolderServerIds()
|
||||
val (foldersToUpdate, foldersToCreate) = foldersOnServer.partition { it.id in oldFolderServerIds }
|
||||
|
||||
for (folder in foldersToUpdate) {
|
||||
folderUpdater.changeFolder(folder.id, folder.name, folder.type)
|
||||
}
|
||||
|
||||
val newFolders = foldersToCreate.map { folder ->
|
||||
FolderInfo(folder.id, folder.name, folder.type)
|
||||
}
|
||||
folderUpdater.createFolders(newFolders)
|
||||
|
||||
val newFolderServerIds = foldersOnServer.map { it.id }
|
||||
val removedFolderServerIds = oldFolderServerIds - newFolderServerIds
|
||||
folderUpdater.deleteFolders(removedFolderServerIds)
|
||||
|
||||
backendStorage.setExtraString(STATE, response.state)
|
||||
}
|
||||
|
||||
private fun fetchMailboxUpdates(folderUpdater: BackendFolderUpdater, state: String) {
|
||||
try {
|
||||
fetchAllMailboxChanges(folderUpdater, state)
|
||||
} catch (e: MethodErrorResponseException) {
|
||||
if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) {
|
||||
fetchMailboxes(folderUpdater)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAllMailboxChanges(folderUpdater: BackendFolderUpdater, state: String) {
|
||||
var currentState = state
|
||||
do {
|
||||
val (newState, hasMoreChanges) = fetchMailboxChanges(folderUpdater, currentState)
|
||||
currentState = newState
|
||||
} while (hasMoreChanges)
|
||||
}
|
||||
|
||||
private fun fetchMailboxChanges(folderUpdater: BackendFolderUpdater, state: String): UpdateState {
|
||||
val multiCall = jmapClient.newMultiCall()
|
||||
val mailboxChangesCall = multiCall.call(
|
||||
ChangesMailboxMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.sinceState(state)
|
||||
.build(),
|
||||
)
|
||||
val createdMailboxesCall = multiCall.call(
|
||||
GetMailboxMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.idsReference(mailboxChangesCall.createResultReference(ResultReference.Path.CREATED))
|
||||
.build(),
|
||||
)
|
||||
val changedMailboxesCall = multiCall.call(
|
||||
GetMailboxMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.idsReference(mailboxChangesCall.createResultReference(ResultReference.Path.UPDATED))
|
||||
.build(),
|
||||
)
|
||||
multiCall.execute()
|
||||
|
||||
val mailboxChangesResponse = mailboxChangesCall.getMainResponseBlocking<ChangesMailboxMethodResponse>()
|
||||
val createdMailboxResponse = createdMailboxesCall.getMainResponseBlocking<GetMailboxMethodResponse>()
|
||||
val changedMailboxResponse = changedMailboxesCall.getMainResponseBlocking<GetMailboxMethodResponse>()
|
||||
|
||||
val foldersToCreate = createdMailboxResponse.list.map { folder ->
|
||||
FolderInfo(folder.id, folder.name, folder.type)
|
||||
}
|
||||
folderUpdater.createFolders(foldersToCreate)
|
||||
|
||||
for (folder in changedMailboxResponse.list) {
|
||||
folderUpdater.changeFolder(folder.id, folder.name, folder.type)
|
||||
}
|
||||
|
||||
val destroyed = mailboxChangesResponse.destroyed
|
||||
destroyed?.let {
|
||||
folderUpdater.deleteFolders(it.toList())
|
||||
}
|
||||
|
||||
backendStorage.setExtraString(STATE, mailboxChangesResponse.newState)
|
||||
|
||||
return UpdateState(
|
||||
state = mailboxChangesResponse.newState,
|
||||
hasMoreChanges = mailboxChangesResponse.isHasMoreChanges,
|
||||
)
|
||||
}
|
||||
|
||||
private val Mailbox.type: FolderType
|
||||
get() = when (role) {
|
||||
Role.INBOX -> FolderType.INBOX
|
||||
Role.ARCHIVE -> FolderType.ARCHIVE
|
||||
Role.DRAFTS -> FolderType.DRAFTS
|
||||
Role.SENT -> FolderType.SENT
|
||||
Role.TRASH -> FolderType.TRASH
|
||||
Role.JUNK -> FolderType.SPAM
|
||||
else -> FolderType.REGULAR
|
||||
}
|
||||
|
||||
private val MethodErrorResponseException.isPermanentError: Boolean
|
||||
get() = methodErrorResponse.type != ERROR_SERVER_UNAVAILABLE
|
||||
|
||||
companion object {
|
||||
private const val STATE = "jmapState"
|
||||
private const val ERROR_SERVER_UNAVAILABLE = "serverUnavailable"
|
||||
private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges"
|
||||
}
|
||||
|
||||
private data class UpdateState(val state: String, val hasMoreChanges: Boolean)
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
|
||||
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
|
||||
import rs.ltt.jmap.common.util.Patches
|
||||
|
||||
class CommandSetFlag(
|
||||
private val jmapClient: JmapClient,
|
||||
private val accountId: String,
|
||||
) {
|
||||
fun setFlag(messageServerIds: List<String>, flag: Flag, newState: Boolean) {
|
||||
if (newState) {
|
||||
Log.v("Setting flag %s for messages %s", flag, messageServerIds)
|
||||
} else {
|
||||
Log.v("Removing flag %s for messages %s", flag, messageServerIds)
|
||||
}
|
||||
|
||||
val keyword = flag.toKeyword()
|
||||
val keywordsPatch = if (newState) {
|
||||
Patches.set("keywords/$keyword", true)
|
||||
} else {
|
||||
Patches.remove("keywords/$keyword")
|
||||
}
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInSet = session.maxObjectsInSet
|
||||
|
||||
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
|
||||
val updates = emailIds.map { emailId ->
|
||||
emailId to keywordsPatch
|
||||
}.toMap()
|
||||
|
||||
val setEmailCall = jmapClient.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.update(updates)
|
||||
.build(),
|
||||
)
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllAsRead(folderServerId: String) {
|
||||
Log.d("Marking all messages in %s as read", folderServerId)
|
||||
|
||||
val keywordsPatch = Patches.set("keywords/\$seen", true)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val limit = minOf(MAX_CHUNK_SIZE, session.maxObjectsInSet).toLong()
|
||||
|
||||
do {
|
||||
Log.v("Trying to mark up to %d messages in %s as read", limit, folderServerId)
|
||||
|
||||
val queryEmailCall = jmapClient.call(
|
||||
QueryEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.filter(
|
||||
EmailFilterCondition.builder()
|
||||
.inMailbox(folderServerId)
|
||||
.notKeyword("\$seen")
|
||||
.build(),
|
||||
)
|
||||
.calculateTotal(true)
|
||||
.limit(limit)
|
||||
.build(),
|
||||
)
|
||||
|
||||
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
|
||||
val numberOfReturnedEmails = queryEmailResponse.ids.size
|
||||
val totalNumberOfEmails = queryEmailResponse.total ?: error("Server didn't return property 'total'")
|
||||
|
||||
if (numberOfReturnedEmails == 0) {
|
||||
Log.v("There were no messages in %s to mark as read", folderServerId)
|
||||
} else {
|
||||
val updates = queryEmailResponse.ids.map { emailId ->
|
||||
emailId to keywordsPatch
|
||||
}.toMap()
|
||||
|
||||
val setEmailCall = jmapClient.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.update(updates)
|
||||
.build(),
|
||||
)
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
|
||||
Log.v("Marked %d messages in %s as read", numberOfReturnedEmails, folderServerId)
|
||||
}
|
||||
} while (totalNumberOfEmails > numberOfReturnedEmails)
|
||||
}
|
||||
|
||||
private fun Flag.toKeyword(): String = when (this) {
|
||||
Flag.SEEN -> "\$seen"
|
||||
Flag.FLAGGED -> "\$flagged"
|
||||
Flag.DRAFT -> "\$draft"
|
||||
Flag.ANSWERED -> "\$answered"
|
||||
Flag.FORWARDED -> "\$forwarded"
|
||||
else -> error("Unsupported flag: $name")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.backend.api.BackendFolder
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import com.fsck.k9.mail.internet.MimeMessage
|
||||
import java.util.Date
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.api.MethodErrorResponseException
|
||||
import rs.ltt.jmap.client.api.UnauthorizedException
|
||||
import rs.ltt.jmap.client.http.HttpAuthentication
|
||||
import rs.ltt.jmap.client.session.Session
|
||||
import rs.ltt.jmap.common.entity.Email
|
||||
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
|
||||
import rs.ltt.jmap.common.entity.query.EmailQuery
|
||||
import rs.ltt.jmap.common.method.call.email.GetEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.call.email.QueryChangesEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.GetEmailMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.email.QueryChangesEmailMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
|
||||
|
||||
class CommandSync(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val jmapClient: JmapClient,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val accountId: String,
|
||||
private val httpAuthentication: HttpAuthentication,
|
||||
) {
|
||||
|
||||
fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
try {
|
||||
val backendFolder = backendStorage.getFolder(folderServerId)
|
||||
listener.syncStarted(folderServerId)
|
||||
|
||||
val limit = if (backendFolder.visibleLimit > 0) backendFolder.visibleLimit.toLong() else null
|
||||
|
||||
val queryState = backendFolder.getFolderExtraString(EXTRA_QUERY_STATE)
|
||||
if (queryState == null) {
|
||||
fullSync(backendFolder, folderServerId, syncConfig, limit, listener)
|
||||
} else {
|
||||
deltaSync(backendFolder, folderServerId, syncConfig, limit, queryState, listener)
|
||||
}
|
||||
|
||||
listener.syncFinished(folderServerId)
|
||||
} catch (e: UnauthorizedException) {
|
||||
Log.e(e, "Authentication failure during sync")
|
||||
|
||||
val exception = AuthenticationFailedException(e.message ?: "Authentication failed", e)
|
||||
listener.syncFailed(folderServerId, "Authentication failed", exception)
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Unexpected failure during sync")
|
||||
|
||||
listener.syncFailed(folderServerId, "Unexpected failure", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fullSync(
|
||||
backendFolder: BackendFolder,
|
||||
folderServerId: String,
|
||||
syncConfig: SyncConfig,
|
||||
limit: Long?,
|
||||
listener: SyncListener,
|
||||
) {
|
||||
val cachedServerIds: Set<String> = backendFolder.getMessageServerIds()
|
||||
|
||||
if (limit != null) {
|
||||
Log.d("Fetching %d latest messages in %s (%s)", limit, backendFolder.name, folderServerId)
|
||||
} else {
|
||||
Log.d("Fetching all messages in %s (%s)", backendFolder.name, folderServerId)
|
||||
}
|
||||
|
||||
val queryEmailCall = jmapClient.call(
|
||||
QueryEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.query(createEmailQuery(folderServerId))
|
||||
.limit(limit)
|
||||
.build(),
|
||||
)
|
||||
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
|
||||
val queryState = if (queryEmailResponse.isCanCalculateChanges) queryEmailResponse.queryState else null
|
||||
val remoteServerIds = queryEmailResponse.ids.toSet()
|
||||
|
||||
val destroyServerIds = (cachedServerIds - remoteServerIds).toList()
|
||||
val newServerIds = remoteServerIds - cachedServerIds
|
||||
|
||||
handleFolderUpdates(backendFolder, folderServerId, destroyServerIds, newServerIds, queryState, listener)
|
||||
|
||||
val refreshServerIds = cachedServerIds.intersect(remoteServerIds)
|
||||
refreshMessageFlags(backendFolder, syncConfig, refreshServerIds)
|
||||
}
|
||||
|
||||
private fun createEmailQuery(folderServerId: String): EmailQuery? {
|
||||
val filter = EmailFilterCondition.builder()
|
||||
.inMailbox(folderServerId)
|
||||
.build()
|
||||
|
||||
// FIXME: Add sort parameter
|
||||
return EmailQuery.of(filter)
|
||||
}
|
||||
|
||||
private fun deltaSync(
|
||||
backendFolder: BackendFolder,
|
||||
folderServerId: String,
|
||||
syncConfig: SyncConfig,
|
||||
limit: Long?,
|
||||
queryState: String,
|
||||
listener: SyncListener,
|
||||
) {
|
||||
Log.d("Updating messages in %s (%s)", backendFolder.name, folderServerId)
|
||||
|
||||
val emailQuery = createEmailQuery(folderServerId)
|
||||
val queryChangesEmailCall = jmapClient.call(
|
||||
QueryChangesEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.sinceQueryState(queryState)
|
||||
.query(emailQuery)
|
||||
.build(),
|
||||
)
|
||||
|
||||
val queryChangesEmailResponse = try {
|
||||
queryChangesEmailCall.getMainResponseBlocking<QueryChangesEmailMethodResponse>()
|
||||
} catch (e: MethodErrorResponseException) {
|
||||
if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) {
|
||||
Log.d("Server responded with '$ERROR_CANNOT_CALCULATE_CHANGES'; switching to full sync")
|
||||
|
||||
backendFolder.saveQueryState(null)
|
||||
fullSync(backendFolder, folderServerId, syncConfig, limit, listener)
|
||||
return
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
|
||||
val cachedServerIds = backendFolder.getMessageServerIds()
|
||||
|
||||
val removedServerIds = queryChangesEmailResponse.removed.toSet()
|
||||
val addedServerIds = queryChangesEmailResponse.added.map { it.item }.toSet()
|
||||
val newQueryState = queryChangesEmailResponse.newQueryState
|
||||
|
||||
// An email can appear in both the 'removed' and the 'added' properties, e.g. when its position in the list
|
||||
// changes. But we don't want to remove a message from the database only to download it again right away.
|
||||
val retainedServerIds = removedServerIds.intersect(addedServerIds)
|
||||
val destroyServerIds = (removedServerIds - retainedServerIds).toList()
|
||||
val newServerIds = addedServerIds - retainedServerIds
|
||||
|
||||
handleFolderUpdates(backendFolder, folderServerId, destroyServerIds, newServerIds, newQueryState, listener)
|
||||
|
||||
val refreshServerIds = cachedServerIds - destroyServerIds
|
||||
refreshMessageFlags(backendFolder, syncConfig, refreshServerIds)
|
||||
}
|
||||
|
||||
private fun handleFolderUpdates(
|
||||
backendFolder: BackendFolder,
|
||||
folderServerId: String,
|
||||
destroyServerIds: List<String>,
|
||||
newServerIds: Set<String>,
|
||||
newQueryState: String?,
|
||||
listener: SyncListener,
|
||||
) {
|
||||
if (destroyServerIds.isNotEmpty()) {
|
||||
Log.d("Removing messages no longer on server: %s", destroyServerIds)
|
||||
backendFolder.destroyMessages(destroyServerIds)
|
||||
}
|
||||
|
||||
if (newServerIds.isEmpty()) {
|
||||
Log.d("No new messages on server")
|
||||
backendFolder.saveQueryState(newQueryState)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("New messages on server: %s", newServerIds)
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInGet = session.maxObjectsInGet
|
||||
val messageInfoList = fetchMessageInfo(session, maxObjectsInGet, newServerIds)
|
||||
|
||||
val total = messageInfoList.size
|
||||
messageInfoList.forEachIndexed { index, messageInfo ->
|
||||
Log.v("Downloading message %s (%s)", messageInfo.serverId, messageInfo.downloadUrl)
|
||||
val message = downloadMessage(messageInfo.downloadUrl)
|
||||
if (message != null) {
|
||||
message.apply {
|
||||
uid = messageInfo.serverId
|
||||
setInternalSentDate(messageInfo.receivedAt)
|
||||
setFlags(messageInfo.flags, true)
|
||||
}
|
||||
|
||||
backendFolder.saveMessage(message, MessageDownloadState.FULL)
|
||||
} else {
|
||||
Log.d("Failed to download message: %s", messageInfo.serverId)
|
||||
}
|
||||
|
||||
listener.syncProgress(folderServerId, index + 1, total)
|
||||
}
|
||||
|
||||
backendFolder.saveQueryState(newQueryState)
|
||||
}
|
||||
|
||||
private fun fetchMessageInfo(session: Session, maxObjectsInGet: Int, emailIds: Set<String>): List<MessageInfo> {
|
||||
return emailIds
|
||||
.chunked(maxObjectsInGet) { emailIdsChunk ->
|
||||
getEmailPropertiesFromServer(emailIdsChunk, INFO_PROPERTIES)
|
||||
}
|
||||
.flatten()
|
||||
.map { email ->
|
||||
email.toMessageInfo(session)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmailPropertiesFromServer(emailIdsChunk: List<String>, properties: Array<String>): List<Email> {
|
||||
val getEmailCall = jmapClient.call(
|
||||
GetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.ids(emailIdsChunk.toTypedArray())
|
||||
.properties(properties)
|
||||
.build(),
|
||||
)
|
||||
|
||||
val getEmailResponse = getEmailCall.getMainResponseBlocking<GetEmailMethodResponse>()
|
||||
return getEmailResponse.list.toList()
|
||||
}
|
||||
|
||||
private fun Email.toMessageInfo(session: Session): MessageInfo {
|
||||
val downloadUrl = session.getDownloadUrl(accountId, blobId, blobId, "application/octet-stream")
|
||||
return MessageInfo(id, downloadUrl, receivedAt, keywords.toFlags())
|
||||
}
|
||||
|
||||
private fun downloadMessage(downloadUrl: HttpUrl): MimeMessage? {
|
||||
val request = Request.Builder()
|
||||
.url(downloadUrl)
|
||||
.apply {
|
||||
httpAuthentication.authenticate(this)
|
||||
}
|
||||
.build()
|
||||
|
||||
return okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val inputStream = response.body!!.byteStream()
|
||||
MimeMessage.parseMimeMessage(inputStream, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMessageFlags(backendFolder: BackendFolder, syncConfig: SyncConfig, emailIds: Set<String>) {
|
||||
if (emailIds.isEmpty()) return
|
||||
|
||||
Log.v("Fetching flags for messages: %s", emailIds)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInGet = session.maxObjectsInGet
|
||||
|
||||
emailIds
|
||||
.asSequence()
|
||||
.chunked(maxObjectsInGet) { emailIdsChunk ->
|
||||
getEmailPropertiesFromServer(emailIdsChunk, FLAG_PROPERTIES)
|
||||
}
|
||||
.flatten()
|
||||
.forEach { email ->
|
||||
syncFlagsForMessage(backendFolder, syncConfig, email)
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncFlagsForMessage(backendFolder: BackendFolder, syncConfig: SyncConfig, email: Email) {
|
||||
val messageServerId = email.id
|
||||
val localFlags = backendFolder.getMessageFlags(messageServerId)
|
||||
val remoteFlags = email.keywords.toFlags()
|
||||
for (flag in syncConfig.syncFlags) {
|
||||
val flagSetOnServer = flag in remoteFlags
|
||||
val flagSetLocally = flag in localFlags
|
||||
if (flagSetOnServer != flagSetLocally) {
|
||||
backendFolder.setMessageFlag(messageServerId, flag, flagSetOnServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Boolean>?.toFlags(): Set<Flag> {
|
||||
return if (this == null) {
|
||||
emptySet()
|
||||
} else {
|
||||
filterValues { it }.keys
|
||||
.mapNotNull { keyword -> keyword.toFlag() }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toFlag(): Flag? = when (this) {
|
||||
"\$seen" -> Flag.SEEN
|
||||
"\$flagged" -> Flag.FLAGGED
|
||||
"\$draft" -> Flag.DRAFT
|
||||
"\$answered" -> Flag.ANSWERED
|
||||
"\$forwarded" -> Flag.FORWARDED
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun BackendFolder.saveQueryState(queryState: String?) {
|
||||
setFolderExtraString(EXTRA_QUERY_STATE, queryState)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_QUERY_STATE = "jmapQueryState"
|
||||
private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges"
|
||||
private val INFO_PROPERTIES = arrayOf("id", "blobId", "size", "receivedAt", "keywords")
|
||||
private val FLAG_PROPERTIES = arrayOf("id", "keywords")
|
||||
}
|
||||
}
|
||||
|
||||
private data class MessageInfo(
|
||||
val serverId: String,
|
||||
val downloadUrl: HttpUrl,
|
||||
val receivedAt: Date,
|
||||
val flags: Set<Flag>,
|
||||
)
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.squareup.moshi.Moshi
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.http.HttpAuthentication
|
||||
import rs.ltt.jmap.common.entity.EmailImport
|
||||
import rs.ltt.jmap.common.method.call.email.ImportEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.ImportEmailMethodResponse
|
||||
|
||||
class CommandUpload(
|
||||
private val jmapClient: JmapClient,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val httpAuthentication: HttpAuthentication,
|
||||
private val accountId: String,
|
||||
) {
|
||||
private val moshi = Moshi.Builder().build()
|
||||
|
||||
fun uploadMessage(folderServerId: String, message: Message): String? {
|
||||
Log.d("Uploading message to $folderServerId")
|
||||
|
||||
val uploadResponse = uploadMessageAsBlob(message)
|
||||
return importEmailBlob(uploadResponse, folderServerId)
|
||||
}
|
||||
|
||||
private fun uploadMessageAsBlob(message: Message): JmapUploadResponse {
|
||||
val session = jmapClient.session.get()
|
||||
val uploadUrl = session.getUploadUrl(accountId)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(uploadUrl)
|
||||
.post(MessageRequestBody(message))
|
||||
.apply {
|
||||
httpAuthentication.authenticate(this)
|
||||
}
|
||||
.build()
|
||||
|
||||
return okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw MessagingException("Uploading message as blob failed")
|
||||
}
|
||||
|
||||
response.body!!.source().use { source ->
|
||||
val adapter = moshi.adapter(JmapUploadResponse::class.java)
|
||||
val uploadResponse = adapter.fromJson(source)
|
||||
uploadResponse ?: throw MessagingException("Error reading upload response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun importEmailBlob(uploadResponse: JmapUploadResponse, folderServerId: String): String? {
|
||||
val importEmailRequest = ImportEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.email(
|
||||
LOCAL_EMAIL_ID,
|
||||
EmailImport.builder()
|
||||
.blobId(uploadResponse.blobId)
|
||||
.keywords(mapOf("\$seen" to true))
|
||||
.mailboxIds(mapOf(folderServerId to true))
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
val importEmailCall = jmapClient.call(importEmailRequest)
|
||||
val importEmailResponse = importEmailCall.getMainResponseBlocking<ImportEmailMethodResponse>()
|
||||
|
||||
return importEmailResponse.serverEmailId
|
||||
}
|
||||
|
||||
private val ImportEmailMethodResponse.serverEmailId
|
||||
get() = created?.get(LOCAL_EMAIL_ID)?.id
|
||||
|
||||
companion object {
|
||||
private const val LOCAL_EMAIL_ID = "t1"
|
||||
}
|
||||
}
|
||||
|
||||
private class MessageRequestBody(private val message: Message) : RequestBody() {
|
||||
override fun contentType(): MediaType? {
|
||||
return "message/rfc822".toMediaType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return message.calculateSize()
|
||||
}
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
message.writeTo(sink.outputStream())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import java.net.UnknownHostException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.api.EndpointNotFoundException
|
||||
import rs.ltt.jmap.client.api.UnauthorizedException
|
||||
import rs.ltt.jmap.common.entity.capability.MailAccountCapability
|
||||
|
||||
class JmapAccountDiscovery {
|
||||
fun discover(emailAddress: String, password: String): JmapDiscoveryResult {
|
||||
val jmapClient = JmapClient(emailAddress, password)
|
||||
val session = try {
|
||||
jmapClient.session.futureGetOrThrow()
|
||||
} catch (e: EndpointNotFoundException) {
|
||||
return JmapDiscoveryResult.EndpointNotFoundFailure
|
||||
} catch (e: UnknownHostException) {
|
||||
return JmapDiscoveryResult.EndpointNotFoundFailure
|
||||
} catch (e: UnauthorizedException) {
|
||||
return JmapDiscoveryResult.AuthenticationFailure
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Unable to get JMAP session")
|
||||
return JmapDiscoveryResult.GenericFailure(e)
|
||||
}
|
||||
|
||||
val accounts = session.getAccounts(MailAccountCapability::class.java)
|
||||
val accountId = when {
|
||||
accounts.isEmpty() -> return JmapDiscoveryResult.NoEmailAccountFoundFailure
|
||||
accounts.size == 1 -> accounts.keys.first()
|
||||
else -> session.getPrimaryAccount(MailAccountCapability::class.java)
|
||||
}
|
||||
|
||||
val account = accounts[accountId]!!
|
||||
val accountName = account.name ?: emailAddress
|
||||
return JmapDiscoveryResult.JmapAccount(accountId, accountName)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class JmapDiscoveryResult {
|
||||
class GenericFailure(val cause: Throwable) : JmapDiscoveryResult()
|
||||
object EndpointNotFoundFailure : JmapDiscoveryResult()
|
||||
object AuthenticationFailure : JmapDiscoveryResult()
|
||||
object NoEmailAccountFoundFailure : JmapDiscoveryResult()
|
||||
|
||||
data class JmapAccount(val accountId: String, val name: String) : JmapDiscoveryResult()
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.backend.api.BackendPusher
|
||||
import com.fsck.k9.backend.api.BackendPusherCallback
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Part
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.http.BasicAuthHttpAuthentication
|
||||
import rs.ltt.jmap.client.http.HttpAuthentication
|
||||
|
||||
class JmapBackend(
|
||||
backendStorage: BackendStorage,
|
||||
okHttpClient: OkHttpClient,
|
||||
config: JmapConfig,
|
||||
) : Backend {
|
||||
private val httpAuthentication = config.toHttpAuthentication()
|
||||
private val jmapClient = createJmapClient(config, httpAuthentication)
|
||||
private val accountId = config.accountId
|
||||
private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage, jmapClient, accountId)
|
||||
private val commandSync = CommandSync(backendStorage, jmapClient, okHttpClient, accountId, httpAuthentication)
|
||||
private val commandSetFlag = CommandSetFlag(jmapClient, accountId)
|
||||
private val commandDelete = CommandDelete(jmapClient, accountId)
|
||||
private val commandMove = CommandMove(jmapClient, accountId)
|
||||
private val commandUpload = CommandUpload(jmapClient, okHttpClient, httpAuthentication, accountId)
|
||||
override val supportsFlags = true
|
||||
override val supportsExpunge = false
|
||||
override val supportsMove = true
|
||||
override val supportsCopy = true
|
||||
override val supportsUpload = true
|
||||
override val supportsTrashFolder = true
|
||||
override val supportsSearchByDate = true
|
||||
override val supportsFolderSubscriptions = false // TODO: add support
|
||||
override val isPushCapable = false // FIXME
|
||||
|
||||
override fun refreshFolderList(): FolderPathDelimiter? {
|
||||
return commandRefreshFolderList.refreshFolderList()
|
||||
}
|
||||
|
||||
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
commandSync.sync(folderServerId, syncConfig, listener)
|
||||
}
|
||||
|
||||
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) {
|
||||
commandSetFlag.setFlag(messageServerIds, flag, newState)
|
||||
}
|
||||
|
||||
override fun markAllAsRead(folderServerId: String) {
|
||||
commandSetFlag.markAllAsRead(folderServerId)
|
||||
}
|
||||
|
||||
override fun expunge(folderServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
commandDelete.deleteMessages(messageServerIds)
|
||||
}
|
||||
|
||||
override fun deleteAllMessages(folderServerId: String) {
|
||||
commandDelete.deleteAllMessages(folderServerId)
|
||||
}
|
||||
|
||||
override fun moveMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>? {
|
||||
commandMove.moveMessages(targetFolderServerId, messageServerIds)
|
||||
return messageServerIds.associateWith { it }
|
||||
}
|
||||
|
||||
override fun moveMessagesAndMarkAsRead(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>? {
|
||||
commandMove.moveMessagesAndMarkAsRead(targetFolderServerId, messageServerIds)
|
||||
return messageServerIds.associateWith { it }
|
||||
}
|
||||
|
||||
override fun copyMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>,
|
||||
): Map<String, String>? {
|
||||
commandMove.copyMessages(targetFolderServerId, messageServerIds)
|
||||
return messageServerIds.associateWith { it }
|
||||
}
|
||||
|
||||
override fun search(
|
||||
folderServerId: String,
|
||||
query: String?,
|
||||
requiredFlags: Set<Flag>?,
|
||||
forbiddenFlags: Set<Flag>?,
|
||||
performFullTextSearch: Boolean,
|
||||
): List<String> {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun findByMessageId(folderServerId: String, messageId: String): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun uploadMessage(folderServerId: String, message: Message): String? {
|
||||
return commandUpload.uploadMessage(folderServerId, message)
|
||||
}
|
||||
|
||||
override fun sendMessage(message: Message) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
private fun JmapConfig.toHttpAuthentication(): HttpAuthentication {
|
||||
return BasicAuthHttpAuthentication(username, password)
|
||||
}
|
||||
|
||||
private fun createJmapClient(jmapConfig: JmapConfig, httpAuthentication: HttpAuthentication): JmapClient {
|
||||
return if (jmapConfig.baseUrl == null) {
|
||||
JmapClient(httpAuthentication)
|
||||
} else {
|
||||
val baseHttpUrl = jmapConfig.baseUrl.toHttpUrlOrNull()
|
||||
JmapClient(httpAuthentication, baseHttpUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
data class JmapConfig(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val baseUrl: String?,
|
||||
val accountId: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import java.util.concurrent.ExecutionException
|
||||
import rs.ltt.jmap.client.JmapRequest
|
||||
import rs.ltt.jmap.client.MethodResponses
|
||||
import rs.ltt.jmap.client.session.Session
|
||||
import rs.ltt.jmap.common.entity.capability.CoreCapability
|
||||
import rs.ltt.jmap.common.method.MethodResponse
|
||||
|
||||
internal const val MAX_CHUNK_SIZE = 5000
|
||||
|
||||
internal inline fun <reified T : MethodResponse> ListenableFuture<MethodResponses>.getMainResponseBlocking(): T {
|
||||
return futureGetOrThrow().getMain(T::class.java)
|
||||
}
|
||||
|
||||
internal inline fun <reified T : MethodResponse> JmapRequest.Call.getMainResponseBlocking(): T {
|
||||
return methodResponses.getMainResponseBlocking()
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
internal inline fun <T> ListenableFuture<T>.futureGetOrThrow(): T {
|
||||
return try {
|
||||
get()
|
||||
} catch (e: ExecutionException) {
|
||||
throw e.cause ?: e
|
||||
}
|
||||
}
|
||||
|
||||
internal val Session.maxObjectsInGet: Int
|
||||
get() {
|
||||
val coreCapability = getCapability(CoreCapability::class.java)
|
||||
return coreCapability.maxObjectsInGet.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
||||
internal val Session.maxObjectsInSet: Int
|
||||
get() {
|
||||
val coreCapability = getCapability(CoreCapability::class.java)
|
||||
return coreCapability.maxObjectsInSet.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class JmapUploadResponse(
|
||||
val accountId: String,
|
||||
val blobId: String,
|
||||
val type: String,
|
||||
val size: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import app.k9mail.backend.testing.InMemoryBackendStorage
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactlyInAnyOrder
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.fail
|
||||
import com.fsck.k9.backend.api.BackendFolderUpdater
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.backend.api.updateFolders
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.Test
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
|
||||
class CommandRefreshFolderListTest {
|
||||
private val backendStorage = InMemoryBackendStorage()
|
||||
|
||||
@Test
|
||||
fun sessionResourceWithAuthenticationError() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
MockResponse().setResponseCode(401),
|
||||
)
|
||||
|
||||
assertFailure {
|
||||
command.refreshFolderList()
|
||||
}.isInstanceOf<AuthenticationFailedException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidSessionResource() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
MockResponse().setBody("invalid"),
|
||||
)
|
||||
|
||||
assertFailure {
|
||||
command.refreshFolderList()
|
||||
}.isInstanceOf<MessagingException>()
|
||||
.transform { it.isPermanentFailure }.isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchMailboxes() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json"),
|
||||
)
|
||||
|
||||
command.refreshFolderList()
|
||||
|
||||
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1")
|
||||
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
|
||||
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
|
||||
assertFolderPresent("id_trash", "Trash", FolderType.TRASH)
|
||||
assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR)
|
||||
assertMailboxState("23")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchMailboxUpdates() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes.json"),
|
||||
)
|
||||
createFoldersInBackendStorage(state = "23")
|
||||
|
||||
command.refreshFolderList()
|
||||
|
||||
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2")
|
||||
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
|
||||
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
|
||||
assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH)
|
||||
assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR)
|
||||
assertMailboxState("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchMailboxUpdates_withHasMoreChanges() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_1.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_2.json"),
|
||||
)
|
||||
createFoldersInBackendStorage(state = "23")
|
||||
|
||||
command.refreshFolderList()
|
||||
|
||||
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2")
|
||||
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
|
||||
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
|
||||
assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH)
|
||||
assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR)
|
||||
assertMailboxState("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchMailboxUpdates_withCannotCalculateChangesError() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json"),
|
||||
)
|
||||
setMailboxState("unknownToServer")
|
||||
|
||||
command.refreshFolderList()
|
||||
|
||||
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1")
|
||||
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
|
||||
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
|
||||
assertFolderPresent("id_trash", "Trash", FolderType.TRASH)
|
||||
assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR)
|
||||
assertMailboxState("23")
|
||||
}
|
||||
|
||||
private fun createCommandRefreshFolderList(vararg mockResponses: MockResponse): CommandRefreshFolderList {
|
||||
val server = createMockWebServer(*mockResponses)
|
||||
return createCommandRefreshFolderList(server.url("/jmap/"))
|
||||
}
|
||||
|
||||
private fun createCommandRefreshFolderList(
|
||||
baseUrl: HttpUrl,
|
||||
accountId: String = "test@example.com",
|
||||
): CommandRefreshFolderList {
|
||||
val jmapClient = JmapClient("test", "test", baseUrl)
|
||||
return CommandRefreshFolderList(backendStorage, jmapClient, accountId)
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun createFoldersInBackendStorage(state: String) {
|
||||
backendStorage.updateFolders {
|
||||
createFolder("id_inbox", "Inbox", FolderType.INBOX)
|
||||
createFolder("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
createFolder("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
createFolder("id_sent", "Sent", FolderType.SENT)
|
||||
createFolder("id_trash", "Trash", FolderType.TRASH)
|
||||
createFolder("id_folder1", "folder1", FolderType.REGULAR)
|
||||
}
|
||||
setMailboxState(state)
|
||||
}
|
||||
|
||||
private fun BackendFolderUpdater.createFolder(serverId: String, name: String, type: FolderType) {
|
||||
createFolders(listOf(FolderInfo(serverId, name, type)))
|
||||
}
|
||||
|
||||
private fun setMailboxState(state: String) {
|
||||
backendStorage.setExtraString("jmapState", state)
|
||||
}
|
||||
|
||||
private fun assertFolderList(vararg folderServerIds: String) {
|
||||
assertThat(backendStorage.getFolderServerIds()).containsExactlyInAnyOrder(*folderServerIds)
|
||||
}
|
||||
|
||||
private fun assertFolderPresent(serverId: String, name: String, type: FolderType) {
|
||||
val folder = backendStorage.folders[serverId] ?: fail("Expected folder '$serverId' in BackendStorage")
|
||||
|
||||
assertThat(folder.name).isEqualTo(name)
|
||||
assertThat(folder.type).isEqualTo(type)
|
||||
}
|
||||
|
||||
private fun assertMailboxState(expected: String) {
|
||||
assertThat(backendStorage.getExtraString("jmapState")).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import app.k9mail.backend.testing.InMemoryBackendFolder
|
||||
import app.k9mail.backend.testing.InMemoryBackendStorage
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsOnly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
|
||||
import com.fsck.k9.backend.api.updateFolders
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.internet.BinaryTempFileBody
|
||||
import java.io.File
|
||||
import java.util.EnumSet
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.http.BasicAuthHttpAuthentication
|
||||
|
||||
class CommandSyncTest {
|
||||
private val backendStorage = InMemoryBackendStorage()
|
||||
private val okHttpClient = OkHttpClient.Builder().build()
|
||||
private val syncListener = LoggingSyncListener()
|
||||
private val syncConfig = SyncConfig(
|
||||
expungePolicy = ExpungePolicy.IMMEDIATELY,
|
||||
earliestPollDate = null,
|
||||
syncRemoteDeletions = true,
|
||||
maximumAutoDownloadMessageSize = 1000,
|
||||
defaultVisibleLimit = 25,
|
||||
syncFlags = EnumSet.of(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED),
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
BinaryTempFileBody.setTempDirectory(File(System.getProperty("java.io.tmpdir")))
|
||||
createFolderInBackendStorage()
|
||||
Log.logger = TestLogger()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionResourceWithAuthenticationError() {
|
||||
val command = createCommandSync(
|
||||
MockResponse().setResponseCode(401),
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(syncListener.getNextEvent()).isEqualTo(SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID))
|
||||
val failedEvent = syncListener.getNextEvent() as SyncListenerEvent.SyncFailed
|
||||
assertThat(failedEvent.exception).isNotNull().isInstanceOf<AuthenticationFailedException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncStartingWithEmptyLocalMailbox() {
|
||||
val server = createMockWebServer(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_M001_and_M002.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M001_and_M002.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_1.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_2.eml"),
|
||||
)
|
||||
val baseUrl = server.url("/jmap/")
|
||||
val command = createCommandSync(baseUrl)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.assertMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml",
|
||||
)
|
||||
backendFolder.assertQueryState("50:0")
|
||||
syncListener.assertSyncEvents(
|
||||
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
|
||||
SyncListenerEvent.SyncProgress(FOLDER_SERVER_ID, completed = 1, total = 2),
|
||||
SyncListenerEvent.SyncProgress(FOLDER_SERVER_ID, completed = 2, total = 2),
|
||||
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID),
|
||||
)
|
||||
server.skipRequests(3)
|
||||
server.assertRequestUrlPath("/jmap/download/test%40example.com/B001/B001?accept=application%2Foctet-stream")
|
||||
server.assertRequestUrlPath("/jmap/download/test%40example.com/B002/B002?accept=application%2Foctet-stream")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncExceedingMaxObjectsInGet() {
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/session_with_maxObjectsInGet_2.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_M001_to_M005.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M001_and_M002.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003_and_M004.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M005.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_1.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_2.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
assertThat(backendFolder.getMessageServerIds()).containsOnly(
|
||||
"M001",
|
||||
"M002",
|
||||
"M003",
|
||||
"M004",
|
||||
"M005",
|
||||
)
|
||||
syncListener.assertSyncSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncWithLocalMessagesAndDifferentMessagesInRemoteMailbox() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml",
|
||||
)
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_M002_and_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"),
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
backendFolder.assertMessages(
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml",
|
||||
"M003" to "/jmap_responses/blob/email/email_3.eml",
|
||||
)
|
||||
syncListener.assertSyncSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncWithLocalMessagesAndEmptyRemoteMailbox() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml",
|
||||
)
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_empty_result.json"),
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).isEmpty()
|
||||
syncListener.assertSyncEvents(
|
||||
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
|
||||
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deltaSyncWithoutChanges() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml",
|
||||
)
|
||||
backendFolder.setQueryState("50:0")
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_changes_empty_result.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M001_and_M002.json"),
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsOnly("M001", "M002")
|
||||
assertThat(backendFolder.getMessageFlags("M001")).isEmpty()
|
||||
assertThat(backendFolder.getMessageFlags("M002")).containsOnly(Flag.SEEN)
|
||||
backendFolder.assertQueryState("50:0")
|
||||
syncListener.assertSyncEvents(
|
||||
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
|
||||
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deltaSyncWithLocalMessagesAndDifferentMessagesInRemoteMailbox() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml",
|
||||
)
|
||||
backendFolder.setQueryState("50:0")
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_changes_M001_deleted_M003_added.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"),
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsOnly("M002", "M003")
|
||||
backendFolder.assertQueryState("51:0")
|
||||
syncListener.assertSyncSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deltaSyncCannotCalculateChanges() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml",
|
||||
)
|
||||
backendFolder.setQueryState("10:0")
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_changes_cannot_calculate_changes_error.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_M002_and_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"),
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertThat(backendFolder.getMessageServerIds()).containsOnly("M002", "M003")
|
||||
backendFolder.assertQueryState("50:0")
|
||||
syncListener.assertSyncSuccess()
|
||||
}
|
||||
|
||||
private fun createCommandSync(vararg mockResponses: MockResponse): CommandSync {
|
||||
val server = createMockWebServer(*mockResponses)
|
||||
return createCommandSync(server.url("/jmap/"))
|
||||
}
|
||||
|
||||
private fun createCommandSync(baseUrl: HttpUrl): CommandSync {
|
||||
val httpAuthentication = BasicAuthHttpAuthentication(USERNAME, PASSWORD)
|
||||
val jmapClient = JmapClient(httpAuthentication, baseUrl)
|
||||
return CommandSync(backendStorage, jmapClient, okHttpClient, ACCOUNT_ID, httpAuthentication)
|
||||
}
|
||||
|
||||
private fun createFolderInBackendStorage() {
|
||||
backendStorage.updateFolders {
|
||||
createFolders(listOf(FolderInfo(FOLDER_SERVER_ID, "Regular folder", FolderType.REGULAR)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MockWebServer.assertRequestUrlPath(expected: String) {
|
||||
val request = takeRequest()
|
||||
val requestUrl = request.requestUrl ?: error("No request URL")
|
||||
val requestUrlPath = requestUrl.encodedPath + "?" + requestUrl.encodedQuery
|
||||
assertThat(requestUrlPath).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun InMemoryBackendFolder.assertQueryState(expected: String) {
|
||||
assertThat(getFolderExtraString("jmapQueryState")).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun InMemoryBackendFolder.setQueryState(queryState: String) {
|
||||
setFolderExtraString("jmapQueryState", queryState)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOLDER_SERVER_ID = "id_folder"
|
||||
private const val USERNAME = "username"
|
||||
private const val PASSWORD = "password"
|
||||
private const val ACCOUNT_ID = "test@example.com"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.fail
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
|
||||
class LoggingSyncListener : SyncListener {
|
||||
private val events = mutableListOf<SyncListenerEvent>()
|
||||
|
||||
fun assertSyncSuccess() {
|
||||
events.filterIsInstance<SyncListenerEvent.SyncFailed>().firstOrNull()?.let { syncFailed ->
|
||||
throw AssertionError("Expected sync success", syncFailed.exception)
|
||||
}
|
||||
|
||||
if (events.none { it is SyncListenerEvent.SyncFinished }) {
|
||||
fail("Expected SyncFinished, but only got: $events")
|
||||
}
|
||||
}
|
||||
|
||||
fun assertSyncEvents(vararg events: SyncListenerEvent) {
|
||||
for (event in events) {
|
||||
assertThat(getNextEvent()).isEqualTo(event)
|
||||
}
|
||||
|
||||
assertThat(this.events).isEmpty()
|
||||
}
|
||||
|
||||
fun getNextEvent(): SyncListenerEvent {
|
||||
require(events.isNotEmpty()) { "No events left" }
|
||||
return events.removeAt(0)
|
||||
}
|
||||
|
||||
override fun syncStarted(folderServerId: String) {
|
||||
events.add(SyncListenerEvent.SyncStarted(folderServerId))
|
||||
}
|
||||
|
||||
override fun syncAuthenticationSuccess() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncHeadersStarted(folderServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncHeadersProgress(folderServerId: String, completed: Int, total: Int) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncHeadersFinished(folderServerId: String, totalMessagesInMailbox: Int, numNewMessages: Int) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncProgress(folderServerId: String, completed: Int, total: Int) {
|
||||
events.add(SyncListenerEvent.SyncProgress(folderServerId, completed, total))
|
||||
}
|
||||
|
||||
override fun syncNewMessage(folderServerId: String, messageServerId: String, isOldMessage: Boolean) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncRemovedMessage(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncFlagChanged(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncFinished(folderServerId: String) {
|
||||
events.add(SyncListenerEvent.SyncFinished(folderServerId))
|
||||
}
|
||||
|
||||
override fun syncFailed(folderServerId: String, message: String, exception: Exception?) {
|
||||
events.add(SyncListenerEvent.SyncFailed(folderServerId, message, exception))
|
||||
}
|
||||
|
||||
override fun folderStatusChanged(folderServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SyncListenerEvent {
|
||||
data class SyncStarted(val folderServerId: String) : SyncListenerEvent()
|
||||
data class SyncFinished(val folderServerId: String) : SyncListenerEvent()
|
||||
data class SyncFailed(
|
||||
val folderServerId: String,
|
||||
val message: String,
|
||||
val exception: Exception?,
|
||||
) : SyncListenerEvent()
|
||||
|
||||
data class SyncProgress(val folderServerId: String, val completed: Int, val total: Int) : SyncListenerEvent()
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import java.io.InputStream
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
|
||||
fun createMockWebServer(vararg mockResponses: MockResponse): MockWebServer {
|
||||
return MockWebServer().apply {
|
||||
for (mockResponse in mockResponses) {
|
||||
enqueue(mockResponse)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun responseBodyFromResource(name: String): MockResponse {
|
||||
return MockResponse().setBody(loadResource(name))
|
||||
}
|
||||
|
||||
fun MockWebServer.skipRequests(count: Int) {
|
||||
repeat(count) {
|
||||
takeRequest()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadResource(name: String): String {
|
||||
val resourceAsStream = ResourceLoader.getResourceAsStream(name) ?: error("Couldn't load resource: $name")
|
||||
return resourceAsStream.use { it.source().buffer().readUtf8() }
|
||||
}
|
||||
|
||||
private object ResourceLoader {
|
||||
fun getResourceAsStream(name: String): InputStream? = javaClass.getResourceAsStream(name)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
From: alice@domain.example
|
||||
To: bob@domain.example
|
||||
Message-ID: <message001@domain.example>
|
||||
Date: Mon, 10 Feb 2020 10:20:30 +0100
|
||||
Subject: Hello there
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
Hi Bob,
|
||||
|
||||
this is a message from me to you.
|
||||
|
||||
Cheers,
|
||||
Alice
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
From: Bob <bob@domain.example>
|
||||
To: alice@domain.example
|
||||
Message-ID: <message002@domain.example>
|
||||
In-Reply-To: <message001@domain.example>
|
||||
References: <message001@domain.example>
|
||||
Date: Mon, 10 Feb 2020 10:20:30 +0100
|
||||
Subject: Re: Hello there
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
Hi Alice,
|
||||
|
||||
I've received your message.
|
||||
|
||||
Best,
|
||||
Bob
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
From: alice@domain.example
|
||||
To: alice@domain.example
|
||||
Message-ID: <message003@domain.example>
|
||||
Date: Mon, 10 Feb 2020 12:20:30 +0100
|
||||
Subject: Dummy
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
-
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M001",
|
||||
"blobId": "B001",
|
||||
"keywords": {},
|
||||
"size": 280,
|
||||
"receivedAt": "2020-02-11T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "M002",
|
||||
"blobId": "B002",
|
||||
"keywords": {
|
||||
"$seen": true
|
||||
},
|
||||
"size": 365,
|
||||
"receivedAt": "2020-01-11T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue