Repo created
This commit is contained in:
parent
a629de6271
commit
3cef7c5092
2161 changed files with 246605 additions and 2 deletions
9
backend/api/build.gradle.kts
Normal file
9
backend/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.mail.common)
|
||||
}
|
||||
101
backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt
Normal file
101
backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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.MessagingException
|
||||
import com.fsck.k9.mail.Part
|
||||
|
||||
interface Backend {
|
||||
val supportsFlags: Boolean
|
||||
val supportsExpunge: Boolean
|
||||
val supportsMove: Boolean
|
||||
val supportsCopy: Boolean
|
||||
val supportsUpload: Boolean
|
||||
val supportsTrashFolder: Boolean
|
||||
val supportsSearchByDate: Boolean
|
||||
val isPushCapable: Boolean
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun refreshFolderList()
|
||||
|
||||
// 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 expungeMessages(folderServerId: String, messageServerIds: List<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 checkIncomingServerSettings()
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun sendMessage(message: Message)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun checkOutgoingServerSettings()
|
||||
|
||||
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,27 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import java.io.Closeable
|
||||
|
||||
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 {
|
||||
fun createFolders(folders: List<FolderInfo>)
|
||||
fun deleteFolders(folderServerIds: List<String>)
|
||||
fun changeFolder(folderServerId: String, name: String, type: FolderType)
|
||||
}
|
||||
|
||||
inline fun BackendStorage.updateFolders(block: BackendFolderUpdater.() -> Unit) {
|
||||
createFolderUpdater().use { it.block() }
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.backend.api
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
|
||||
data class FolderInfo(val serverId: String, val name: String, val type: FolderType)
|
||||
|
|
@ -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)
|
||||
}
|
||||
16
backend/demo/build.gradle.kts
Normal file
16
backend/demo/build.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.backend.api)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.moshi)
|
||||
ksp(libs.moshi.kotlin.codegen)
|
||||
|
||||
testImplementation(projects.mail.testing)
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.backend.api.BackendFolder.MoreMessages
|
||||
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.FolderInfo
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.backend.api.updateFolders
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
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.Part
|
||||
import com.fsck.k9.mail.internet.MimeMessage
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
|
||||
class DemoBackend(private val backendStorage: BackendStorage) : Backend {
|
||||
private val messageStoreInfo by lazy { readMessageStoreInfo() }
|
||||
|
||||
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 isPushCapable: Boolean = false
|
||||
|
||||
override fun refreshFolderList() {
|
||||
val localFolderServerIds = backendStorage.getFolderServerIds().toSet()
|
||||
|
||||
backendStorage.updateFolders {
|
||||
val remoteFolderServerIds = messageStoreInfo.keys
|
||||
val foldersServerIdsToCreate = remoteFolderServerIds - localFolderServerIds
|
||||
val foldersToCreate = foldersServerIdsToCreate.mapNotNull { folderServerId ->
|
||||
messageStoreInfo[folderServerId]?.let { folderData ->
|
||||
FolderInfo(folderServerId, folderData.name, folderData.type)
|
||||
}
|
||||
}
|
||||
createFolders(foldersToCreate)
|
||||
|
||||
val folderServerIdsToRemove = (localFolderServerIds - remoteFolderServerIds).toList()
|
||||
deleteFolders(folderServerIdsToRemove)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
listener.syncStarted(folderServerId)
|
||||
|
||||
val folderData = messageStoreInfo[folderServerId]
|
||||
if (folderData == 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 folderData.messageServerIds) {
|
||||
val message = loadMessage(folderServerId, messageServerId)
|
||||
backendFolder.saveMessage(message, MessageDownloadState.FULL)
|
||||
listener.syncNewMessage(folderServerId, messageServerId, isOldMessage = false)
|
||||
}
|
||||
|
||||
backendFolder.setMoreMessages(MoreMessages.FALSE)
|
||||
|
||||
listener.syncFinished(folderServerId)
|
||||
}
|
||||
|
||||
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 expungeMessages(folderServerId: String, messageServerIds: List<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 checkIncomingServerSettings() = Unit
|
||||
|
||||
override fun checkOutgoingServerSettings() = Unit
|
||||
|
||||
override fun sendMessage(message: Message) {
|
||||
val inboxServerId = messageStoreInfo.filterValues { it.type == FolderType.INBOX }.keys.first()
|
||||
val backendFolder = backendStorage.getFolder(inboxServerId)
|
||||
|
||||
val newMessage = message.copy(uid = createNewServerId())
|
||||
backendFolder.saveMessage(newMessage, MessageDownloadState.FULL)
|
||||
}
|
||||
|
||||
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
private fun createNewServerId() = UUID.randomUUID().toString()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
private fun readMessageStoreInfo(): MessageStoreInfo {
|
||||
return getResourceAsStream("/contents.json").source().buffer().use { bufferedSource ->
|
||||
val moshi = Moshi.Builder().build()
|
||||
val adapter = moshi.adapter<MessageStoreInfo>()
|
||||
adapter.fromJson(bufferedSource)
|
||||
} ?: error("Couldn't read message store info")
|
||||
}
|
||||
|
||||
private 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 DemoBackend::class.java.getResourceAsStream(name) ?: error("Resource '$name' not found")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package app.k9mail.backend.demo
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
typealias MessageStoreInfo = Map<String, FolderData>
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FolderData(
|
||||
val name: String,
|
||||
val type: FolderType,
|
||||
val messageServerIds: List<String>
|
||||
)
|
||||
60
backend/demo/src/main/resources/contents.json
Normal file
60
backend/demo/src/main/resources/contents.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"inbox": {
|
||||
"name": "Inbox",
|
||||
"type": "INBOX",
|
||||
"messageServerIds": [
|
||||
"intro",
|
||||
"many_recipients",
|
||||
"thread_1",
|
||||
"thread_2",
|
||||
"inline_image_data_uri",
|
||||
"inline_image_attachment"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
MIME-Version: 1.0
|
||||
From: "Test data" <data@k9mail.example>
|
||||
Date: Tue, 14 Feb 2023 15:00:00 +0100
|
||||
Message-ID: <inbox-6@k9mail.example>
|
||||
Subject: Inline image attachment
|
||||
To: User <user@k9mail.example>
|
||||
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@k9mail.example">
|
||||
</body>
|
||||
</html>
|
||||
--BOUNDARY
|
||||
Content-Type: image/png; name="k9mail.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <part1@k9mail.example>
|
||||
Content-Disposition: inline; filename="k9mail.png"
|
||||
|
||||
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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: "cketti" <cketti@k9mail.example>
|
||||
Date: Thu, 23 Sep 2021 23:42:00 +0200
|
||||
Message-ID: <hello-1-2-3@k9mail.example>
|
||||
Subject: Welcome to K-9 Mail
|
||||
To: User <user@k9mail.example>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Congratulations, you have managed to set up K-9 Mail's demo account. Have fun exploring the app.
|
||||
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@k9mail.example>, "Bob" <from2@k9mail.example>
|
||||
Sender: "Bernd" <sender@k9mail.example>
|
||||
Reply-To: <reply-to@k9mail.example>
|
||||
Date: Mon, 23 Jan 2023 12:00:00 +0100
|
||||
Message-ID: <inbox-2@k9mail.example>
|
||||
Subject: Message details demo
|
||||
To: "User 1" <to1@k9mail.example>,
|
||||
"User 2" <to2@k9mail.example>,
|
||||
"User 3" <to3@k9mail.example>,
|
||||
"User 4" <to4@k9mail.example>,
|
||||
"User 5" <to5@k9mail.example>,
|
||||
"User 6" <to6@k9mail.example>,
|
||||
"User 7" <to7@k9mail.example>,
|
||||
"User 8" <to8@k9mail.example>,
|
||||
"User 9" <to9@k9mail.example>,
|
||||
"User 10" <to10@k9mail.example>,
|
||||
"User 11" <to11@k9mail.example>,
|
||||
"User 12" <to12@k9mail.example>,
|
||||
"User 13" <to13@k9mail.example>,
|
||||
"User 14" <to14@k9mail.example>,
|
||||
"User 15" <to15@k9mail.example>,
|
||||
"User 16" <to16@k9mail.example>,
|
||||
"User 17" <to17@k9mail.example>,
|
||||
"User 18" <to18@k10mail.example>,
|
||||
"User 19" <to19@k11mail.example>,
|
||||
"User 20" <to20@k12mail.example>
|
||||
Cc: "Copy 1" <cc1@k9mail.example>,
|
||||
"Copy 2" <cc2@k9mail.example>,
|
||||
"Copy 3" <cc3@k9mail.example>
|
||||
Bcc: "Blind 1" <bcc1@k9mail.example>,
|
||||
"Blind 2" <bcc2@k9mail.example>,
|
||||
"Blind 3" <bcc3@k9mail.example>
|
||||
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@k9mail.example>
|
||||
Date: Fri, 10 Feb 2023 10:00:00 +0100
|
||||
Message-ID: <thread-1@k9mail.example>
|
||||
Subject: Thread
|
||||
To: Bob <bob@k9mail.example>
|
||||
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@k9mail.example>
|
||||
Date: Fri, 10 Feb 2023 10:05:00 +0100
|
||||
Message-ID: <thread-2@k9mail.example>
|
||||
Subject: Re: Thread
|
||||
To: Alice <alice@k9mail.example>
|
||||
In-Reply-To: <thread-1@k9mail.example>
|
||||
References: <thread-2@k9mail.example>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
This is the second message in this thread.
|
||||
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@cketti.de>
|
||||
Subject: The Synthesis of Algorithmic Systems
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Computers Then and Now
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: One Man's View of Computer Science
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Some Comments from a Numerical Analyst
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Generality in Artificial Intelligence
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: The Humble Programmer
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Computer Science as Empirical Inquiry: Symbols and Search
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Can Programming Be Liberated from the von Neumann Style? A Functional
|
||||
Style and Its Algebra of Programs
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: The Paradigms of Programming
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Notation as a Tool of Thought
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Relational Database: A Practical Foundation for Productivity
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Reflections on Software Research
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: The Search for Performance in Scientific Processors
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Elements of Interaction
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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@cketti.de>
|
||||
Subject: Verification Engineering: A Future Profession
|
||||
To: Alan Turing <alan@turing.example>
|
||||
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--
|
||||
17
backend/imap/build.gradle.kts
Normal file
17
backend/imap/build.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.backend.api)
|
||||
api(projects.mail.protocols.imap)
|
||||
api(projects.mail.protocols.smtp)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
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,24 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandDeleteAll(private val imapStore: ImapStore) {
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun deleteAll(folderServerId: String) {
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
if (!remoteFolder.exists()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
remoteFolder.setFlags(setOf(Flag.DELETED), true)
|
||||
} 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,40 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
internal class CommandExpunge(private val imapStore: ImapStore) {
|
||||
|
||||
fun expunge(folderServerId: String) {
|
||||
Timber.d("processPendingExpunge: folder = %s", folderServerId)
|
||||
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
if (!remoteFolder.exists()) return
|
||||
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
if (remoteFolder.mode != OpenMode.READ_WRITE) return
|
||||
|
||||
remoteFolder.expunge()
|
||||
|
||||
Timber.d("processPendingExpunge: complete for folder = %s", folderServerId)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun expungeMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
val remoteFolder = imapStore.getFolder(folderServerId)
|
||||
try {
|
||||
if (!remoteFolder.exists()) return
|
||||
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
if (remoteFolder.mode != OpenMode.READ_WRITE) return
|
||||
|
||||
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,22 @@
|
|||
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)
|
||||
if (!remoteFolder.exists()) return
|
||||
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
if (remoteFolder.mode != OpenMode.READ_WRITE) return
|
||||
|
||||
remoteFolder.setFlags(setOf(Flag.SEEN), true)
|
||||
} finally {
|
||||
remoteFolder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.fsck.k9.mail.store.imap.ImapFolder
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.OpenMode
|
||||
|
||||
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()) {
|
||||
Timber.i("moveOrCopyMessages: no remote messages to move, skipping")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!remoteSrcFolder.exists()) {
|
||||
throw MessagingException("moveOrCopyMessages: remoteFolder $srcFolder does not exist", true)
|
||||
}
|
||||
|
||||
remoteSrcFolder.open(OpenMode.READ_WRITE)
|
||||
if (remoteSrcFolder.mode != OpenMode.READ_WRITE) {
|
||||
throw MessagingException(
|
||||
"moveOrCopyMessages: could not open remoteSrcFolder $srcFolder read/write",
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
val messages = uids.map { uid -> remoteSrcFolder.getMessage(uid) }
|
||||
|
||||
Timber.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,47 @@
|
|||
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
|
||||
|
||||
internal class CommandRefreshFolderList(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val imapStore: ImapStore
|
||||
) {
|
||||
fun refreshFolderList() {
|
||||
// TODO: Start using the proper server ID.
|
||||
// For now we still use the old server ID format (decoded, with prefix removed).
|
||||
val foldersOnServer = imapStore.getFolders().toLegacyFolderList()
|
||||
val oldFolderServerIds = backendStorage.getFolderServerIds()
|
||||
|
||||
backendStorage.updateFolders {
|
||||
val foldersToCreate = mutableListOf<FolderInfo>()
|
||||
for (folder in foldersOnServer) {
|
||||
if (folder.serverId !in oldFolderServerIds) {
|
||||
foldersToCreate.add(FolderInfo(folder.serverId, folder.name, folder.type))
|
||||
} else {
|
||||
changeFolder(folder.serverId, folder.name, folder.type)
|
||||
}
|
||||
}
|
||||
createFolders(foldersToCreate)
|
||||
|
||||
val newFolderServerIds = foldersOnServer.map { it.serverId }
|
||||
val removedFolderServerIds = oldFolderServerIds - newFolderServerIds
|
||||
deleteFolders(removedFolderServerIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<FolderListItem>.toLegacyFolderList(): List<LegacyFolderListItem> {
|
||||
return this.filterNot { it.oldServerId == null }
|
||||
.map { LegacyFolderListItem(it.oldServerId!!, it.name, it.type) }
|
||||
}
|
||||
|
||||
private data class LegacyFolderListItem(
|
||||
val serverId: String,
|
||||
val name: String,
|
||||
val type: FolderType
|
||||
)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
|
||||
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 {
|
||||
return folder.search(query, requiredFlags, forbiddenFlags, performFullTextSearch)
|
||||
.sortedWith(UidReverseComparator())
|
||||
.map { it.uid }
|
||||
} finally {
|
||||
folder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
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)
|
||||
if (!remoteFolder.exists()) return
|
||||
|
||||
try {
|
||||
remoteFolder.open(OpenMode.READ_WRITE)
|
||||
if (remoteFolder.mode != OpenMode.READ_WRITE) return
|
||||
|
||||
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,162 @@
|
|||
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
|
||||
|
||||
class ImapBackend(
|
||||
private val accountName: String,
|
||||
backendStorage: BackendStorage,
|
||||
private 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 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 isPushCapable = true
|
||||
|
||||
override fun refreshFolderList() {
|
||||
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 expungeMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
commandExpunge.expungeMessages(folderServerId, messageServerIds)
|
||||
}
|
||||
|
||||
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
commandSetFlag.setFlag(folderServerId, messageServerIds, Flag.DELETED, true)
|
||||
}
|
||||
|
||||
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 checkIncomingServerSettings() {
|
||||
imapStore.checkSettings()
|
||||
}
|
||||
|
||||
override fun sendMessage(message: Message) {
|
||||
smtpTransport.sendMessage(message)
|
||||
}
|
||||
|
||||
override fun checkOutgoingServerSettings() {
|
||||
smtpTransport.checkSettings()
|
||||
}
|
||||
|
||||
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.logging.Timber
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
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
|
||||
|
||||
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) {
|
||||
Timber.v("ImapBackendPusher.updateFolders(): %s", folderServerIds)
|
||||
|
||||
val pushFolderServerIds = if (folderServerIds.size > maxPushFolders) {
|
||||
folderServerIds.take(maxPushFolders).also { pushFolderServerIds ->
|
||||
Timber.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() {
|
||||
Timber.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() {
|
||||
Timber.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 -> {
|
||||
Timber.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 -> {
|
||||
Timber.v(exception, "I/O error while trying to use IDLE")
|
||||
|
||||
startRetryTimer(folderServerId, IO_ERROR_TIMEOUT)
|
||||
}
|
||||
is MessagingException -> {
|
||||
Timber.v(exception, "MessagingException")
|
||||
|
||||
if (exception.isPermanentFailure) {
|
||||
startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT)
|
||||
} else {
|
||||
startRetryTimer(folderServerId, IO_ERROR_TIMEOUT)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Timber.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) {
|
||||
Timber.v("ImapBackendPusher for folder %s sleeping for %d ms", folderServerId, timeout)
|
||||
pushFolderSleeping[folderServerId] = idleRefreshManager.startTimer(timeout, ::restartFolderPushers)
|
||||
}
|
||||
|
||||
private fun cancelRetryTimer(folderServerId: String) {
|
||||
Timber.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() {
|
||||
Timber.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.logging.Timber
|
||||
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
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
Timber.v("Starting ImapFolderPusher for %s / %s", accountName, folderServerId)
|
||||
|
||||
thread(name = "ImapFolderPusher-$accountName-$folderServerId") {
|
||||
Timber.v("Starting ImapFolderPusher thread for %s / %s", accountName, folderServerId)
|
||||
|
||||
runPushLoop()
|
||||
|
||||
Timber.v("Exiting ImapFolderPusher thread for %s / %s", accountName, folderServerId)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
Timber.v("Refreshing ImapFolderPusher for %s / %s", accountName, folderServerId)
|
||||
|
||||
folderIdler?.refresh()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Timber.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) {
|
||||
Timber.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()
|
||||
}
|
||||
729
backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapSync.kt
Normal file
729
backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapSync.kt
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
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.logging.Timber
|
||||
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
|
||||
|
||||
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) {
|
||||
Timber.i("Synchronizing folder %s:%s", accountName, folder)
|
||||
|
||||
var remoteFolder: ImapFolder? = null
|
||||
var backendFolder: BackendFolder? = null
|
||||
var newHighestKnownUid: Long = 0
|
||||
try {
|
||||
Timber.v("SYNC: About to get local folder %s", folder)
|
||||
|
||||
backendFolder = backendStorage.getFolder(folder)
|
||||
|
||||
listener.syncStarted(folder)
|
||||
|
||||
Timber.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.
|
||||
*/
|
||||
Timber.v("SYNC: About to open remote folder %s", folder)
|
||||
|
||||
if (syncConfig.expungePolicy === ExpungePolicy.ON_POLL) {
|
||||
Timber.d("SYNC: Expunging folder %s:%s", accountName, folder)
|
||||
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) {
|
||||
Timber.d("SYNC: Saving UIDVALIDITY for %s", folder)
|
||||
backendFolder.setFolderExtraNumber(EXTRA_UID_VALIDITY, uidValidity)
|
||||
} else if (oldUidValidity != null && oldUidValidity != uidValidity) {
|
||||
Timber.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>()
|
||||
|
||||
Timber.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
|
||||
}
|
||||
|
||||
Timber.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
|
||||
}
|
||||
}
|
||||
|
||||
Timber.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)
|
||||
|
||||
Timber.d("Done synchronizing folder %s:%s @ %tc", accountName, folder, System.currentTimeMillis())
|
||||
|
||||
listener.syncFinished(folder)
|
||||
|
||||
Timber.i("Done synchronizing folder %s:%s", accountName, folder)
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
listener.syncFailed(folder, "Authentication failure", e)
|
||||
} catch (e: Exception) {
|
||||
Timber.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) {
|
||||
Timber.e(e, "Could not set last checked on folder %s:%s", accountName, folder)
|
||||
}
|
||||
}
|
||||
|
||||
listener.syncFailed(folder, rootMessage, e)
|
||||
|
||||
Timber.e(
|
||||
"Failed synchronizing folder %s:%s @ %tc",
|
||||
accountName,
|
||||
folder,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
} finally {
|
||||
if (newHighestKnownUid > 0 && backendFolder != null) {
|
||||
Timber.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)
|
||||
|
||||
Timber.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)
|
||||
}
|
||||
|
||||
Timber.d("SYNC: About to fetch %d unsynced messages for folder %s", unsyncedMessages.size, folder)
|
||||
|
||||
fetchUnsyncedMessages(
|
||||
syncConfig,
|
||||
remoteFolder,
|
||||
unsyncedMessages,
|
||||
smallMessages,
|
||||
largeMessages,
|
||||
progress,
|
||||
todo,
|
||||
listener
|
||||
)
|
||||
|
||||
Timber.d("SYNC: Synced unsynced messages for folder %s", folder)
|
||||
}
|
||||
|
||||
Timber.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)
|
||||
|
||||
Timber.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)) {
|
||||
Timber.v("Message with uid %s is marked as deleted", messageServerId)
|
||||
syncFlagMessages.add(message)
|
||||
return
|
||||
}
|
||||
|
||||
val messagePresentLocally = backendFolder.isMessagePresent(messageServerId)
|
||||
if (!messagePresentLocally) {
|
||||
Timber.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)) {
|
||||
Timber.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)) {
|
||||
Timber.v("Message with uid %s is not downloaded, even partially; trying again", messageServerId)
|
||||
unsyncedMessages.add(message)
|
||||
} else {
|
||||
syncFlagMessages.add(message)
|
||||
}
|
||||
} else {
|
||||
Timber.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) {
|
||||
Timber.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)) {
|
||||
Timber.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) {
|
||||
Timber.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)
|
||||
}
|
||||
|
||||
Timber.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
|
||||
Timber.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) {
|
||||
Timber.e(e, "SYNC: fetch small messages")
|
||||
}
|
||||
}
|
||||
},
|
||||
-1
|
||||
)
|
||||
|
||||
Timber.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)
|
||||
}
|
||||
|
||||
Timber.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
|
||||
Timber.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)
|
||||
}
|
||||
|
||||
Timber.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
|
||||
Timber.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,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,330 @@
|
|||
package com.fsck.k9.backend.imap
|
||||
|
||||
import app.k9mail.backend.testing.InMemoryBackendStorage
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsAll
|
||||
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.buildMessage
|
||||
import com.fsck.k9.mail.store.imap.FetchListener
|
||||
import com.fsck.k9.mail.store.imap.ImapMessage
|
||||
import java.util.Date
|
||||
import org.apache.james.mime4j.dom.field.DateTimeField
|
||||
import org.apache.james.mime4j.field.DefaultFieldParser
|
||||
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()
|
||||
|
||||
@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")).containsAll(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,179 @@
|
|||
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.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 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 setFlags(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 expunge() {
|
||||
mode = OpenMode.READ_WRITE
|
||||
wasExpunged = true
|
||||
}
|
||||
|
||||
override fun expungeUids(uids: List<String>) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
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>()
|
||||
|
||||
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,
|
||||
oldServerId = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun checkSettings() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun closeAllConnections() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
fun createImapMessage(uid: String) = ImapMessage(uid)
|
||||
19
backend/jmap/build.gradle.kts
Normal file
19
backend/jmap/build.gradle.kts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.backend.api)
|
||||
|
||||
api(libs.okhttp)
|
||||
implementation(libs.jmap.client)
|
||||
implementation(libs.moshi)
|
||||
ksp(libs.moshi.kotlin.codegen)
|
||||
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(projects.backend.testing)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
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>) {
|
||||
Timber.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) {
|
||||
Timber.d("Deleting all messages from %s", folderServerId)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val limit = session.maxObjectsInSet.coerceAtMost(MAX_CHUNK_SIZE).toLong()
|
||||
|
||||
do {
|
||||
Timber.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>()
|
||||
|
||||
Timber.v("Deleted %d messages from %s", numberOfReturnedEmails, folderServerId)
|
||||
} while (totalNumberOfEmails > numberOfReturnedEmails)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
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>) {
|
||||
Timber.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>) {
|
||||
Timber.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>) {
|
||||
Timber.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,165 @@
|
|||
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 com.fsck.k9.mail.MessagingException
|
||||
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
|
||||
) {
|
||||
fun refreshFolderList() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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.logging.Timber
|
||||
import com.fsck.k9.mail.Flag
|
||||
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) {
|
||||
Timber.v("Setting flag %s for messages %s", flag, messageServerIds)
|
||||
} else {
|
||||
Timber.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) {
|
||||
Timber.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 {
|
||||
Timber.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) {
|
||||
Timber.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>()
|
||||
|
||||
Timber.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.logging.Timber
|
||||
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 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) {
|
||||
Timber.e(e, "Authentication failure during sync")
|
||||
|
||||
val exception = AuthenticationFailedException(e.message ?: "Authentication failed", e)
|
||||
listener.syncFailed(folderServerId, "Authentication failed", exception)
|
||||
} catch (e: Exception) {
|
||||
Timber.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) {
|
||||
Timber.d("Fetching %d latest messages in %s (%s)", limit, backendFolder.name, folderServerId)
|
||||
} else {
|
||||
Timber.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
|
||||
) {
|
||||
Timber.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) {
|
||||
Timber.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()) {
|
||||
Timber.d("Removing messages no longer on server: %s", destroyServerIds)
|
||||
backendFolder.destroyMessages(destroyServerIds)
|
||||
}
|
||||
|
||||
if (newServerIds.isEmpty()) {
|
||||
Timber.d("No new messages on server")
|
||||
backendFolder.saveQueryState(newQueryState)
|
||||
return
|
||||
}
|
||||
|
||||
Timber.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 ->
|
||||
Timber.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 {
|
||||
Timber.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
|
||||
|
||||
Timber.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.logging.Timber
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.squareup.moshi.Moshi
|
||||
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? {
|
||||
Timber.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 com.fsck.k9.logging.Timber
|
||||
import java.net.UnknownHostException
|
||||
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) {
|
||||
Timber.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,160 @@
|
|||
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 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
|
||||
import rs.ltt.jmap.common.method.call.core.EchoMethodCall
|
||||
|
||||
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 isPushCapable = false // FIXME
|
||||
|
||||
override fun refreshFolderList() {
|
||||
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 expungeMessages(folderServerId: String, messageServerIds: List<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 checkIncomingServerSettings() {
|
||||
jmapClient.call(EchoMethodCall()).get()
|
||||
}
|
||||
|
||||
override fun sendMessage(message: Message) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun checkOutgoingServerSettings() {
|
||||
checkIncomingServerSettings()
|
||||
}
|
||||
|
||||
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,179 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import app.k9mail.backend.testing.InMemoryBackendStorage
|
||||
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 com.fsck.k9.mail.MessagingException
|
||||
import junit.framework.AssertionFailedError
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
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)
|
||||
)
|
||||
|
||||
try {
|
||||
command.refreshFolderList()
|
||||
fail("Expected exception")
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidSessionResource() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
MockResponse().setBody("invalid")
|
||||
)
|
||||
|
||||
try {
|
||||
command.refreshFolderList()
|
||||
fail("Expected exception")
|
||||
} catch (e: MessagingException) {
|
||||
assertTrue(e.isPermanentFailure)
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
assertEquals(folderServerIds.toSet(), backendStorage.getFolderServerIds().toSet())
|
||||
}
|
||||
|
||||
private fun assertFolderPresent(serverId: String, name: String, type: FolderType) {
|
||||
val folder = backendStorage.folders[serverId]
|
||||
?: throw AssertionFailedError("Expected folder '$serverId' in BackendStorage")
|
||||
|
||||
assertEquals(name, folder.name)
|
||||
assertEquals(type, folder.type)
|
||||
}
|
||||
|
||||
private fun assertMailboxState(expected: String) {
|
||||
assertEquals(expected, backendStorage.getExtraString("jmapState"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import app.k9mail.backend.testing.InMemoryBackendFolder
|
||||
import app.k9mail.backend.testing.InMemoryBackendStorage
|
||||
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 okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionResourceWithAuthenticationError() {
|
||||
val command = createCommandSync(
|
||||
MockResponse().setResponseCode(401)
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertEquals(SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID), syncListener.getNextEvent())
|
||||
val failedEvent = syncListener.getNextEvent() as SyncListenerEvent.SyncFailed
|
||||
assertEquals(AuthenticationFailedException::class.java, failedEvent.exception!!.javaClass)
|
||||
}
|
||||
|
||||
@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)
|
||||
assertEquals(setOf("M001", "M002", "M003", "M004", "M005"), backendFolder.getMessageServerIds())
|
||||
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)
|
||||
|
||||
assertEquals(emptySet<String>(), backendFolder.getMessageServerIds())
|
||||
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)
|
||||
|
||||
assertEquals(setOf("M001", "M002"), backendFolder.getMessageServerIds())
|
||||
assertEquals(emptySet<Flag>(), backendFolder.getMessageFlags("M001"))
|
||||
assertEquals(setOf(Flag.SEEN), backendFolder.getMessageFlags("M002"))
|
||||
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)
|
||||
|
||||
assertEquals(setOf("M002", "M003"), backendFolder.getMessageServerIds())
|
||||
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)
|
||||
|
||||
assertEquals(setOf("M002", "M003"), backendFolder.getMessageServerIds())
|
||||
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
|
||||
assertEquals(expected, requestUrlPath)
|
||||
}
|
||||
|
||||
private fun InMemoryBackendFolder.assertQueryState(expected: String) {
|
||||
assertEquals(expected, getFolderExtraString("jmapQueryState"))
|
||||
}
|
||||
|
||||
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,96 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
|
||||
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) {
|
||||
assertEquals(event, getNextEvent())
|
||||
}
|
||||
assertNoMoreEventsLeft()
|
||||
}
|
||||
|
||||
fun getNextEvent(): SyncListenerEvent {
|
||||
require(events.isNotEmpty()) { "No events left" }
|
||||
return events.removeAt(0)
|
||||
}
|
||||
|
||||
private fun assertNoMoreEventsLeft() {
|
||||
assertTrue("Expected no more events; but still have: $events", events.isEmpty())
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M003",
|
||||
"blobId": "B003",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-02-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M003",
|
||||
"blobId": "B003",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-02-11T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "M004",
|
||||
"blobId": "B004",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-01-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M005",
|
||||
"blobId": "B005",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-01-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M001",
|
||||
"keywords": {}
|
||||
},
|
||||
{
|
||||
"id": "M002",
|
||||
"keywords": {
|
||||
"$seen": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M002",
|
||||
"keywords": {}
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 2,
|
||||
"ids": [
|
||||
"M001",
|
||||
"M002"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 5,
|
||||
"ids": [
|
||||
"M001",
|
||||
"M002",
|
||||
"M003",
|
||||
"M004",
|
||||
"M005"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 2,
|
||||
"ids": [
|
||||
"M002",
|
||||
"M003"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/queryChanges",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldQueryState": "50:0",
|
||||
"newQueryState": "51:0",
|
||||
"removed": ["M001"],
|
||||
"added": [
|
||||
{
|
||||
"id": "M003",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"error",
|
||||
{
|
||||
"type": "cannotCalculateChanges"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/queryChanges",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldQueryState": "50:0",
|
||||
"newQueryState": "50:0",
|
||||
"removed": [],
|
||||
"added": []
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 0,
|
||||
"ids": [],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/changes",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldState": "23",
|
||||
"newState": "42",
|
||||
"hasMoreChanges": false,
|
||||
"created": [ "id_folder2" ],
|
||||
"updated": [ "id_trash" ],
|
||||
"destroyed": [ "id_folder1" ]
|
||||
},
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "42",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_folder2",
|
||||
"name": "folder2",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": null,
|
||||
"totalEmails": 0,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 0,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 10,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "42",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_trash",
|
||||
"name": "Deleted messages",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "trash",
|
||||
"totalEmails": 2,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 2,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 7,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"2"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/changes",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldState": "23",
|
||||
"newState": "27",
|
||||
"hasMoreChanges": true,
|
||||
"created": [ "id_folder2" ],
|
||||
"updated": [],
|
||||
"destroyed": []
|
||||
},
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "27",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_folder2",
|
||||
"name": "folder2",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": null,
|
||||
"totalEmails": 0,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 0,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 10,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "27",
|
||||
"list": []
|
||||
},
|
||||
"2"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/changes",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldState": "27",
|
||||
"newState": "42",
|
||||
"hasMoreChanges": false,
|
||||
"created": [],
|
||||
"updated": [ "id_trash" ],
|
||||
"destroyed": [ "id_folder1" ]
|
||||
},
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "42",
|
||||
"list": []
|
||||
},
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "42",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_trash",
|
||||
"name": "Deleted messages",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "trash",
|
||||
"totalEmails": 2,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 2,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 7,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"2"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"error",
|
||||
{
|
||||
"type": "cannotCalculateChanges"
|
||||
},
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"error",
|
||||
{
|
||||
"type": "resultReference"
|
||||
},
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"error",
|
||||
{
|
||||
"type": "resultReference"
|
||||
},
|
||||
"2"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "23",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_inbox",
|
||||
"name": "Inbox",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": false,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": false
|
||||
},
|
||||
"role": "inbox",
|
||||
"totalEmails": 238,
|
||||
"unreadEmails": 6,
|
||||
"totalThreads": 80,
|
||||
"unreadThreads": 4,
|
||||
"sortOrder": 1,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_archive",
|
||||
"name": "Archive",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "archive",
|
||||
"totalEmails": 295,
|
||||
"unreadEmails": 36,
|
||||
"totalThreads": 136,
|
||||
"unreadThreads": 17,
|
||||
"sortOrder": 3,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_drafts",
|
||||
"name": "Drafts",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "drafts",
|
||||
"totalEmails": 0,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 0,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 4,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_sent",
|
||||
"name": "Sent",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "sent",
|
||||
"totalEmails": 2,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 2,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 5,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_trash",
|
||||
"name": "Trash",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "trash",
|
||||
"totalEmails": 2,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 2,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 7,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_folder1",
|
||||
"name": "folder1",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": null,
|
||||
"totalEmails": 0,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 0,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 10,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"username": "test",
|
||||
"apiUrl": "/jmap/",
|
||||
"downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
|
||||
"uploadUrl": "/jmap/upload/{accountId}/",
|
||||
"eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
|
||||
"accounts": {
|
||||
"test@example.com": {
|
||||
"name": "test@example.com",
|
||||
"isPersonal": true,
|
||||
"isReadOnly": false,
|
||||
"accountCapabilities": {
|
||||
"urn:ietf:params:jmap:core": {},
|
||||
"urn:ietf:params:jmap:submission": {
|
||||
"maxDelayedSend": 44236800,
|
||||
"submissionExtensions": {
|
||||
"size": [
|
||||
"10240000"
|
||||
],
|
||||
"dsn": []
|
||||
}
|
||||
},
|
||||
"urn:ietf:params:jmap:mail": {
|
||||
"emailQuerySortOptions": [
|
||||
"receivedAt",
|
||||
"sentAt",
|
||||
"from",
|
||||
"id",
|
||||
"emailstate",
|
||||
"size",
|
||||
"subject",
|
||||
"to",
|
||||
"hasKeyword",
|
||||
"someInThreadHaveKeyword",
|
||||
"addedDates",
|
||||
"threadSize",
|
||||
"spamScore",
|
||||
"snoozedUntil"
|
||||
],
|
||||
"maxKeywordsPerEmail": 100,
|
||||
"maxSizeAttachmentsPerEmail": 10485760,
|
||||
"maxMailboxesPerEmail": 20,
|
||||
"mayCreateTopLevelMailbox": true,
|
||||
"maxSizeMailboxName": 500
|
||||
},
|
||||
"urn:ietf:params:jmap:vacationresponse": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"urn:ietf:params:jmap:core": {
|
||||
"maxSizeUpload": 1073741824,
|
||||
"maxConcurrentUpload": 5,
|
||||
"maxCallsInRequest": 50,
|
||||
"maxObjectsInGet": 2,
|
||||
"maxObjectsInSet": 4096,
|
||||
"collationAlgorithms": []
|
||||
},
|
||||
"urn:ietf:params:jmap:submission": {},
|
||||
"urn:ietf:params:jmap:mail": {},
|
||||
"urn:ietf:params:jmap:vacationresponse": {}
|
||||
},
|
||||
"state": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"username": "test",
|
||||
"apiUrl": "/jmap/",
|
||||
"downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
|
||||
"uploadUrl": "/jmap/upload/{accountId}/",
|
||||
"eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
|
||||
"accounts": {
|
||||
"test@example.com": {
|
||||
"name": "test@example.com",
|
||||
"isPersonal": true,
|
||||
"isReadOnly": false,
|
||||
"accountCapabilities": {
|
||||
"urn:ietf:params:jmap:core": {},
|
||||
"urn:ietf:params:jmap:submission": {
|
||||
"maxDelayedSend": 44236800,
|
||||
"submissionExtensions": {
|
||||
"size": [
|
||||
"10240000"
|
||||
],
|
||||
"dsn": []
|
||||
}
|
||||
},
|
||||
"urn:ietf:params:jmap:mail": {
|
||||
"emailQuerySortOptions": [
|
||||
"receivedAt",
|
||||
"sentAt",
|
||||
"from",
|
||||
"id",
|
||||
"emailstate",
|
||||
"size",
|
||||
"subject",
|
||||
"to",
|
||||
"hasKeyword",
|
||||
"someInThreadHaveKeyword",
|
||||
"addedDates",
|
||||
"threadSize",
|
||||
"spamScore",
|
||||
"snoozedUntil"
|
||||
],
|
||||
"maxKeywordsPerEmail": 100,
|
||||
"maxSizeAttachmentsPerEmail": 10485760,
|
||||
"maxMailboxesPerEmail": 20,
|
||||
"mayCreateTopLevelMailbox": true,
|
||||
"maxSizeMailboxName": 500
|
||||
},
|
||||
"urn:ietf:params:jmap:vacationresponse": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"urn:ietf:params:jmap:core": {
|
||||
"maxSizeUpload": 1073741824,
|
||||
"maxConcurrentUpload": 5,
|
||||
"maxCallsInRequest": 50,
|
||||
"maxObjectsInGet": 4096,
|
||||
"maxObjectsInSet": 4096,
|
||||
"collationAlgorithms": []
|
||||
},
|
||||
"urn:ietf:params:jmap:submission": {},
|
||||
"urn:ietf:params:jmap:mail": {},
|
||||
"urn:ietf:params:jmap:vacationresponse": {}
|
||||
},
|
||||
"state": "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