Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,12 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.outcome)
implementation(projects.feature.mail.account.api)
implementation(projects.feature.mail.folder.api)
api(projects.mail.common)
}

View file

@ -0,0 +1,94 @@
package com.fsck.k9.backend.api
import com.fsck.k9.mail.BodyFactory
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Part
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
interface Backend {
val supportsFlags: Boolean
val supportsExpunge: Boolean
val supportsMove: Boolean
val supportsCopy: Boolean
val supportsUpload: Boolean
val supportsTrashFolder: Boolean
val supportsSearchByDate: Boolean
val supportsFolderSubscriptions: Boolean
val isPushCapable: Boolean
@Throws(MessagingException::class)
fun refreshFolderList(): FolderPathDelimiter?
// TODO: Add a way to cancel the sync process
fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener)
@Throws(MessagingException::class)
fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String)
@Throws(MessagingException::class)
fun downloadMessageStructure(folderServerId: String, messageServerId: String)
@Throws(MessagingException::class)
fun downloadCompleteMessage(folderServerId: String, messageServerId: String)
@Throws(MessagingException::class)
fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean)
@Throws(MessagingException::class)
fun markAllAsRead(folderServerId: String)
@Throws(MessagingException::class)
fun expunge(folderServerId: String)
@Throws(MessagingException::class)
fun deleteMessages(folderServerId: String, messageServerIds: List<String>)
@Throws(MessagingException::class)
fun deleteAllMessages(folderServerId: String)
@Throws(MessagingException::class)
fun moveMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>?
@Throws(MessagingException::class)
fun moveMessagesAndMarkAsRead(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>?
@Throws(MessagingException::class)
fun copyMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>?
@Throws(MessagingException::class)
fun search(
folderServerId: String,
query: String?,
requiredFlags: Set<Flag>?,
forbiddenFlags: Set<Flag>?,
performFullTextSearch: Boolean,
): List<String>
@Throws(MessagingException::class)
fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory)
@Throws(MessagingException::class)
fun findByMessageId(folderServerId: String, messageId: String): String?
@Throws(MessagingException::class)
fun uploadMessage(folderServerId: String, message: Message): String?
@Throws(MessagingException::class)
fun sendMessage(message: Message)
fun createPusher(callback: BackendPusherCallback): BackendPusher
}

View file

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

View file

@ -0,0 +1,8 @@
package com.fsck.k9.backend.api
interface BackendPusher {
fun start()
fun updateFolders(folderServerIds: Collection<String>)
fun stop()
fun reconnect()
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9.backend.api
interface BackendPusherCallback {
fun onPushEvent(folderServerId: String)
fun onPushError(exception: Exception)
fun onPushNotSupported()
}

View file

@ -0,0 +1,33 @@
package com.fsck.k9.backend.api
import com.fsck.k9.mail.FolderType
import java.io.Closeable
import net.thunderbird.core.common.exception.MessagingException
interface BackendStorage {
fun getFolder(folderServerId: String): BackendFolder
fun getFolderServerIds(): List<String>
fun createFolderUpdater(): BackendFolderUpdater
fun getExtraString(name: String): String?
fun setExtraString(name: String, value: String)
fun getExtraNumber(name: String): Long?
fun setExtraNumber(name: String, value: Long)
}
interface BackendFolderUpdater : Closeable {
@Throws(MessagingException::class)
fun createFolders(folders: List<FolderInfo>): Set<Long>
fun deleteFolders(folderServerIds: List<String>)
@Throws(MessagingException::class)
fun changeFolder(folderServerId: String, name: String, type: FolderType)
}
fun BackendFolderUpdater.createFolder(folder: FolderInfo): Long? = createFolders(listOf(folder)).firstOrNull()
inline fun <T> BackendStorage.updateFolders(block: BackendFolderUpdater.() -> T): T {
return createFolderUpdater().use { it.block() }
}

View file

@ -0,0 +1,11 @@
package com.fsck.k9.backend.api
import com.fsck.k9.mail.FolderType
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
data class FolderInfo(
val serverId: String,
val name: String,
val type: FolderType,
val folderPathDelimiter: FolderPathDelimiter? = null,
)

View file

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

View file

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

View file

@ -0,0 +1,8 @@
package net.thunderbird.backend.api
import com.fsck.k9.backend.api.Backend
import net.thunderbird.feature.mail.account.api.BaseAccount
interface BackendFactory<TAccount : BaseAccount> {
fun createBackend(account: TAccount): Backend
}

View file

@ -0,0 +1,8 @@
package net.thunderbird.backend.api
import com.fsck.k9.backend.api.BackendStorage
import net.thunderbird.feature.mail.account.api.BaseAccount
interface BackendStorageFactory<in TAccount : BaseAccount> {
fun createBackendStorage(account: TAccount): BackendStorage
}

View file

@ -0,0 +1,68 @@
package net.thunderbird.backend.api.folder
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.folders.FolderServerId
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.account.api.BaseAccount
interface RemoteFolderCreator {
/**
* Creates a folder on the remote server. If the folder already exists and [mustCreate] is `false`,
* the operation will succeed returning [RemoteFolderCreationOutcome.Success.AlreadyExists].
*
* @param folderServerId The folder server ID.
* @param mustCreate If `true`, the folder must be created returning
* [RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder]. If `false`, the folder will be created
* only if it doesn't exist.
* @param folderType The folder type. This requires special handling for some servers. Default [FolderType.REGULAR].
* @return The result of the operation.
* @see RemoteFolderCreationOutcome.Success
* @see RemoteFolderCreationOutcome.Error
*/
suspend fun create(
folderServerId: FolderServerId,
mustCreate: Boolean,
folderType: FolderType = FolderType.REGULAR,
): Outcome<RemoteFolderCreationOutcome.Success, RemoteFolderCreationOutcome.Error>
interface Factory {
fun create(account: BaseAccount): RemoteFolderCreator
}
}
sealed interface RemoteFolderCreationOutcome {
sealed interface Success : RemoteFolderCreationOutcome {
/**
* Used to flag that the folder was created successfully.
*/
data object Created : Success
/**
* Used to flag that the folder creation was skipped because the folder already exists and
* the creation is NOT mandatory.
*/
data object AlreadyExists : Success
}
sealed interface Error : RemoteFolderCreationOutcome {
/**
* Used to flag that the folder creation has failed because the folder already exists and
* the creation is mandatory.
*/
data object AlreadyExists : Error
/**
* Used to flag that the folder creation failed on the remote server.
* @param reason The reason why the folder creation failed.
*/
data class FailedToCreateRemoteFolder(
val reason: String,
) : Error
/**
* Used to flag that the Create Folder operation is not supported by the server.
* E.g. POP3 servers don't support creating archive folders.
*/
data object NotSupportedOperation : Error
}
}

View file

@ -0,0 +1,14 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
alias(libs.plugins.kotlin.serialization)
}
dependencies {
api(projects.backend.api)
implementation(projects.feature.mail.folder.api)
implementation(libs.kotlinx.serialization.json)
testImplementation(projects.mail.testing)
}

View file

@ -0,0 +1,33 @@
package app.k9mail.backend.demo
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.updateFolders
import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
internal class CommandRefreshFolderList(
private val backendStorage: BackendStorage,
private val demoStore: DemoStore,
) {
fun refreshFolderList(): FolderPathDelimiter? {
val localFolderServerIds = backendStorage.getFolderServerIds().toSet()
backendStorage.updateFolders {
val remoteFolderServerIds = demoStore.getFolderIds()
val foldersServerIdsToCreate = remoteFolderServerIds - localFolderServerIds
val foldersToCreate = foldersServerIdsToCreate.mapNotNull { folderServerId ->
demoStore.getFolder(folderServerId)?.let { folderData ->
FolderInfo(folderServerId, folderData.name, folderData.type)
}
}
createFolders(foldersToCreate)
val folderServerIdsToRemove = (localFolderServerIds - remoteFolderServerIds).toList()
deleteFolders(folderServerIdsToRemove)
}
return FOLDER_DEFAULT_PATH_DELIMITER
}
}

View file

@ -0,0 +1,32 @@
package app.k9mail.backend.demo
import app.k9mail.backend.demo.DemoHelper.createNewServerId
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.MessageDownloadState
import com.fsck.k9.mail.internet.MimeMessage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
internal class CommandSendMessage(
private val backendStorage: BackendStorage,
private val demoStore: DemoStore,
) {
fun sendMessage(message: Message) {
val inboxServerId = demoStore.getInboxFolderId()
val backendFolder = backendStorage.getFolder(inboxServerId)
val newMessage = message.copy(uid = createNewServerId())
backendFolder.saveMessage(newMessage, MessageDownloadState.FULL)
}
private fun Message.copy(uid: String): MimeMessage {
val outputStream = ByteArrayOutputStream()
writeTo(outputStream)
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
return MimeMessage.parseMimeMessage(inputStream, false).apply {
this.uid = uid
}
}
}

View file

@ -0,0 +1,40 @@
package app.k9mail.backend.demo
import com.fsck.k9.backend.api.BackendFolder.MoreMessages
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.mail.MessageDownloadState
internal class CommandSync(
private val backendStorage: BackendStorage,
private val demoStore: DemoStore,
) {
fun sync(folderServerId: String, listener: SyncListener) {
listener.syncStarted(folderServerId)
val folder = demoStore.getFolder(folderServerId)
if (folder == null) {
listener.syncFailed(folderServerId, "Folder $folderServerId doesn't exist", null)
return
}
val backendFolder = backendStorage.getFolder(folderServerId)
val localMessageServerIds = backendFolder.getMessageServerIds()
if (localMessageServerIds.isNotEmpty()) {
listener.syncFinished(folderServerId)
return
}
for (messageServerId in folder.messageServerIds) {
val message = demoStore.getMessage(folderServerId, messageServerId)
backendFolder.saveMessage(message, MessageDownloadState.FULL)
listener.syncNewMessage(folderServerId, messageServerId, isOldMessage = false)
}
backendFolder.setMoreMessages(MoreMessages.FALSE)
listener.syncFinished(folderServerId)
}
}

View file

@ -0,0 +1,123 @@
package app.k9mail.backend.demo
import app.k9mail.backend.demo.DemoHelper.createNewServerId
import com.fsck.k9.backend.api.Backend
import com.fsck.k9.backend.api.BackendPusher
import com.fsck.k9.backend.api.BackendPusherCallback
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.mail.BodyFactory
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Part
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
class DemoBackend(
private val backendStorage: BackendStorage,
) : Backend {
private val demoStore by lazy { DemoStore() }
private val commandSync by lazy { CommandSync(backendStorage, demoStore) }
private val commandRefreshFolderList by lazy { CommandRefreshFolderList(backendStorage, demoStore) }
private val commandSendMessage by lazy { CommandSendMessage(backendStorage, demoStore) }
override val supportsFlags: Boolean = true
override val supportsExpunge: Boolean = false
override val supportsMove: Boolean = true
override val supportsCopy: Boolean = true
override val supportsUpload: Boolean = true
override val supportsTrashFolder: Boolean = true
override val supportsSearchByDate: Boolean = false
override val supportsFolderSubscriptions: Boolean = false
override val isPushCapable: Boolean = false
override fun refreshFolderList(): FolderPathDelimiter? {
return commandRefreshFolderList.refreshFolderList()
}
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
commandSync.sync(folderServerId, listener)
}
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) = Unit
override fun markAllAsRead(folderServerId: String) = Unit
override fun expunge(folderServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) = Unit
override fun deleteAllMessages(folderServerId: String) = Unit
override fun moveMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String> {
// We do just enough to simulate a successful operation on the server.
return messageServerIds.associateWith { createNewServerId() }
}
override fun moveMessagesAndMarkAsRead(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String> {
// We do just enough to simulate a successful operation on the server.
return messageServerIds.associateWith { createNewServerId() }
}
override fun copyMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String> {
// We do just enough to simulate a successful operation on the server.
return messageServerIds.associateWith { createNewServerId() }
}
override fun search(
folderServerId: String,
query: String?,
requiredFlags: Set<Flag>?,
forbiddenFlags: Set<Flag>?,
performFullTextSearch: Boolean,
): List<String> {
throw UnsupportedOperationException("not implemented")
}
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
throw UnsupportedOperationException("not implemented")
}
override fun findByMessageId(folderServerId: String, messageId: String): String? {
throw UnsupportedOperationException("not implemented")
}
override fun uploadMessage(folderServerId: String, message: Message): String {
return createNewServerId()
}
override fun sendMessage(message: Message) {
commandSendMessage.sendMessage(message)
}
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
throw UnsupportedOperationException("not implemented")
}
}

View file

@ -0,0 +1,30 @@
package app.k9mail.backend.demo
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.internet.MimeMessage
import java.io.InputStream
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
internal class DemoDataLoader {
@OptIn(ExperimentalSerializationApi::class)
fun loadFolders(): DemoFolders {
return getResourceAsStream("/contents.json").use { inputStream ->
Json.decodeFromStream<DemoFolders>(inputStream)
}
}
fun loadMessage(folderServerId: String, messageServerId: String): Message {
return getResourceAsStream("/$folderServerId/$messageServerId.eml").use { inputStream ->
MimeMessage.parseMimeMessage(inputStream, false).apply {
uid = messageServerId
}
}
}
private fun getResourceAsStream(name: String): InputStream {
return this.javaClass.getResourceAsStream(name) ?: error("Resource '$name' not found")
}
}

View file

@ -0,0 +1,12 @@
package app.k9mail.backend.demo
import com.fsck.k9.mail.FolderType
import kotlinx.serialization.Serializable
@Serializable
internal data class DemoFolder(
val name: String,
val type: FolderType,
val messageServerIds: List<String>,
val subFolders: DemoFolders? = null,
)

View file

@ -0,0 +1,3 @@
package app.k9mail.backend.demo
internal typealias DemoFolders = Map<String, DemoFolder>

View file

@ -0,0 +1,7 @@
package app.k9mail.backend.demo
import java.util.UUID
internal object DemoHelper {
fun createNewServerId() = UUID.randomUUID().toString()
}

View file

@ -0,0 +1,54 @@
package app.k9mail.backend.demo
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.Message
internal class DemoStore(
private val demoDataLoader: DemoDataLoader = DemoDataLoader(),
) {
private val demoFolders: DemoFolders by lazy { flattenDemoFolders(demoDataLoader.loadFolders()) }
fun getFolder(folderServerId: String): DemoFolder? {
return demoFolders[folderServerId]
}
fun getFolderIds(): Set<String> {
return demoFolders.keys
}
fun getInboxFolderId(): String {
return demoFolders.filterValues { it.type == FolderType.INBOX }.keys.first()
}
fun getMessage(folderServerId: String, messageServerId: String): Message {
return demoDataLoader.loadMessage(folderServerId, messageServerId)
}
// This is a workaround for the fact that the backend doesn't support nested folders
private fun flattenDemoFolders(
demoFolders: DemoFolders,
parentName: String = "",
parentServerId: String = "",
): DemoFolders {
val flatFolders = mutableMapOf<String, DemoFolder>()
for ((folderServerId, demoFolder) in demoFolders) {
val fullName = if (parentName.isEmpty()) {
demoFolder.name
} else {
"$parentName/${demoFolder.name}"
}
val fullServerId = if (parentServerId.isEmpty()) {
folderServerId
} else {
"$parentServerId/$folderServerId"
}
flatFolders[fullServerId] = demoFolder.copy(name = fullName)
val subFolders = demoFolder.subFolders
if (subFolders != null) {
flatFolders.putAll(flattenDemoFolders(demoFolder.subFolders, fullName, fullServerId))
}
}
return flatFolders
}
}

View file

@ -0,0 +1,88 @@
{
"inbox": {
"name": "Inbox",
"type": "INBOX",
"messageServerIds": [
"intro",
"many_recipients",
"thread_1",
"thread_2",
"inline_image_data_uri",
"inline_image_attachment",
"localpart_exceeds_length_limit"
]
},
"trash": {
"name": "Trash",
"type": "TRASH",
"messageServerIds": []
},
"drafts": {
"name": "Drafts",
"type": "DRAFTS",
"messageServerIds": []
},
"sent": {
"name": "Sent",
"type": "SENT",
"messageServerIds": []
},
"archive": {
"name": "Archive",
"type": "ARCHIVE",
"messageServerIds": []
},
"spam": {
"name": "Spam",
"type": "SPAM",
"messageServerIds": []
},
"turing": {
"name": "Turing Awards",
"type": "REGULAR",
"messageServerIds": [
"turing_award_1966",
"turing_award_1967",
"turing_award_1968",
"turing_award_1970",
"turing_award_1971",
"turing_award_1972",
"turing_award_1975",
"turing_award_1977",
"turing_award_1978",
"turing_award_1979",
"turing_award_1981",
"turing_award_1983",
"turing_award_1987",
"turing_award_1991",
"turing_award_1996"
]
},
"nested": {
"name": "Nested",
"type": "REGULAR",
"messageServerIds": [
"nested_1"
],
"subFolders": {
"nested_level_1": {
"name": "Nested Level 1",
"type": "REGULAR",
"messageServerIds": [
"nested_level_1_1",
"nested_level_1_2"
],
"subFolders": {
"nested_level_2": {
"name": "Nested Level 2",
"type": "REGULAR",
"messageServerIds": [
"nested_level_2_1",
"nested_level_2_2"
]
}
}
}
}
}
}

View file

@ -0,0 +1,332 @@
MIME-Version: 1.0
From: "Test data" <data@example.com>
Date: Tue, 14 Feb 2023 15:00:00 +0100
Message-ID: <inbox-6@example.com>
Subject: Inline image attachment
To: User <user@example.com>
Content-Type: multipart/related; boundary=BOUNDARY
--BOUNDARY
Content-Type: text/html; charset=UTF-8
<html>
<body>
<p>Inline image using a cid: URI to reference an attached image:</p>
<img src="cid:part1@example.com">
</body>
</html>
--BOUNDARY
Content-Type: image/png; name="thunderbird.png"
Content-Transfer-Encoding: base64
Content-ID: <part1@example.com>
Content-Disposition: inline; filename="thunderbird.png"
iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAEtWlUWHRYTUw6Y29tLmFkb2JlLnht
cAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQi
Pz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUg
NS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIy
LXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1s
bnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJo
dHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDov
L25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFk
b2JlLmNvbS94YXAvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hh
cC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9z
VHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjEyOCIKICAgZXhp
ZjpQaXhlbFlEaW1lbnNpb249IjEyOCIKICAgZXhpZjpDb2xvclNwYWNlPSIxIgogICB0aWZmOklt
YWdlV2lkdGg9IjEyOCIKICAgdGlmZjpJbWFnZUxlbmd0aD0iMTI4IgogICB0aWZmOlJlc29sdXRp
b25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9u
PSI3Mi8xIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmls
ZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDI1LTAzLTA0VDE5OjEy
OjU5KzAxOjAwIgogICB4bXA6TWV0YWRhdGFEYXRlPSIyMDI1LTAzLTA0VDE5OjEyOjU5KzAxOjAw
Ij4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0
RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5
IFBob3RvIDIgMi42LjAiCiAgICAgIHN0RXZ0OndoZW49IjIwMjUtMDMtMDRUMTk6MTI6NTkrMDE6
MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0
aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PkBqBbkAAAGB
aUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRzytEURTHPzNDxAixUCwmYTU0fjSxUUZCSdMYZbCZ
eTNvRs2P13szabJVtooSG78W/AVslbVSRErWbIkNes6bUTPJnNu553O/957TveeCPZhUUkaVB1Lp
rB6Y9LkWQouumhccVNHMEO1hxdDG/P4ZKtrHHTYr3vRatSqf+9fqozFDAVut8Kii6VnhKeGZ1axm
8bZwq5IIR4VPhd26XFD41tIjRX62OF7kL4v1YGAc7E3CrngZR8pYSegpYXk5XalkTvm9j/USZyw9
PyexU7wDgwCT+HAxzQTjeOlnRGYvvQzQJysq5HsK+bNkJFeRWSOPzgpxEmRxi5qT6jGJqugxGUny
Vv//9tVQBweK1Z0+qH4yzbduqNmC703T/Dw0ze8jcDzCRbqUnzmA4XfRN0ta1z40rsPZZUmL7MD5
BrQ9aGE9XJAc4nZVhdcTaAhByzXULRV79rvP8T0E1+SrrmB3D3rkfOPyD2DqZ+MT1h/FAAAACXBI
WXMAAAsTAAALEwEAmpwYAAAgAElEQVR4nO29ebwmRXkv/q3qftfznn3OmY0BZoYBAdnRxKDmZxIQ
iEt+10RzzQ+SuIZEjahBXBL9GTXGeKMs0Zt7jbJqhKi43HsjKBEBBWTfZ2dgZs6s58xZ3vMu3V3P
/aO6qp7q7vfMDMy4RGo+Z97urv3Zn6equoHn0/Pp+fSrm8TPewD/mdNrPztZGhJiHMDiQGBcAGMS
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
--BOUNDARY--

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,9 @@
MIME-Version: 1.0
From: "Thunderbird" <thunderbird@example.com>
Date: Thu, 23 Sep 2021 23:42:00 +0200
Message-ID: <hello-1-2-3@example.com>
Subject: Welcome to Thunderbird for Android
To: User <user@example.com>
Content-Type: text/plain; charset=UTF-8
Congratulations, you have managed to set up Thunderbird for Android's demo account. Have fun exploring the app.

View file

@ -0,0 +1,9 @@
MIME-Version: 1.0
From: Sender <aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffggggg@example.com> (local part exceeds maximum length)
Date: Thu, 15 Jun 2023 18:00:00 +0200
Message-ID: <localpart@example.com>
Subject: Localpart of email address exceeds 64 characters
To: User <user@example.com>
Content-Type: text/plain; charset=UTF-8
You should still be able to read this message.

View file

@ -0,0 +1,42 @@
MIME-Version: 1.0
From: "Alice" <from1@example.com>, "Bob" <from2@example.com>
Sender: "Bernd" <sender@example.com>
Reply-To: <reply-to@example.com>
Date: Mon, 23 Jan 2023 12:00:00 +0100
Message-ID: <inbox-2@example.com>
Subject: Message details demo
To: "User 1" <to1@example.com>,
"User 2" <to2@example.com>,
"User 3" <to3@example.com>,
"User 4" <to4@example.com>,
"User 5" <to5@example.com>,
"User 6" <to6@example.com>,
"User 7" <to7@example.com>,
"User 8" <to8@example.com>,
"User 9" <to9@example.com>,
"User 10" <to10@example.com>,
"User 11" <to11@example.com>,
"User 12" <to12@example.com>,
"User 13" <to13@example.com>,
"User 14" <to14@example.com>,
"User 15" <to15@example.com>,
"User 16" <to16@example.com>,
"User 17" <to17@example.com>,
"User 18" <to18@e10.example.com>,
"User 19" <to19@e11.example.com>,
"User 20" <to20@e12.example.com>
Cc: "Copy 1" <cc1@example.com>,
"Copy 2" <cc2@example.com>,
"Copy 3" <cc3@example.com>
Bcc: "Blind 1" <bcc1@example.com>,
"Blind 2" <bcc2@example.com>,
"Blind 3" <bcc3@example.com>
Content-Type: text/plain; charset=UTF-8
This message contains…
- multiple addresses in the From: header
- a Sender: header
- a Reply-To: header
- multiple addresses in the To: header
- multiple addresses in the Cc: header
- multiple addresses in the Bcc: header

View file

@ -0,0 +1,9 @@
MIME-Version: 1.0
From: Alice <alice@example.com>
Date: Fri, 10 Feb 2023 10:00:00 +0100
Message-ID: <thread-1@example.com>
Subject: Thread
To: Bob <bob@example.com>
Content-Type: text/plain; charset=UTF-8
This is the first message in this thread.

View file

@ -0,0 +1,11 @@
MIME-Version: 1.0
From: Bob <bob@example.com>
Date: Fri, 10 Feb 2023 10:05:00 +0100
Message-ID: <thread-2@example.com>
Subject: Re: Thread
To: Alice <alice@example.com>
In-Reply-To: <thread-1@example.com>
References: <thread-2@example.com>
Content-Type: text/plain; charset=UTF-8
This is the second message in this thread.

View file

@ -0,0 +1,20 @@
MIME-Version: 1.0
From: "Nested User" <nested@example.com>
Date: Mon, 01 Jan 2024 12:00:00 -0400
Message-ID: <nested_1@example.com>
Subject: Nested Message
To: User <user@example.com>
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
--047d7b450b100959e604d85a5320
Content-Type: text/plain; charset=UTF-8
This is a message in the Nested folder.
--047d7b450b100959e604d85a5320
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>This is a message in the Nested folder.</div></div>
--047d7b450b100959e604d85a5320--

View file

@ -0,0 +1,20 @@
MIME-Version: 1.0
From: "Nested Level 1 User" <nested.level1@example.com>
Date: Mon, 01 Jan 2024 12:00:00 -0400
Message-ID: <nested_level_1_1@example.com>
Subject: Nested Level 1 Message
To: Nested Level 2 User <nested.level2@example.com>
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
--047d7b450b100959e604d85a5320
Content-Type: text/plain; charset=UTF-8
This is a message 1 in the Nested Level 1 folder.
--047d7b450b100959e604d85a5320
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>This is a message in the Nested Level 1 folder.</div></div>
--047d7b450b100959e604d85a5320--

View file

@ -0,0 +1,20 @@
MIME-Version: 1.0
From: "Nested Level 1 User" <nested.level1@example.com>
Date: Mon, 01 Jan 2024 12:00:00 -0400
Message-ID: <nested_level_1_2@example.com>
Subject: Nested Level 1 Message
To: Nested Level 2 User <nested.level2@example.com>
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
--047d7b450b100959e604d85a5320
Content-Type: text/plain; charset=UTF-8
This is a message 2 in the Nested Level 1 folder.
--047d7b450b100959e604d85a5320
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>This is a message in the Nested Level 1 folder.</div></div>
--047d7b450b100959e604d85a5320--

View file

@ -0,0 +1,20 @@
MIME-Version: 1.0
From: "Nested Level 2 User" <nested.level2@example.com>
Date: Mon, 01 Jan 2024 12:00:00 -0400
Message-ID: <nested_level_2_1@example.com>
Subject: Nested Level 2 Message
To: Nested Level 1 User <nested.level1@example.com>
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
--047d7b450b100959e604d85a5320
Content-Type: text/plain; charset=UTF-8
This is a message 1 in the Nested Level 2 folder.
--047d7b450b100959e604d85a5320
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>This is a message in the Nested Level 2 folder.</div></div>
--047d7b450b100959e604d85a5320--

View file

@ -0,0 +1,20 @@
MIME-Version: 1.0
From: "Nested Level 2 User" <nested.level2@example.com>
Date: Mon, 01 Jan 2024 12:00:00 -0400
Message-ID: <nested_level_2_2@example.com>
Subject: Nested Level 2 Message
To: Nested Level 1 User <nested.level1@example.com>
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
--047d7b450b100959e604d85a5320
Content-Type: text/plain; charset=UTF-8
This is a message 2 in the Nested Level 2 folder.
--047d7b450b100959e604d85a5320
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>This is a message in the Nested Level 2 folder.</div></div>
--047d7b450b100959e604d85a5320--

View file

@ -0,0 +1,84 @@
MIME-Version: 1.0
From: "Alan J. Perlis" <alan.perlis@example.com>
Date: Sat, 01 Jan 1966 12:00:00 -0400
Message-ID: <turing1966@example.com>
Subject: The Synthesis of Algorithmic Systems
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
--047d7b450b100959e604d85a5320
Content-Type: text/plain; charset=UTF-8
Both knowledge and wisdom extend man's reach. Knowledge led to computers,
wisdom to chopsticks. Unfortunately our association is overinvolved with
the former. The latter will have to wait for a more sublime day.
On what does and will the fame of Turing rest? That he proved a theorem
showing that for a general computing device--later dubbed a "Turing
machine"--there existed functions which it could not compute? I doubt it.
More likely it rests on the model he invented and employed: his formal
mechanism.
This model has captured the imagination and mobilized the thoughts of a
generation of scientists. It has provided a basis for arguments leading to
theories. His model has proved so useful that its generated activity has
been distributed not only in mathematics, but through several technologies
as well. The arguments that have been employed are not always formal and
the consequent creations not all abstract.
Indeed a most fruitful consequence of the Turing machine has been with the
creation, study and computation of functions which are computable, i.e., in
computer programming. This is not surprising since computers can compute so
much more than we yet know how to specify.
I am sure that all will agree that this model has been enormously valuable.
History will forgive me for not devoting any attention in this lecture to
the effect which Turing had on the development of the general-purpose
digital computer, which has further accelerated our involvement with the
theory and practice of computation.
Since the appearance of Turing's model there have, of course, been others
which have concerned and benefited us in computing. I think, however, that
only one has had an effect as great as Turing's: the formal mechanism
called ALGOL Many will immediately disagree, pointing out that too few of
us have understood it or used it.
While such has, unhappily, been the case, it is not the point. The impulse
given by ALGOL to the development of research in computer science is
relevant while the number of adherents is not. ALGOL, too, has mobilized
our thoughts and has provided us with a basis for our arguments.
--047d7b450b100959e604d85a5320
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Both knowledge and wisdom extend man&#39;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 &quot;Turing =
machine&quot;--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&#39;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&#39;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--

View file

@ -0,0 +1,35 @@
MIME-Version: 1.0
From: "Maurice V. Wilkes" <maurice.wilkes@example.com>
Date: Wed, 30 Aug 1967 12:00:00 -0400
Message-ID: <turing1967@example.com>
Subject: Computers Then and Now
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=047d7b5d9bdd0d571a04d85aec30
--047d7b5d9bdd0d571a04d85aec30
Content-Type: text/plain; charset=UTF-8
I do not imagine that many of the Turing lecturers who will follow me will
be people who were acquainted with Alan Turing. The work on computable
numbers, for which he is famous, was published in 1936 before digital
computers existed. Later he became one of the first of a distinguished
succession of able mathematicians who have made contributions to the
computer field. He was a colorful figure in the early days of digital
computer development in England, and I would find it difficult to speak of
that period without making some references to him.
--047d7b5d9bdd0d571a04d85aec30
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>I do not imagine that many of the Turing lecturers wh=
o will follow me will be people who were acquainted with Alan Turing. The w=
ork on computable numbers, for which he is famous, was published in 1936 be=
fore digital computers existed. Later he became one of the first of a disti=
nguished succession of able mathematicians who have made contributions to t=
he computer field. He was a colorful figure in the early days of digital co=
mputer development in England, and I would find it difficult to speak of th=
at period without making some references to him.</div>
</div>
--047d7b5d9bdd0d571a04d85aec30--

View file

@ -0,0 +1,40 @@
MIME-Version: 1.0
From: Richard Hamming <richard.hamming@example.com>
Date: Tue, 27 Aug 1968 12:00:00 -0400
Message-ID: <turing1968@example.com>
Subject: One Man's View of Computer Science
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=089e01227b30f6f60004d85af2ae
--089e01227b30f6f60004d85af2ae
Content-Type: text/plain; charset=UTF-8
Let me begin with a few personal words. When one is notified that he has
been elected the ACM Turing lecturer for the year, he is at first
surprised--especially is the nonacademic person surprised by an ACM award.
After a little while the surprise is replaced by a feeling of pleasure.
Still later comes a feeling of "Why me?" With all that has been done and is
being done in computing, why single out me and my work? Well, I suppose
that it has to happen to someone each year, and this
time I am the lucky person. Anyway, let me thank you for the honor you have
given to me and by inference to the Bell Telephone Laboratories where I
work and which has made possible so much of what I have done.
--089e01227b30f6f60004d85af2ae
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Let me begin with a few personal words. When one is n=
otified that he has been elected the ACM Turing lecturer for the year, he i=
s at first surprised--especially is the nonacademic person surprised by an =
ACM award. After a little while the surprise is replaced by a feeling of pl=
easure. Still later comes a feeling of &quot;Why me?&quot; 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--

View file

@ -0,0 +1,35 @@
MIME-Version: 1.0
From: "James H. Wilkinson" <james.wilkinson@example.com>
Date: Tue, 01 Sep 1970 12:00:00 -0400
Message-ID: <turing1970@example.com>
Subject: Some Comments from a Numerical Analyst
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=047d7b5d9bdd9697d504d85ac65f
--047d7b5d9bdd9697d504d85ac65f
Content-Type: text/plain; charset=UTF-8
When at last I recovered from the feeling of shocked elation at being
invited to give the 1970 Turing Award Lecture, I became aware that I must
indeed prepare an appropriate lecture. There appears to be a tradition that
a Turing Lecturer should decide for himself what is expected from him, and
probably for this reason previous lectures have differed considerably in
style and content. However, it was made quite clear that I was to give an
after-luncheon speech and that I would not have the benefit of an overhead
projector or a blackboard.
--047d7b5d9bdd9697d504d85ac65f
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>When at last I recovered from the feeling of shocked =
elation at being invited to give the 1970 Turing Award Lecture, I became aw=
are that I must indeed prepare an appropriate lecture. There appears to be =
a tradition that a Turing Lecturer should decide for himself what is expect=
ed from him, and probably for this reason previous lectures have differed c=
onsiderably in style and content. However, it was made quite clear that I w=
as to give an after-luncheon speech and that I would not have the benefit o=
f an overhead projector or a blackboard.</div>
</div>
--047d7b5d9bdd9697d504d85ac65f--

View file

@ -0,0 +1,32 @@
MIME-Version: 1.0
From: John McCarthy <john.mccarthy@example.com>
Date: Fri, 01 Jan 1971 12:00:00 -0400
Message-ID: <turing1971@example.com>
Subject: Generality in Artificial Intelligence
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=089e01030106b6942904d85ad870
--089e01030106b6942904d85ad870
Content-Type: text/plain; charset=UTF-8
Postscript
My 1971 Turing Award Lecture was entitled "Generality in Artificial
Intelligence." The topic turned out to have been overambitious in that I
discovered that I was unable to put my thoughts on the subject in a
satisfactory written form at that time. It would have been better to have
reviewed previous work rather than attempt something new, but such wasn't
my custom at that time.
--089e01030106b6942904d85ad870
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Postscript</div><div>My 1971 Turing Award Lecture was=
entitled &quot;Generality in Artificial Intelligence.&quot; 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&#39;t my custom at that time.</div>
</div>
--089e01030106b6942904d85ad870--

View file

@ -0,0 +1,27 @@
MIME-Version: 1.0
From: "Edsger W. Dijkstra" <edsger.dijkstra@example.com>
Date: Mon, 02 Aug 1972 12:00:00 -0500
Message-ID: <turing1972@example.com>
Subject: The Humble Programmer
To: Alan Turing <alan.turing@example.com>
Content-Type: text/plain; charset=UTF-8; format=flowed
As a result of a long sequence of coincidences I entered the programming
profession officially on the first spring morning of 1952, and as far as
I have been able to trace, I was the first Dutchman to do so in my
country. In retrospect the most amazing thing is the slowness with which,
at least in my part of the world, the programming profession emerged, a
slowness which is now hard to believe. But I am grateful for two vivid
recollections from that period that establish that slowness beyond any
doubt.
After having programmed for some three years, I had a discussion with
van Wijngaarden, who was then my boss at the Mathematical Centre in
Amsterdam - a discussion for which I shall remain grateful to him
as long as I live. The point was that I was supposed to study theoretical
physics at the University of Leiden simultaneously, and as I found the
two activities harder and harder to combine, I had to make up my
mind, either to stop programming and become a real, respectable theoretical
physicist, or to carry my study of physics to a formal completion only,
with a minimum of effort, and to become..., yes what? A programmer?
But was that a respectable profession? After all, what was programming?

View file

@ -0,0 +1,30 @@
MIME-Version: 1.0
From: Allen Newell <allen.newell@example.com>
Cc: Herbert Simon <herbert.simon@example.com>
Date: Mon, 20 Oct 1975 12:00:00 -0500
Message-ID: <turing1975@example.com>
Subject: Computer Science as Empirical Inquiry: Symbols and Search
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=047d7b450b1092035304d85abf33
--047d7b450b1092035304d85abf33
Content-Type: text/plain; charset=UTF-8
Computer science is the study of the phenomena surrounding computers. The
founders of this society understood this very well when they called
themselves the Association for Computing Machinery. The machine---not just
the hardware, but the programmed, living machine--is the organism we study.
--047d7b450b1092035304d85abf33
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr">Computer science is the study of the phenomena surrounding=
computers. The founders of this society understood this very well when the=
y called themselves the Association for Computing Machinery. The machine---=
not just the hardware, but the programmed, living machine--is the organism =
we study.<br>
</div>
--047d7b450b1092035304d85abf33--

View file

@ -0,0 +1,39 @@
MIME-Version: 1.0
From: "John W. Backus" <john.backus@example.com>
Date: Mon, 17 Oct 1977 12:00:00 -0700
Message-ID: <turing1977@example.com>
Subject: Can Programming Be Liberated from the von Neumann Style? A Functional
Style and Its Algebra of Programs
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=047d7b5d9bdd8a36e804d85ade47
--047d7b5d9bdd8a36e804d85ade47
Content-Type: text/plain; charset=UTF-8
Conventional programming languages are growing ever more enormous, but not
stronger. Inherent defects at the most basic level cause them to be both
fat and weak: their primitive word-at-a-time style of programming inherited
from their common ancestor--the von Neumann computer, their close coupling
of semantics to state transitions, their division of programming into a
world of expressions and a world of statements, their inability to
effectively use powerful combining forms for building new programs from
existing ones, and their lack of useful mathematical properties for
reasoning about
programs.
--047d7b5d9bdd8a36e804d85ade47
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Conventional programming languages are growing ever m=
ore enormous, but not stronger. Inherent defects at the most basic level ca=
use them to be both fat and weak: their primitive word-at-a-time style of p=
rogramming inherited from their common ancestor--the von Neumann computer, =
their close coupling of semantics to state transitions, their division of p=
rogramming into a world of expressions and a world of statements, their ina=
bility to effectively use powerful combining forms for building new program=
s from existing ones, and their lack of useful mathematical properties for =
reasoning about=C2=A0</div>
<div>programs.</div></div>
--047d7b5d9bdd8a36e804d85ade47--

View file

@ -0,0 +1,36 @@
MIME-Version: 1.0
From: Robert Floyd <robert.floyd@example.com>
Date: Mon, 04 Dec 1978 12:00:00 -0500
Message-ID: <turing1978@example.com>
Subject: The Paradigms of Programming
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=089e0118419206e64304d85af860
--089e0118419206e64304d85af860
Content-Type: text/plain; charset=UTF-8
Today I want to talk about the paradigms of programming, how they affect
our success as designers of computer programs, how they should be taught,
and how they should be embodied in our programming languages.
A familiar example of a paradigm of programming is the technique of
structured programming, which appears to be the dominant paradigm in most
current treatments of programming methodology. Structured programming, as
formulated by Dijkstra, Wirth, and Parnas, among others, consists of two
phases.
--089e0118419206e64304d85af860
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Today I want to talk about the paradigms of programmi=
ng, how they affect our success as designers of computer programs, how they=
should be taught, and how they should be embodied in our programming langu=
ages.=C2=A0</div>
<div>A familiar example of a paradigm of programming is the technique of st=
ructured programming, which appears to be the dominant paradigm in most cur=
rent treatments of programming methodology. Structured programming, as form=
ulated by Dijkstra, Wirth, and Parnas, among others, consists of two phases=
.=C2=A0</div>
</div>
--089e0118419206e64304d85af860--

View file

@ -0,0 +1,33 @@
MIME-Version: 1.0
From: "Kenneth E. Iverson" <kenneth.iverson@example.com>
Date: Mon, 29 Oct 1979 12:00:00 -0500
Message-ID: <turing1979@example.com>
Subject: Notation as a Tool of Thought
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=20cf30549cad76254e04d85ae4df
--20cf30549cad76254e04d85ae4df
Content-Type: text/plain; charset=UTF-8
The importance of nomenclature, notation, and language as tools of thought
has long been recognized. In chemistry and in botany, for example, the
establishment of systems of nomenclature by Lavoisier and Linnaeus did much
to stimulate and to channel later investigation. Concerning language,
George Boole in his Laws off Thought asserted "That language is an
instrument of human reason, and not merely a medium for the expression of
thought, is a truth generally admitted."
--20cf30549cad76254e04d85ae4df
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>The importance of nomenclature, notation, and languag=
e as tools of thought has long been recognized. In chemistry and in botany,=
for example, the establishment of systems of nomenclature by Lavoisier and=
Linnaeus did much to stimulate and to channel later investigation. Concern=
ing language, George Boole in his Laws off Thought asserted &quot;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.&quot;</div>
</div>
--20cf30549cad76254e04d85ae4df--

View file

@ -0,0 +1,51 @@
MIME-Version: 1.0
From: "Edgar F. Codd" <edgar.codd@example.com>
Date: Wed, 11 Nov 1981 12:00:00 -0800
Message-ID: <turing1981@example.com>
Subject: Relational Database: A Practical Foundation for Productivity
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=047d7bfd026c782f2404d85ab4b8
--047d7bfd026c782f2404d85ab4b8
Content-Type: text/plain; charset=UTF-8
It is well known that the growth in demands from end users for new
applications is outstripping the capability of data processing departments
to implement the corresponding application programs. There are two
complementary approaches to attacking this problem (and both approaches are
needed): one is to put end users into direct touch with the information
stored in computers; the other is to increase the productivity of data
processing professionals in the development of application programs. It is
less well known that a single technology, relational database management,
provides a practical foundation for both approaches. It is explained why
this
is so.
While developing this productivity theme, it is noted that the time has
come to draw a very sharp line between relational and non-relational
database systems, so that the label "relational" will not be used in
misleading ways.
The key to drawing this line is something called a "relational processing
capability."
--047d7bfd026c782f2404d85ab4b8
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>It is well known that the growth in demands from end =
users for new applications is outstripping the capability of data processin=
g departments to implement the corresponding application programs. There ar=
e two complementary approaches to attacking this problem (and both approach=
es are needed): one is to put end users into direct touch with the informat=
ion stored in computers; the other is to increase the productivity of data =
processing professionals in the development of application programs. It is =
less well known that a single technology, relational database management, p=
rovides a practical foundation for both approaches. It is explained why thi=
s=C2=A0</div>
<div><div>is so.=C2=A0</div><div>While developing this productivity theme, =
it is noted that the time has come to draw a very sharp line between relati=
onal and non-relational database systems, so that the label &quot;relationa=
l&quot; will not be used in misleading ways.=C2=A0</div>
<div>The key to drawing this line is something called a &quot;relational pr=
ocessing capability.&quot;</div></div></div>
--047d7bfd026c782f2404d85ab4b8--

View file

@ -0,0 +1,46 @@
MIME-Version: 1.0
From: Dennis Ritchie <dennis.ritchie@example.com>
Date: Mon, 24 Oct 1983 12:00:00 -0400
Message-ID: <turing1983@example.com>
Subject: Reflections on Software Research
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=bcaec54fbb2250035a04d85aabcd
--bcaec54fbb2250035a04d85aabcd
Content-Type: text/plain; charset=UTF-8
The UNIX operating system has suddenly become news, but it is not new. It
began in 1969 when Ken Thompson discovered a little-used PDP-7 computer and
set out to fashion a computing environment that he liked, His work soon
attracted me; I joined in the enterprise, though most of the ideas, and
most of the work for that matter, were his. Before long, others from our
group in the research area of AT&T Bell Laboratories were using the system;
Joe Ossanna, Doug Mcllroy, and
Bob Morris were especially enthusiastic critics and contributors, tn 1971,
we acquired a PDP-11, and by the end of that year we were supporting our
first real users: three typists entering patent applications. In 1973, the
system was rewritten in the C language, and in that year, too, it was first
described publicly at the Operating Systems Principles conference; the
resulting paper appeared in Communications of the ACM the next year.
--bcaec54fbb2250035a04d85aabcd
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>The UNIX operating system has suddenly become news, b=
ut it is not new. It began in 1969 when Ken Thompson discovered a little-us=
ed PDP-7 computer and set out to fashion a computing environment that he li=
ked, His work soon attracted me; I joined in the enterprise, though most of=
the ideas, and most of the work for that matter, were his. Before long, ot=
hers from our group in the research area of AT&amp;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--

View file

@ -0,0 +1,42 @@
MIME-Version: 1.0
From: John Cocke <john.cocke@example.com>
Date: Mon, 16 Feb 1987 12:00:00 -0600
Message-ID: <turing1987@example.com>
Subject: The Search for Performance in Scientific Processors
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=047d7bfd079665fb2c04d85ad0bc
--047d7bfd079665fb2c04d85ad0bc
Content-Type: text/plain; charset=UTF-8
I am honored and grateful to have been selected to join the ranks of ACM
Turing Award winners. I probably have spent too much of my life thinking
about computers, but I do not regret it a bit. I was fortunate to enter the
field of computing in its infancy and participate in its explosive growth.
The rapid evolution of the underlying technologies in the past 30 years has
not only provided an exciting environment, but has also presented a
constant stream of intellectual challenges to those of us trying to harness
this power and squeeze it to the last ounce. I hasten to say, especially to
the
younger members of the audience, there is no end in sight. As a matter of
fact, I believe the next thirty years will be even more exciting and rich
with challenges.
--047d7bfd079665fb2c04d85ad0bc
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>I am honored and grateful to have been selected to jo=
in the ranks of ACM Turing Award winners. I probably have spent too much of=
my life thinking about computers, but I do not regret it a bit. I was fort=
unate to enter the field of computing in its infancy and participate in its=
explosive growth. The rapid evolution of the underlying technologies in th=
e past 30 years has not only provided an exciting environment, but has also=
presented a constant stream of intellectual challenges to those of us tryi=
ng to harness this power and squeeze it to the last ounce. I hasten to say,=
especially to the=C2=A0</div>
<div>younger members of the audience, there is no end in sight. As a matter=
of fact, I believe the next thirty years will be even more exciting and ri=
ch with challenges.=C2=A0</div></div>
--047d7bfd079665fb2c04d85ad0bc--

View file

@ -0,0 +1,44 @@
MIME-Version: 1.0
From: Robin Milner <robin.milner@example.com>
Date: Mon, 18 Nov 1991 12:00:00 -0700
Message-ID: <turing1991@example.com>
Subject: Elements of Interaction
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=047d7b86e6de64aecb04d85affff
--047d7b86e6de64aecb04d85affff
Content-Type: text/plain; charset=UTF-8
I am greatly honored to receive this award, bearing the name of Alan
Turing. Perhaps Turing would be pleased that it should go to someone
educated at his old college, King's College at Cambridge. While there in
1956 I wrote my first computer program; it was on the EDSAC. Of course
EDSAC made history. But I am ashamed to say it did not lure me into
computing, and I ignored computers for four years. In 1960 I thought that
computers might be more peaceful to handle than schoolchildren--I was then
a teacher--so I applied for a job at Ferranti in London, at the time of
Pegasus. I was asked at the interview whether I would like to devote my
life to computers. This daunting notion had never crossed my mind. Well,
here I am still, and I have had the lucky chance to grow alongside computer
science.
--047d7b86e6de64aecb04d85affff
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>I am greatly honored to receive this award, bearing t=
he name of Alan Turing. Perhaps Turing would be pleased that it should go t=
o someone educated at his old college, King&#39;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--

View file

@ -0,0 +1,28 @@
MIME-Version: 1.0
From: Amir Pnueli <amir.pnueli@example.com>
Date: Thu, 15 Feb 1996 12:00:00 -0500
Message-ID: <turing1996@example.com>
Subject: Verification Engineering: A Future Profession
To: Alan Turing <alan.turing@example.com>
Content-Type: multipart/alternative; boundary=bcaec54fbb222acf6704d85aa523
--bcaec54fbb222acf6704d85aa523
Content-Type: text/plain; charset=UTF-8
It is time that formal verification (of both software and hardware systems)
be demoted from an art practiced by the enlightened few to an activity
routinely and mundanely performed by a cadre of Verification Engineers (a
new profession), as a standard part of the system development process.
--bcaec54fbb222acf6704d85aa523
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>It is time that formal verification (of both software=
and hardware systems) be demoted from an art practiced by the enlightened =
few to an activity routinely and mundanely performed by a cadre of Verifica=
tion Engineers (a new profession), as a standard part of the system develop=
ment process.</div>
</div>
--bcaec54fbb222acf6704d85aa523--

View file

@ -0,0 +1,24 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.backend.api)
implementation(projects.core.common)
api(projects.core.outcome)
api(projects.feature.mail.account.api)
api(projects.mail.protocols.imap)
api(projects.mail.protocols.smtp)
implementation(projects.feature.mail.folder.api)
implementation(libs.kotlinx.coroutines.core)
testImplementation(projects.core.logging.testing)
testImplementation(projects.mail.testing)
testImplementation(projects.backend.testing)
testImplementation(libs.mime4j.dom)
}

View file

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

View file

@ -0,0 +1,22 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
import net.thunderbird.core.common.exception.MessagingException
internal class CommandDelete(private val imapStore: ImapStore) {
@Throws(MessagingException::class)
fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
val remoteFolder = imapStore.getFolder(folderServerId)
try {
remoteFolder.open(OpenMode.READ_WRITE)
val messages = messageServerIds.map { uid -> remoteFolder.getMessage(uid) }
remoteFolder.deleteMessages(messages)
} finally {
remoteFolder.close()
}
}
}

View file

@ -0,0 +1,20 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
import net.thunderbird.core.common.exception.MessagingException
internal class CommandDeleteAll(private val imapStore: ImapStore) {
@Throws(MessagingException::class)
fun deleteAll(folderServerId: String) {
val remoteFolder = imapStore.getFolder(folderServerId)
try {
remoteFolder.open(OpenMode.READ_WRITE)
remoteFolder.deleteAllMessages()
} finally {
remoteFolder.close()
}
}
}

View file

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

View file

@ -0,0 +1,34 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
import net.thunderbird.core.logging.legacy.Log
internal class CommandExpunge(private val imapStore: ImapStore) {
fun expunge(folderServerId: String) {
Log.d("processPendingExpunge: folder = %s", folderServerId)
val remoteFolder = imapStore.getFolder(folderServerId)
try {
remoteFolder.open(OpenMode.READ_WRITE)
remoteFolder.expunge()
Log.d("processPendingExpunge: complete for folder = %s", folderServerId)
} finally {
remoteFolder.close()
}
}
fun expungeMessages(folderServerId: String, messageServerIds: List<String>) {
val remoteFolder = imapStore.getFolder(folderServerId)
try {
remoteFolder.open(OpenMode.READ_WRITE)
remoteFolder.expungeUids(messageServerIds)
} finally {
remoteFolder.close()
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,19 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
internal class CommandMarkAllAsRead(private val imapStore: ImapStore) {
fun markAllAsRead(folderServerId: String) {
val remoteFolder = imapStore.getFolder(folderServerId)
try {
remoteFolder.open(OpenMode.READ_WRITE)
remoteFolder.setFlagsForAllMessages(setOf(Flag.SEEN), true)
} finally {
remoteFolder.close()
}
}
}

View file

@ -0,0 +1,66 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.store.imap.ImapFolder
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
import net.thunderbird.core.logging.legacy.Log
internal class CommandMoveOrCopyMessages(private val imapStore: ImapStore) {
fun moveMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>? {
return moveOrCopyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds, false)
}
fun copyMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>? {
return moveOrCopyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds, true)
}
private fun moveOrCopyMessages(
srcFolder: String,
destFolder: String,
uids: Collection<String>,
isCopy: Boolean,
): Map<String, String>? {
var remoteSrcFolder: ImapFolder? = null
var remoteDestFolder: ImapFolder? = null
return try {
remoteSrcFolder = imapStore.getFolder(srcFolder)
if (uids.isEmpty()) {
Log.i("moveOrCopyMessages: no remote messages to move, skipping")
return null
}
remoteSrcFolder.open(OpenMode.READ_WRITE)
val messages = uids.map { uid -> remoteSrcFolder.getMessage(uid) }
Log.d(
"moveOrCopyMessages: source folder = %s, %d messages, destination folder = %s, isCopy = %s",
srcFolder,
messages.size,
destFolder,
isCopy,
)
remoteDestFolder = imapStore.getFolder(destFolder)
if (isCopy) {
remoteSrcFolder.copyMessages(messages, remoteDestFolder)
} else {
remoteSrcFolder.moveMessages(messages, remoteDestFolder)
}
} finally {
remoteSrcFolder?.close()
remoteDestFolder?.close()
}
}
}

View file

@ -0,0 +1,64 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.updateFolders
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.store.imap.FolderListItem
import com.fsck.k9.mail.store.imap.ImapStore
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
private const val TAG = "CommandRefreshFolderList"
internal class CommandRefreshFolderList(
private val backendStorage: BackendStorage,
private val imapStore: ImapStore,
private val logger: Logger = Log,
) {
private val LegacyFolderListItem.normalizedServerId: String
get() = imapStore.combinedPrefix?.let {
serverId.removePrefix(prefix = it)
} ?: serverId
fun refreshFolderList(): FolderPathDelimiter? {
logger.verbose(TAG) { "refreshFolderList() called" }
val folders = imapStore.getFolders()
val folderPathDelimiter = folders.firstOrNull { it.folderPathDelimiter != null }?.folderPathDelimiter
val foldersOnServer = folders.toLegacyFolderList()
val oldFolderServerIds = backendStorage.getFolderServerIds()
backendStorage.updateFolders {
val foldersToCreate = mutableListOf<FolderInfo>()
for (folder in foldersOnServer) {
if (folder.normalizedServerId !in oldFolderServerIds) {
foldersToCreate.add(FolderInfo(folder.normalizedServerId, folder.name, folder.type))
} else {
changeFolder(folder.normalizedServerId, folder.name, folder.type)
}
}
logger.verbose(TAG) { "refreshFolderList: foldersToCreate = $foldersToCreate" }
createFolders(foldersToCreate)
val newFolderServerIds = foldersOnServer.map { it.normalizedServerId }
val removedFolderServerIds = oldFolderServerIds - newFolderServerIds
logger.verbose(TAG) { "refreshFolderList: folders to remove = $removedFolderServerIds" }
deleteFolders(removedFolderServerIds)
}
return folderPathDelimiter
}
}
private fun List<FolderListItem>.toLegacyFolderList(): List<LegacyFolderListItem> {
return this
.map { LegacyFolderListItem(it.serverId, it.name, it.type) }
}
private data class LegacyFolderListItem(
val serverId: String,
val name: String,
val type: FolderType,
)

View file

@ -0,0 +1,27 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
internal class CommandSearch(private val imapStore: ImapStore) {
fun search(
folderServerId: String,
query: String?,
requiredFlags: Set<Flag>?,
forbiddenFlags: Set<Flag>?,
performFullTextSearch: Boolean,
): List<String> {
val folder = imapStore.getFolder(folderServerId)
try {
folder.open(OpenMode.READ_ONLY)
return folder.search(query, requiredFlags, forbiddenFlags, performFullTextSearch)
.sortedWith(UidReverseComparator())
.map { it.uid }
} finally {
folder.close()
}
}
}

View file

@ -0,0 +1,23 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
internal class CommandSetFlag(private val imapStore: ImapStore) {
fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) {
if (messageServerIds.isEmpty()) return
val remoteFolder = imapStore.getFolder(folderServerId)
try {
remoteFolder.open(OpenMode.READ_WRITE)
val messages = messageServerIds.map { uid -> remoteFolder.getMessage(uid) }
remoteFolder.setFlags(messages, setOf(flag), newState)
} finally {
remoteFolder.close()
}
}
}

View file

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

View file

@ -0,0 +1,153 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.backend.api.Backend
import com.fsck.k9.backend.api.BackendPusher
import com.fsck.k9.backend.api.BackendPusherCallback
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.mail.BodyFactory
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.power.PowerManager
import com.fsck.k9.mail.store.imap.IdleRefreshManager
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.transport.smtp.SmtpTransport
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
class ImapBackend(
private val accountName: String,
backendStorage: BackendStorage,
internal val imapStore: ImapStore,
private val powerManager: PowerManager,
private val idleRefreshManager: IdleRefreshManager,
private val pushConfigProvider: ImapPushConfigProvider,
private val smtpTransport: SmtpTransport,
) : Backend {
private val imapSync = ImapSync(accountName, backendStorage, imapStore)
private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage, imapStore)
private val commandSetFlag = CommandSetFlag(imapStore)
private val commandMarkAllAsRead = CommandMarkAllAsRead(imapStore)
private val commandExpunge = CommandExpunge(imapStore)
private val commandMoveOrCopyMessages = CommandMoveOrCopyMessages(imapStore)
private val commandDelete = CommandDelete(imapStore)
private val commandDeleteAll = CommandDeleteAll(imapStore)
private val commandSearch = CommandSearch(imapStore)
private val commandDownloadMessage = CommandDownloadMessage(backendStorage, imapStore)
private val commandFetchMessage = CommandFetchMessage(imapStore)
private val commandFindByMessageId = CommandFindByMessageId(imapStore)
private val commandUploadMessage = CommandUploadMessage(imapStore)
override val supportsFlags = true
override val supportsExpunge = true
override val supportsMove = true
override val supportsCopy = true
override val supportsUpload = true
override val supportsTrashFolder = true
override val supportsSearchByDate = true
override val supportsFolderSubscriptions = true
override val isPushCapable = true
override fun refreshFolderList(): FolderPathDelimiter? {
return commandRefreshFolderList.refreshFolderList()
}
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
imapSync.sync(folderServerId, syncConfig, listener)
}
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
imapSync.downloadMessage(syncConfig, folderServerId, messageServerId)
}
override fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
commandDownloadMessage.downloadMessageStructure(folderServerId, messageServerId)
}
override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
commandDownloadMessage.downloadCompleteMessage(folderServerId, messageServerId)
}
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) {
commandSetFlag.setFlag(folderServerId, messageServerIds, flag, newState)
}
override fun markAllAsRead(folderServerId: String) {
commandMarkAllAsRead.markAllAsRead(folderServerId)
}
override fun expunge(folderServerId: String) {
commandExpunge.expunge(folderServerId)
}
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
commandDelete.deleteMessages(folderServerId, messageServerIds)
}
override fun deleteAllMessages(folderServerId: String) {
commandDeleteAll.deleteAll(folderServerId)
}
override fun moveMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>? {
return commandMoveOrCopyMessages.moveMessages(sourceFolderServerId, targetFolderServerId, messageServerIds)
}
override fun moveMessagesAndMarkAsRead(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>? {
val uidMapping = commandMoveOrCopyMessages.moveMessages(
sourceFolderServerId,
targetFolderServerId,
messageServerIds,
)
if (uidMapping != null) {
setFlag(targetFolderServerId, uidMapping.values.toList(), Flag.SEEN, true)
}
return uidMapping
}
override fun copyMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>? {
return commandMoveOrCopyMessages.copyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds)
}
override fun search(
folderServerId: String,
query: String?,
requiredFlags: Set<Flag>?,
forbiddenFlags: Set<Flag>?,
performFullTextSearch: Boolean,
): List<String> {
return commandSearch.search(folderServerId, query, requiredFlags, forbiddenFlags, performFullTextSearch)
}
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
commandFetchMessage.fetchPart(folderServerId, messageServerId, part, bodyFactory)
}
override fun findByMessageId(folderServerId: String, messageId: String): String? {
return commandFindByMessageId.findByMessageId(folderServerId, messageId)
}
override fun uploadMessage(folderServerId: String, message: Message): String? {
return commandUploadMessage.uploadMessage(folderServerId, message)
}
override fun sendMessage(message: Message) {
smtpTransport.sendMessage(message)
}
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
return ImapBackendPusher(imapStore, powerManager, idleRefreshManager, pushConfigProvider, callback, accountName)
}
}

View file

@ -0,0 +1,251 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.backend.api.BackendPusher
import com.fsck.k9.backend.api.BackendPusherCallback
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.power.PowerManager
import com.fsck.k9.mail.store.imap.IdleRefreshManager
import com.fsck.k9.mail.store.imap.IdleRefreshTimeoutProvider
import com.fsck.k9.mail.store.imap.IdleRefreshTimer
import com.fsck.k9.mail.store.imap.ImapStore
import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.logging.legacy.Log
private const val IO_ERROR_TIMEOUT = 5 * 60 * 1000L
private const val UNEXPECTED_ERROR_TIMEOUT = 60 * 60 * 1000L
/**
* Manages [ImapFolderPusher] instances that listen for changes to individual folders.
*/
internal class ImapBackendPusher(
private val imapStore: ImapStore,
private val powerManager: PowerManager,
private val idleRefreshManager: IdleRefreshManager,
private val pushConfigProvider: ImapPushConfigProvider,
private val callback: BackendPusherCallback,
private val accountName: String,
backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : BackendPusher, ImapPusherCallback {
private val coroutineScope = CoroutineScope(backgroundDispatcher)
private val lock = Any()
private val pushFolders = mutableMapOf<String, ImapFolderPusher>()
private var currentFolderServerIds: Collection<String> = emptySet()
private val pushFolderSleeping = mutableMapOf<String, IdleRefreshTimer>()
private val idleRefreshTimeoutProvider = object : IdleRefreshTimeoutProvider {
override val idleRefreshTimeoutMs
get() = currentIdleRefreshMs
}
@Volatile
private var currentMaxPushFolders = 0
@Volatile
private var currentIdleRefreshMs = 15 * 60 * 1000L
override fun start() {
coroutineScope.launch {
pushConfigProvider.maxPushFoldersFlow.collect { maxPushFolders ->
currentMaxPushFolders = maxPushFolders
updateFolders()
}
}
coroutineScope.launch {
pushConfigProvider.idleRefreshMinutesFlow.collect { idleRefreshMinutes ->
currentIdleRefreshMs = idleRefreshMinutes * 60 * 1000L
refreshFolderTimers()
}
}
}
private fun refreshFolderTimers() {
synchronized(lock) {
for (pushFolder in pushFolders.values) {
pushFolder.refresh()
}
}
}
override fun updateFolders(folderServerIds: Collection<String>) {
updateFolders(folderServerIds, currentMaxPushFolders)
}
private fun updateFolders() {
val currentFolderServerIds = synchronized(lock) { currentFolderServerIds }
updateFolders(currentFolderServerIds, currentMaxPushFolders)
}
private fun updateFolders(folderServerIds: Collection<String>, maxPushFolders: Int) {
Log.v("ImapBackendPusher.updateFolders(): %s", folderServerIds)
val pushFolderServerIds = if (folderServerIds.size > maxPushFolders) {
folderServerIds.take(maxPushFolders).also { pushFolderServerIds ->
Log.v("..limiting Push to %d folders: %s", maxPushFolders, pushFolderServerIds)
}
} else {
folderServerIds
}
val stopFolderPushers: List<ImapFolderPusher>
val startFolderPushers: List<ImapFolderPusher>
synchronized(lock) {
currentFolderServerIds = folderServerIds
val oldRunningFolderServerIds = pushFolders.keys
val oldFolderServerIds = oldRunningFolderServerIds + pushFolderSleeping.keys
val removeFolderServerIds = oldFolderServerIds - pushFolderServerIds
stopFolderPushers = removeFolderServerIds
.asSequence()
.onEach { folderServerId -> cancelRetryTimer(folderServerId) }
.map { folderServerId -> pushFolders.remove(folderServerId) }
.filterNotNull()
.toList()
val startFolderServerIds = pushFolderServerIds - oldRunningFolderServerIds
startFolderPushers = startFolderServerIds
.asSequence()
.filterNot { folderServerId -> isWaitingForRetry(folderServerId) }
.onEach { folderServerId -> pushFolderSleeping.remove(folderServerId) }
.map { folderServerId ->
createImapFolderPusher(folderServerId).also { folderPusher ->
pushFolders[folderServerId] = folderPusher
}
}
.toList()
}
for (folderPusher in stopFolderPushers) {
folderPusher.stop()
}
for (folderPusher in startFolderPushers) {
folderPusher.start()
}
}
override fun stop() {
Log.v("ImapBackendPusher.stop()")
coroutineScope.cancel()
synchronized(lock) {
for (pushFolder in pushFolders.values) {
pushFolder.stop()
}
pushFolders.clear()
for (retryTimer in pushFolderSleeping.values) {
retryTimer.cancel()
}
pushFolderSleeping.clear()
currentFolderServerIds = emptySet()
}
}
override fun reconnect() {
Log.v("ImapBackendPusher.reconnect()")
synchronized(lock) {
for (pushFolder in pushFolders.values) {
pushFolder.stop()
}
pushFolders.clear()
for (retryTimer in pushFolderSleeping.values) {
retryTimer.cancel()
}
pushFolderSleeping.clear()
}
imapStore.closeAllConnections()
updateFolders()
}
private fun createImapFolderPusher(folderServerId: String): ImapFolderPusher {
return ImapFolderPusher(
imapStore,
powerManager,
idleRefreshManager,
this,
accountName,
folderServerId,
idleRefreshTimeoutProvider,
)
}
override fun onPushEvent(folderServerId: String) {
callback.onPushEvent(folderServerId)
idleRefreshManager.resetTimers()
}
override fun onPushError(folderServerId: String, exception: Exception) {
synchronized(lock) {
pushFolders.remove(folderServerId)
when (exception) {
is AuthenticationFailedException -> {
Log.v(exception, "Authentication failure when attempting to use IDLE")
// TODO: This could be happening because of too many connections to the host. Ideally we'd want to
// detect this case and use a lower timeout.
startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT)
}
is IOException -> {
Log.v(exception, "I/O error while trying to use IDLE")
startRetryTimer(folderServerId, IO_ERROR_TIMEOUT)
}
is MessagingException -> {
Log.v(exception, "MessagingException")
if (exception.isPermanentFailure) {
startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT)
} else {
startRetryTimer(folderServerId, IO_ERROR_TIMEOUT)
}
}
else -> {
Log.v(exception, "Unexpected error")
startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT)
}
}
if (pushFolders.isEmpty()) {
callback.onPushError(exception)
}
}
}
override fun onPushNotSupported() {
callback.onPushNotSupported()
}
private fun startRetryTimer(folderServerId: String, timeout: Long) {
Log.v("ImapBackendPusher for folder %s sleeping for %d ms", folderServerId, timeout)
pushFolderSleeping[folderServerId] = idleRefreshManager.startTimer(timeout, ::restartFolderPushers)
}
private fun cancelRetryTimer(folderServerId: String) {
Log.v("Canceling ImapBackendPusher retry timer for folder %s", folderServerId)
pushFolderSleeping.remove(folderServerId)?.cancel()
}
private fun isWaitingForRetry(folderServerId: String): Boolean {
return pushFolderSleeping[folderServerId]?.isWaiting == true
}
private fun restartFolderPushers() {
Log.v("Refreshing ImapBackendPusher (at least one retry timer has expired)")
updateFolders()
}
}

View file

@ -0,0 +1,101 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.power.PowerManager
import com.fsck.k9.mail.store.imap.IdleRefreshManager
import com.fsck.k9.mail.store.imap.IdleRefreshTimeoutProvider
import com.fsck.k9.mail.store.imap.IdleResult
import com.fsck.k9.mail.store.imap.ImapFolderIdler
import com.fsck.k9.mail.store.imap.ImapStore
import kotlin.concurrent.thread
import net.thunderbird.core.logging.legacy.Log
/**
* Listens for changes to an IMAP folder in a dedicated thread.
*/
class ImapFolderPusher(
private val imapStore: ImapStore,
private val powerManager: PowerManager,
private val idleRefreshManager: IdleRefreshManager,
private val callback: ImapPusherCallback,
private val accountName: String,
private val folderServerId: String,
private val idleRefreshTimeoutProvider: IdleRefreshTimeoutProvider,
) {
@Volatile
private var folderIdler: ImapFolderIdler? = null
@Volatile
private var stopPushing = false
fun start() {
Log.v("Starting ImapFolderPusher for %s / %s", accountName, folderServerId)
thread(name = "ImapFolderPusher-$accountName-$folderServerId") {
Log.v("Starting ImapFolderPusher thread for %s / %s", accountName, folderServerId)
runPushLoop()
Log.v("Exiting ImapFolderPusher thread for %s / %s", accountName, folderServerId)
}
}
fun refresh() {
Log.v("Refreshing ImapFolderPusher for %s / %s", accountName, folderServerId)
folderIdler?.refresh()
}
fun stop() {
Log.v("Stopping ImapFolderPusher for %s / %s", accountName, folderServerId)
stopPushing = true
folderIdler?.stop()
}
private fun runPushLoop() {
val wakeLock = powerManager.newWakeLock("ImapFolderPusher-$accountName-$folderServerId")
wakeLock.acquire()
performInitialSync()
val folderIdler = ImapFolderIdler.create(
idleRefreshManager,
wakeLock,
imapStore,
folderServerId,
idleRefreshTimeoutProvider,
).also {
folderIdler = it
}
try {
while (!stopPushing) {
when (folderIdler.idle()) {
IdleResult.SYNC -> {
callback.onPushEvent(folderServerId)
}
IdleResult.STOPPED -> {
// ImapFolderIdler only stops when we ask it to.
// But it can't hurt to make extra sure we exit the loop.
stopPushing = true
}
IdleResult.NOT_SUPPORTED -> {
stopPushing = true
callback.onPushNotSupported()
}
}
}
} catch (e: Exception) {
Log.v(e, "Exception in ImapFolderPusher")
this.folderIdler = null
callback.onPushError(folderServerId, e)
}
wakeLock.release()
}
private fun performInitialSync() {
callback.onPushEvent(folderServerId)
}
}

View file

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

View file

@ -0,0 +1,7 @@
package com.fsck.k9.backend.imap
interface ImapPusherCallback {
fun onPushEvent(folderServerId: String)
fun onPushError(folderServerId: String, exception: Exception)
fun onPushNotSupported()
}

View file

@ -0,0 +1,732 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.backend.api.BackendFolder
import com.fsck.k9.backend.api.BackendFolder.MoreMessages
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.helper.ExceptionHelper
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.BodyFactory
import com.fsck.k9.mail.DefaultBodyFactory
import com.fsck.k9.mail.FetchProfile
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.MessageDownloadState
import com.fsck.k9.mail.internet.MessageExtractor
import com.fsck.k9.mail.store.imap.FetchListener
import com.fsck.k9.mail.store.imap.ImapFolder
import com.fsck.k9.mail.store.imap.ImapMessage
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
import java.util.Collections
import java.util.Date
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.max
import net.thunderbird.core.logging.legacy.Log
internal class ImapSync(
private val accountName: String,
private val backendStorage: BackendStorage,
private val imapStore: ImapStore,
) {
fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) {
synchronizeMailboxSynchronous(folder, syncConfig, listener)
}
private fun synchronizeMailboxSynchronous(folder: String, syncConfig: SyncConfig, listener: SyncListener) {
Log.i("Synchronizing folder %s:%s", accountName, folder)
var remoteFolder: ImapFolder? = null
var backendFolder: BackendFolder? = null
var newHighestKnownUid: Long = 0
try {
Log.v("SYNC: About to get local folder %s", folder)
backendFolder = backendStorage.getFolder(folder)
listener.syncStarted(folder)
Log.v("SYNC: About to get remote folder %s", folder)
remoteFolder = imapStore.getFolder(folder)
/*
* Synchronization process:
*
Open the folder
Upload any local messages that are marked as PENDING_UPLOAD (Drafts, Sent, Trash)
Get the message count
Get the list of the newest K9.DEFAULT_VISIBLE_LIMIT messages
getMessages(messageCount - K9.DEFAULT_VISIBLE_LIMIT, messageCount)
See if we have each message locally, if not fetch it's flags and envelope
Get and update the unread count for the folder
Update the remote flags of any messages we have locally with an internal date newer than the remote message.
Get the current flags for any messages we have locally but did not just download
Update local flags
For any message we have locally but not remotely, delete the local message to keep cache clean.
Download larger parts of any new messages.
(Optional) Download small attachments in the background.
*/
/*
* Open the remote folder. This pre-loads certain metadata like message count.
*/
Log.v("SYNC: About to open remote folder %s", folder)
if (syncConfig.expungePolicy === ExpungePolicy.ON_POLL) {
Log.d("SYNC: Expunging folder %s:%s", accountName, folder)
if (!remoteFolder.isOpen || remoteFolder.mode != OpenMode.READ_WRITE) {
remoteFolder.open(OpenMode.READ_WRITE)
}
remoteFolder.expunge()
}
remoteFolder.open(OpenMode.READ_ONLY)
listener.syncAuthenticationSuccess()
val uidValidity = remoteFolder.getUidValidity()
val oldUidValidity = backendFolder.getFolderExtraNumber(EXTRA_UID_VALIDITY)
if (oldUidValidity == null && uidValidity != null) {
Log.d("SYNC: Saving UIDVALIDITY for %s", folder)
backendFolder.setFolderExtraNumber(EXTRA_UID_VALIDITY, uidValidity)
} else if (oldUidValidity != null && oldUidValidity != uidValidity) {
Log.d("SYNC: UIDVALIDITY for %s changed; clearing local message cache", folder)
backendFolder.clearAllMessages()
backendFolder.setFolderExtraNumber(EXTRA_UID_VALIDITY, uidValidity!!)
backendFolder.setFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID, 0)
}
/*
* Get the message list from the local store and create an index of
* the uids within the list.
*/
val highestKnownUid = backendFolder.getFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID) ?: 0
var localUidMap: Map<String, Long?>? = backendFolder.getAllMessagesAndEffectiveDates()
/*
* Get the remote message count.
*/
val remoteMessageCount = remoteFolder.messageCount
var visibleLimit = backendFolder.visibleLimit
if (visibleLimit < 0) {
visibleLimit = syncConfig.defaultVisibleLimit
}
val remoteMessages = mutableListOf<ImapMessage>()
val remoteUidMap = mutableMapOf<String, ImapMessage>()
Log.v("SYNC: Remote message count for folder %s is %d", folder, remoteMessageCount)
val earliestDate = syncConfig.earliestPollDate
val earliestTimestamp = earliestDate?.time ?: 0L
var remoteStart = 1
if (remoteMessageCount > 0) {
/* Message numbers start at 1. */
remoteStart = if (visibleLimit > 0) {
max(0, remoteMessageCount - visibleLimit) + 1
} else {
1
}
Log.v(
"SYNC: About to get messages %d through %d for folder %s",
remoteStart,
remoteMessageCount,
folder,
)
val headerProgress = AtomicInteger(0)
listener.syncHeadersStarted(folder)
val remoteMessageArray = remoteFolder.getMessages(remoteStart, remoteMessageCount, earliestDate, null)
val messageCount = remoteMessageArray.size
for (thisMess in remoteMessageArray) {
headerProgress.incrementAndGet()
listener.syncHeadersProgress(folder, headerProgress.get(), messageCount)
val uid = thisMess.uid.toLong()
if (uid > highestKnownUid && uid > newHighestKnownUid) {
newHighestKnownUid = uid
}
val localMessageTimestamp = localUidMap!![thisMess.uid]
if (localMessageTimestamp == null || localMessageTimestamp >= earliestTimestamp) {
remoteMessages.add(thisMess)
remoteUidMap[thisMess.uid] = thisMess
}
}
Log.v("SYNC: Got %d messages for folder %s", remoteUidMap.size, folder)
listener.syncHeadersFinished(folder, headerProgress.get(), remoteUidMap.size)
} else if (remoteMessageCount < 0) {
throw Exception("Message count $remoteMessageCount for folder $folder")
}
/*
* Remove any messages that are in the local store but no longer on the remote store or are too old
*/
var moreMessages = backendFolder.getMoreMessages()
if (syncConfig.syncRemoteDeletions) {
val destroyMessageUids = mutableListOf<String>()
for (localMessageUid in localUidMap!!.keys) {
if (remoteUidMap[localMessageUid] == null) {
destroyMessageUids.add(localMessageUid)
}
}
if (destroyMessageUids.isNotEmpty()) {
moreMessages = MoreMessages.UNKNOWN
backendFolder.destroyMessages(destroyMessageUids)
for (uid in destroyMessageUids) {
listener.syncRemovedMessage(folder, uid)
}
}
}
@Suppress("UNUSED_VALUE") // free memory early? (better break up the method!)
localUidMap = null
if (moreMessages === MoreMessages.UNKNOWN) {
updateMoreMessages(remoteFolder, backendFolder, earliestDate, remoteStart)
}
/*
* Now we download the actual content of messages.
*/
downloadMessages(
syncConfig,
remoteFolder,
backendFolder,
remoteMessages,
highestKnownUid,
listener,
)
listener.folderStatusChanged(folder)
/* Notify listeners that we're finally done. */
backendFolder.setLastChecked(System.currentTimeMillis())
backendFolder.setStatus(null)
Log.d("Done synchronizing folder %s:%s @ %tc", accountName, folder, System.currentTimeMillis())
listener.syncFinished(folder)
Log.i("Done synchronizing folder %s:%s", accountName, folder)
} catch (e: AuthenticationFailedException) {
listener.syncFailed(folder, "Authentication failure", e)
} catch (e: Exception) {
Log.e(e, "synchronizeMailbox")
// If we don't set the last checked, it can try too often during
// failure conditions
val rootMessage = ExceptionHelper.getRootCauseMessage(e)
if (backendFolder != null) {
try {
backendFolder.setStatus(rootMessage)
backendFolder.setLastChecked(System.currentTimeMillis())
} catch (e: Exception) {
Log.e(e, "Could not set last checked on folder %s:%s", accountName, folder)
}
}
listener.syncFailed(folder, rootMessage, e)
Log.e(
"Failed synchronizing folder %s:%s @ %tc",
accountName,
folder,
System.currentTimeMillis(),
)
} finally {
if (newHighestKnownUid > 0 && backendFolder != null) {
Log.v("Saving new highest known UID: %d", newHighestKnownUid)
backendFolder.setFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID, newHighestKnownUid)
}
remoteFolder?.close()
}
}
fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
val backendFolder = backendStorage.getFolder(folderServerId)
val remoteFolder = imapStore.getFolder(folderServerId)
try {
remoteFolder.open(OpenMode.READ_ONLY)
val remoteMessage = remoteFolder.getMessage(messageServerId)
downloadMessages(
syncConfig,
remoteFolder,
backendFolder,
listOf(remoteMessage),
null,
SimpleSyncListener(),
)
} finally {
remoteFolder.close()
}
}
/**
* Fetches the messages described by inputMessages from the remote store and writes them to local storage.
*
* @param remoteFolder
* The remote folder to download messages from.
* @param backendFolder
* The [BackendFolder] instance corresponding to the remote folder.
* @param inputMessages
* A list of messages objects that store the UIDs of which messages to download.
*/
private fun downloadMessages(
syncConfig: SyncConfig,
remoteFolder: ImapFolder,
backendFolder: BackendFolder,
inputMessages: List<ImapMessage>,
highestKnownUid: Long?,
listener: SyncListener,
) {
val folder = remoteFolder.serverId
val syncFlagMessages = mutableListOf<ImapMessage>()
var unsyncedMessages = mutableListOf<ImapMessage>()
val downloadedMessageCount = AtomicInteger(0)
val messages = inputMessages.toMutableList()
for (message in messages) {
evaluateMessageForDownload(
message,
backendFolder,
unsyncedMessages,
syncFlagMessages,
)
}
val progress = AtomicInteger(0)
val todo = unsyncedMessages.size + syncFlagMessages.size
listener.syncProgress(folder, progress.get(), todo)
Log.d("SYNC: Have %d unsynced messages", unsyncedMessages.size)
messages.clear()
val largeMessages = mutableListOf<ImapMessage>()
val smallMessages = mutableListOf<ImapMessage>()
if (unsyncedMessages.isNotEmpty()) {
Collections.sort(unsyncedMessages, UidReverseComparator())
val visibleLimit = backendFolder.visibleLimit
val listSize = unsyncedMessages.size
if (visibleLimit in 1 until listSize) {
unsyncedMessages = unsyncedMessages.subList(0, visibleLimit)
}
Log.d("SYNC: About to fetch %d unsynced messages for folder %s", unsyncedMessages.size, folder)
fetchUnsyncedMessages(
syncConfig,
remoteFolder,
unsyncedMessages,
smallMessages,
largeMessages,
progress,
todo,
listener,
)
Log.d("SYNC: Synced unsynced messages for folder %s", folder)
}
Log.d(
"SYNC: Have %d large messages and %d small messages out of %d unsynced messages",
largeMessages.size,
smallMessages.size,
unsyncedMessages.size,
)
unsyncedMessages.clear()
/*
* Grab the content of the small messages first. This is going to
* be very fast and at very worst will be a single up of a few bytes and a single
* download of 625k.
*/
val maxDownloadSize = syncConfig.maximumAutoDownloadMessageSize
// TODO: Only fetch small and large messages if we have some
downloadSmallMessages(
remoteFolder,
backendFolder,
smallMessages,
progress,
downloadedMessageCount,
todo,
highestKnownUid,
listener,
)
smallMessages.clear()
/*
* Now do the large messages that require more round trips.
*/
downloadLargeMessages(
remoteFolder,
backendFolder,
largeMessages,
progress,
downloadedMessageCount,
todo,
highestKnownUid,
listener,
maxDownloadSize,
)
largeMessages.clear()
/*
* Refresh the flags for any messages in the local store that we didn't just
* download.
*/
refreshLocalMessageFlags(syncConfig, remoteFolder, backendFolder, syncFlagMessages, progress, todo, listener)
Log.d("SYNC: Synced remote messages for folder %s, %d new messages", folder, downloadedMessageCount.get())
}
private fun evaluateMessageForDownload(
message: ImapMessage,
backendFolder: BackendFolder,
unsyncedMessages: MutableList<ImapMessage>,
syncFlagMessages: MutableList<ImapMessage>,
) {
val messageServerId = message.uid
if (message.isSet(Flag.DELETED)) {
Log.v("Message with uid %s is marked as deleted", messageServerId)
syncFlagMessages.add(message)
return
}
val messagePresentLocally = backendFolder.isMessagePresent(messageServerId)
if (!messagePresentLocally) {
Log.v("Message with uid %s has not yet been downloaded", messageServerId)
unsyncedMessages.add(message)
return
}
val messageFlags = backendFolder.getMessageFlags(messageServerId)
if (!messageFlags.contains(Flag.DELETED)) {
Log.v("Message with uid %s is present in the local store", messageServerId)
if (!messageFlags.contains(Flag.X_DOWNLOADED_FULL) && !messageFlags.contains(Flag.X_DOWNLOADED_PARTIAL)) {
Log.v("Message with uid %s is not downloaded, even partially; trying again", messageServerId)
unsyncedMessages.add(message)
} else {
syncFlagMessages.add(message)
}
} else {
Log.v("Local copy of message with uid %s is marked as deleted", messageServerId)
}
}
private fun isOldMessage(messageServerId: String, highestKnownUid: Long?): Boolean {
if (highestKnownUid == null) return false
try {
val messageUid = messageServerId.toLong()
return messageUid <= highestKnownUid
} catch (e: NumberFormatException) {
Log.w(e, "Couldn't parse UID: %s", messageServerId)
}
return false
}
private fun fetchUnsyncedMessages(
syncConfig: SyncConfig,
remoteFolder: ImapFolder,
unsyncedMessages: List<ImapMessage>,
smallMessages: MutableList<ImapMessage>,
largeMessages: MutableList<ImapMessage>,
progress: AtomicInteger,
todo: Int,
listener: SyncListener,
) {
val folder = remoteFolder.serverId
val fetchProfile = FetchProfile().apply {
add(FetchProfile.Item.FLAGS)
add(FetchProfile.Item.ENVELOPE)
}
remoteFolder.fetch(
unsyncedMessages,
fetchProfile,
object : FetchListener {
override fun onFetchResponse(message: ImapMessage, isFirstResponse: Boolean) {
try {
if (message.isSet(Flag.DELETED)) {
Log.v(
"Newly downloaded message %s:%s:%s was marked deleted on server, skipping",
accountName,
folder,
message.uid,
)
if (isFirstResponse) {
progress.incrementAndGet()
}
// TODO: This might be the source of poll count errors in the UI. Is todo always the same as ofTotal
listener.syncProgress(folder, progress.get(), todo)
return
}
if (syncConfig.maximumAutoDownloadMessageSize > 0 &&
message.size > syncConfig.maximumAutoDownloadMessageSize
) {
largeMessages.add(message)
} else {
smallMessages.add(message)
}
} catch (e: Exception) {
Log.e(e, "Error while storing downloaded message.")
}
}
},
syncConfig.maximumAutoDownloadMessageSize,
)
}
private fun downloadSmallMessages(
remoteFolder: ImapFolder,
backendFolder: BackendFolder,
smallMessages: List<ImapMessage>,
progress: AtomicInteger,
downloadedMessageCount: AtomicInteger,
todo: Int,
highestKnownUid: Long?,
listener: SyncListener,
) {
val folder = remoteFolder.serverId
val fetchProfile = FetchProfile().apply {
add(FetchProfile.Item.BODY)
}
Log.d("SYNC: Fetching %d small messages for folder %s", smallMessages.size, folder)
remoteFolder.fetch(
smallMessages,
fetchProfile,
object : FetchListener {
override fun onFetchResponse(message: ImapMessage, isFirstResponse: Boolean) {
try {
// Store the updated message locally
backendFolder.saveMessage(message, MessageDownloadState.FULL)
if (isFirstResponse) {
progress.incrementAndGet()
downloadedMessageCount.incrementAndGet()
}
val messageServerId = message.uid
Log.v(
"About to notify listeners that we got a new small message %s:%s:%s",
accountName,
folder,
messageServerId,
)
// Update the listener with what we've found
listener.syncProgress(folder, progress.get(), todo)
val isOldMessage = isOldMessage(messageServerId, highestKnownUid)
listener.syncNewMessage(folder, messageServerId, isOldMessage)
} catch (e: Exception) {
Log.e(e, "SYNC: fetch small messages")
}
}
},
-1,
)
Log.d("SYNC: Done fetching small messages for folder %s", folder)
}
private fun downloadLargeMessages(
remoteFolder: ImapFolder,
backendFolder: BackendFolder,
largeMessages: List<ImapMessage>,
progress: AtomicInteger,
downloadedMessageCount: AtomicInteger,
todo: Int,
highestKnownUid: Long?,
listener: SyncListener,
maxDownloadSize: Int,
) {
val folder = remoteFolder.serverId
val fetchProfile = FetchProfile().apply {
add(FetchProfile.Item.STRUCTURE)
}
Log.d("SYNC: Fetching large messages for folder %s", folder)
remoteFolder.fetch(largeMessages, fetchProfile, null, maxDownloadSize)
for (message in largeMessages) {
if (message.body == null) {
downloadSaneBody(remoteFolder, backendFolder, message, maxDownloadSize)
} else {
downloadPartial(remoteFolder, backendFolder, message, maxDownloadSize)
}
val messageServerId = message.uid
Log.v(
"About to notify listeners that we got a new large message %s:%s:%s",
accountName,
folder,
messageServerId,
)
// Update the listener with what we've found
progress.incrementAndGet()
downloadedMessageCount.incrementAndGet()
listener.syncProgress(folder, progress.get(), todo)
val isOldMessage = isOldMessage(messageServerId, highestKnownUid)
listener.syncNewMessage(folder, messageServerId, isOldMessage)
}
Log.d("SYNC: Done fetching large messages for folder %s", folder)
}
private fun refreshLocalMessageFlags(
syncConfig: SyncConfig,
remoteFolder: ImapFolder,
backendFolder: BackendFolder,
syncFlagMessages: List<ImapMessage>,
progress: AtomicInteger,
todo: Int,
listener: SyncListener,
) {
val folder = remoteFolder.serverId
Log.d("SYNC: About to sync flags for %d remote messages for folder %s", syncFlagMessages.size, folder)
val fetchProfile = FetchProfile()
fetchProfile.add(FetchProfile.Item.FLAGS)
val undeletedMessages = mutableListOf<ImapMessage>()
for (message in syncFlagMessages) {
if (!message.isSet(Flag.DELETED)) {
undeletedMessages.add(message)
}
}
val maxDownloadSize = syncConfig.maximumAutoDownloadMessageSize
remoteFolder.fetch(undeletedMessages, fetchProfile, null, maxDownloadSize)
for (remoteMessage in syncFlagMessages) {
val messageChanged = syncFlags(syncConfig, backendFolder, remoteMessage)
if (messageChanged) {
listener.syncFlagChanged(folder, remoteMessage.uid)
}
progress.incrementAndGet()
listener.syncProgress(folder, progress.get(), todo)
}
}
private fun downloadSaneBody(
remoteFolder: ImapFolder,
backendFolder: BackendFolder,
message: ImapMessage,
maxDownloadSize: Int,
) {
/*
* The provider was unable to get the structure of the message, so
* we'll download a reasonable portion of the message and mark it as
* incomplete so the entire thing can be downloaded later if the user
* wishes to download it.
*/
val fetchProfile = FetchProfile()
fetchProfile.add(FetchProfile.Item.BODY_SANE)
/*
* TODO a good optimization here would be to make sure that all Stores set
* the proper size after this fetch and compare the before and after size. If
* they equal we can mark this SYNCHRONIZED instead of PARTIALLY_SYNCHRONIZED
*/
remoteFolder.fetch(listOf(message), fetchProfile, null, maxDownloadSize)
// Store the updated message locally
backendFolder.saveMessage(message, MessageDownloadState.PARTIAL)
}
private fun downloadPartial(
remoteFolder: ImapFolder,
backendFolder: BackendFolder,
message: ImapMessage,
maxDownloadSize: Int,
) {
/*
* We have a structure to deal with, from which
* we can pull down the parts we want to actually store.
* Build a list of parts we are interested in. Text parts will be downloaded
* right now, attachments will be left for later.
*/
val viewables = MessageExtractor.collectTextParts(message)
/*
* Now download the parts we're interested in storing.
*/
val bodyFactory: BodyFactory = DefaultBodyFactory()
for (part in viewables) {
remoteFolder.fetchPart(message, part, bodyFactory, maxDownloadSize)
}
// Store the updated message locally
backendFolder.saveMessage(message, MessageDownloadState.PARTIAL)
}
private fun syncFlags(syncConfig: SyncConfig, backendFolder: BackendFolder, remoteMessage: ImapMessage): Boolean {
val messageServerId = remoteMessage.uid
if (!backendFolder.isMessagePresent(messageServerId)) return false
val localMessageFlags = backendFolder.getMessageFlags(messageServerId)
if (localMessageFlags.contains(Flag.DELETED)) return false
var messageChanged = false
if (remoteMessage.isSet(Flag.DELETED)) {
if (syncConfig.syncRemoteDeletions) {
backendFolder.setMessageFlag(messageServerId, Flag.DELETED, true)
messageChanged = true
}
} else {
for (flag in syncConfig.syncFlags) {
if (remoteMessage.isSet(flag) != localMessageFlags.contains(flag)) {
backendFolder.setMessageFlag(messageServerId, flag, remoteMessage.isSet(flag))
messageChanged = true
}
}
}
return messageChanged
}
private fun updateMoreMessages(
remoteFolder: ImapFolder,
backendFolder: BackendFolder,
earliestDate: Date?,
remoteStart: Int,
) {
if (remoteStart == 1) {
backendFolder.setMoreMessages(MoreMessages.FALSE)
} else {
val moreMessagesAvailable = remoteFolder.areMoreMessagesAvailable(remoteStart, earliestDate)
val newMoreMessages = if (moreMessagesAvailable) MoreMessages.TRUE else MoreMessages.FALSE
backendFolder.setMoreMessages(newMoreMessages)
}
}
companion object {
private const val EXTRA_UID_VALIDITY = "imapUidValidity"
private const val EXTRA_HIGHEST_KNOWN_UID = "imapHighestKnownUid"
}
}

View file

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

View file

@ -0,0 +1,7 @@
package com.fsck.k9.backend.imap
interface SystemAlarmManager {
fun setAlarm(triggerTime: Long, callback: () -> Unit)
fun cancelAlarm()
fun now(): Long
}

View file

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

View file

@ -0,0 +1,75 @@
package net.thunderbird.backend.imap
import com.fsck.k9.backend.imap.ImapBackend
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.folders.FolderServerId
import com.fsck.k9.mail.store.imap.ImapStore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.thunderbird.backend.api.BackendFactory
import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome
import net.thunderbird.backend.api.folder.RemoteFolderCreator
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.account.api.BaseAccount
class ImapRemoteFolderCreator(
private val logger: Logger,
private val imapStore: ImapStore,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : RemoteFolderCreator {
override suspend fun create(
folderServerId: FolderServerId,
mustCreate: Boolean,
folderType: FolderType,
): Outcome<RemoteFolderCreationOutcome.Success, RemoteFolderCreationOutcome.Error> = withContext(ioDispatcher) {
val remoteFolder = imapStore.getFolder(name = folderServerId.serverId)
val outcome = try {
val folderExists = remoteFolder.exists()
when {
folderExists && mustCreate -> Outcome.failure(
RemoteFolderCreationOutcome.Error.AlreadyExists,
)
folderExists -> Outcome.success(RemoteFolderCreationOutcome.Success.AlreadyExists)
!folderExists && remoteFolder.create(folderType = folderType) -> Outcome.success(
RemoteFolderCreationOutcome.Success.Created,
)
else -> Outcome.failure(
RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder(
reason = "Failed to create folder on remote server.",
),
)
}
} catch (e: MessagingException) {
logger.error(message = { "Failed to create remote folder '${folderServerId.serverId}'" }, throwable = e)
Outcome.failure(
RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder(
reason = e.message ?: "Unhandled exception. Please check the logs.",
),
)
} finally {
remoteFolder.close()
}
outcome
}
}
class ImapRemoteFolderCreatorFactory(
private val logger: Logger,
private val backendFactory: BackendFactory<BaseAccount>,
) : RemoteFolderCreator.Factory {
override fun create(account: BaseAccount): RemoteFolderCreator {
val backend = backendFactory.createBackend(account) as ImapBackend
return ImapRemoteFolderCreator(
logger = logger,
imapStore = backend.imapStore,
ioDispatcher = Dispatchers.IO,
)
}
}

View file

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

View file

@ -0,0 +1,338 @@
package com.fsck.k9.backend.imap
import app.k9mail.backend.testing.InMemoryBackendStorage
import assertk.assertThat
import assertk.assertions.containsAtLeast
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEmpty
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.mail.FetchProfile
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.MessageDownloadState
import com.fsck.k9.mail.store.imap.FetchListener
import com.fsck.k9.mail.store.imap.ImapMessage
import com.fsck.k9.mail.testing.message.buildMessage
import java.util.Date
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.logging.testing.TestLogger
import org.apache.james.mime4j.dom.field.DateTimeField
import org.apache.james.mime4j.field.DefaultFieldParser
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito.verify
import org.mockito.kotlin.any
import org.mockito.kotlin.atLeast
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
private const val ACCOUNT_NAME = "Account-1"
private const val FOLDER_SERVER_ID = "FOLDER_ONE"
private const val MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE = 1000
private const val DEFAULT_VISIBLE_LIMIT = 25
private const val DEFAULT_MESSAGE_DATE = "Tue, 04 Jan 2022 10:00:00 +0100"
class ImapSyncTest {
private val backendStorage = createBackendStorage()
private val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
private val imapStore = TestImapStore()
private val imapFolder = imapStore.addFolder(FOLDER_SERVER_ID)
private val imapSync = ImapSync(ACCOUNT_NAME, backendStorage, imapStore)
private val syncListener = mock<SyncListener>()
private val defaultSyncConfig = createSyncConfig()
@Before
fun setUp() {
Log.logger = TestLogger()
}
@Test
fun `sync of empty folder should notify listener`() {
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
verify(syncListener).syncStarted(FOLDER_SERVER_ID)
verify(syncListener).syncAuthenticationSuccess()
verify(syncListener).syncFinished(FOLDER_SERVER_ID)
verify(syncListener, never()).syncFailed(folderServerId = any(), message = any(), exception = any())
}
@Test
fun `sync of folder with negative messageCount should return an error`() {
imapFolder.messageCount = -1
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
verify(syncListener).syncFailed(
folderServerId = eq(FOLDER_SERVER_ID),
message = eq("Exception: Message count -1 for folder $FOLDER_SERVER_ID"),
exception = any(),
)
}
@Test
fun `successful sync should close folder`() {
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
assertThat(imapFolder.isClosed).isTrue()
}
@Test
fun `sync with error should close folder`() {
imapFolder.messageCount = -1
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
assertThat(imapFolder.isClosed).isTrue()
}
@Test
fun `sync with ExpungePolicy ON_POLL should expunge remote folder`() {
val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.ON_POLL)
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(imapFolder.wasExpunged).isTrue()
}
@Test
fun `sync with ExpungePolicy MANUALLY should not expunge remote folder`() {
val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.MANUALLY)
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(imapFolder.wasExpunged).isFalse()
}
@Test
fun `sync with ExpungePolicy IMMEDIATELY should not expunge remote folder`() {
val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.IMMEDIATELY)
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(imapFolder.wasExpunged).isFalse()
}
@Test
fun `sync with syncRemoteDeletions=true should remove local messages`() {
addMessageToBackendFolder(uid = 42)
val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = true)
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).isEmpty()
verify(syncListener).syncStarted(FOLDER_SERVER_ID)
verify(syncListener).syncFinished(FOLDER_SERVER_ID)
}
@Test
fun `sync with syncRemoteDeletions=false should not remove local messages`() {
addMessageToBackendFolder(uid = 23)
val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = false)
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("23")
verify(syncListener).syncStarted(FOLDER_SERVER_ID)
verify(syncListener).syncFinished(FOLDER_SERVER_ID)
}
@Test
fun `sync should remove messages older than earliestPollDate`() {
addMessageToImapAndBackendFolder(uid = 23, date = "Mon, 03 Jan 2022 10:00:00 +0100")
addMessageToImapAndBackendFolder(uid = 42, date = "Wed, 05 Jan 2022 20:00:00 +0100")
val syncConfig = defaultSyncConfig.copy(
syncRemoteDeletions = true,
earliestPollDate = "Tue, 04 Jan 2022 12:00:00 +0100".toDate(),
)
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("42")
}
@Test
fun `sync with new messages on server should download messages`() {
addMessageToImapFolder(uid = 9)
addMessageToImapFolder(uid = 13)
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("9", "13")
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "9", isOldMessage = false)
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "13", isOldMessage = false)
}
@Test
fun `sync downloading old messages should notify listener with isOldMessage=true`() {
addMessageToBackendFolder(uid = 42)
addMessageToImapFolder(uid = 23)
addMessageToImapFolder(uid = 42)
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("23", "42")
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "23", isOldMessage = true)
}
@Test
fun `determining the highest UID should use numerical ordering`() {
addMessageToBackendFolder(uid = 9)
addMessageToBackendFolder(uid = 100)
// When text ordering is used: "9" > "100" -> highest UID = 9 (when it should be 100)
// With 80 > 9 the message on the server is considered a new message, but it shouldn't be (80 < 100)
addMessageToImapFolder(uid = 80)
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "80", isOldMessage = true)
}
@Test
fun `sync should update flags of existing messages`() {
addMessageToBackendFolder(uid = 2)
addMessageToImapFolder(uid = 2, flags = setOf(Flag.SEEN, Flag.ANSWERED))
imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
assertThat(backendFolder.getMessageFlags(messageServerId = "2")).containsAtLeast(Flag.SEEN, Flag.ANSWERED)
}
@Test
fun `sync with UIDVALIDITY change should clear all messages`() {
imapFolder.setUidValidity(1)
addMessageToImapFolder(uid = 300)
addMessageToImapFolder(uid = 301)
val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = false)
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("300", "301")
imapFolder.setUidValidity(9000)
imapFolder.removeAllMessages()
addMessageToImapFolder(uid = 1)
imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("1")
verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "1", isOldMessage = false)
}
@Test
fun `sync with multiple FETCH responses when downloading small message should report correct progress`() {
val folderServerId = "FOLDER_TWO"
backendStorage.createBackendFolder(folderServerId)
val specialImapFolder = object : TestImapFolder(folderServerId) {
override fun fetch(
messages: List<ImapMessage>,
fetchProfile: FetchProfile,
listener: FetchListener?,
maxDownloadSize: Int,
) {
super.fetch(messages, fetchProfile, listener, maxDownloadSize)
// When fetching the body simulate an additional FETCH response
if (FetchProfile.Item.BODY in fetchProfile) {
val message = messages.first()
listener?.onFetchResponse(message, isFirstResponse = false)
}
}
}
specialImapFolder.addMessage(42)
imapStore.addFolder(specialImapFolder)
imapSync.sync(folderServerId, defaultSyncConfig, syncListener)
verify(syncListener, atLeast(1)).syncProgress(folderServerId, completed = 1, total = 1)
verify(syncListener, never()).syncProgress(folderServerId, completed = 2, total = 1)
}
private fun addMessageToBackendFolder(uid: Long, date: String = DEFAULT_MESSAGE_DATE) {
val messageServerId = uid.toString()
val message = createSimpleMessage(messageServerId, date).apply {
setUid(messageServerId)
}
backendFolder.saveMessage(message, MessageDownloadState.FULL)
val highestKnownUid = backendFolder.getFolderExtraNumber("imapHighestKnownUid") ?: 0
if (uid > highestKnownUid) {
backendFolder.setFolderExtraNumber("imapHighestKnownUid", uid)
}
}
private fun addMessageToImapFolder(uid: Long, flags: Set<Flag> = emptySet(), date: String = DEFAULT_MESSAGE_DATE) {
imapFolder.addMessage(uid, flags, date)
}
private fun TestImapFolder.addMessage(
uid: Long,
flags: Set<Flag> = emptySet(),
date: String = DEFAULT_MESSAGE_DATE,
) {
val messageServerId = uid.toString()
val message = createSimpleMessage(messageServerId, date)
addMessage(uid, message)
if (flags.isNotEmpty()) {
val imapMessage = getMessage(messageServerId)
setFlags(listOf(imapMessage), flags, true)
}
}
private fun addMessageToImapAndBackendFolder(uid: Long, date: String) {
addMessageToBackendFolder(uid, date)
addMessageToImapFolder(uid, date = date)
}
private fun createBackendStorage(): InMemoryBackendStorage {
return InMemoryBackendStorage().apply {
createBackendFolder(FOLDER_SERVER_ID)
}
}
private fun InMemoryBackendStorage.createBackendFolder(serverId: String) {
createFolderUpdater().use { updater ->
val folderInfo = FolderInfo(
serverId = serverId,
name = "irrelevant",
type = FolderType.REGULAR,
)
updater.createFolders(listOf(folderInfo))
}
}
private fun createSyncConfig(): SyncConfig {
return SyncConfig(
expungePolicy = ExpungePolicy.MANUALLY,
earliestPollDate = null,
syncRemoteDeletions = true,
maximumAutoDownloadMessageSize = MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE,
defaultVisibleLimit = DEFAULT_VISIBLE_LIMIT,
syncFlags = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED),
)
}
private fun createSimpleMessage(uid: String, date: String, text: String = "UID: $uid"): Message {
return buildMessage {
header("Subject", "Test Message")
header("From", "alice@domain.example")
header("To", "Bob <bob@domain.example>")
header("Date", date)
header("Message-ID", "<msg-$uid@domain.example>")
textBody(text)
}
}
}
private fun String.toDate(): Date {
val dateTimeField = DefaultFieldParser.parse("Date: $this") as DateTimeField
return dateTimeField.date
}

View file

@ -0,0 +1,195 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.BodyFactory
import com.fsck.k9.mail.FetchProfile
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.MessageRetrievalListener
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.store.imap.FetchListener
import com.fsck.k9.mail.store.imap.ImapFolder
import com.fsck.k9.mail.store.imap.ImapMessage
import com.fsck.k9.mail.store.imap.OpenMode
import com.fsck.k9.mail.store.imap.createImapMessage
import java.util.Date
open class TestImapFolder(override val serverId: String) : ImapFolder {
override var mode: OpenMode? = null
protected set
override val isOpen: Boolean
get() = mode != null
override var messageCount: Int = 0
var wasExpunged: Boolean = false
private set
val isClosed: Boolean
get() = mode == null
private val messages = mutableMapOf<Long, Message>()
private val messageFlags = mutableMapOf<Long, MutableSet<Flag>>()
private var uidValidity: Long? = null
fun addMessage(uid: Long, message: Message) {
require(!messages.containsKey(uid)) {
"Folder '$serverId' already contains a message with the UID $uid"
}
messages[uid] = message
messageFlags[uid] = mutableSetOf()
messageCount = messages.size
}
fun removeAllMessages() {
messages.clear()
messageFlags.clear()
}
fun setUidValidity(value: Long) {
uidValidity = value
}
override fun open(mode: OpenMode) {
this.mode = mode
}
override fun close() {
mode = null
}
override fun exists(): Boolean {
throw UnsupportedOperationException("not implemented")
}
override fun getUidValidity() = uidValidity
override fun getMessage(uid: String): ImapMessage {
return createImapMessage(uid)
}
override fun getUidFromMessageId(messageId: String): String? {
throw UnsupportedOperationException("not implemented")
}
override fun getMessages(
start: Int,
end: Int,
earliestDate: Date?,
listener: MessageRetrievalListener<ImapMessage>?,
): List<ImapMessage> {
require(start > 0)
require(end >= start)
require(end <= messages.size)
return messages.keys.sortedDescending()
.slice((start - 1) until end)
.map { createImapMessage(uid = it.toString()) }
}
override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean {
throw UnsupportedOperationException("not implemented")
}
override fun fetch(
messages: List<ImapMessage>,
fetchProfile: FetchProfile,
listener: FetchListener?,
maxDownloadSize: Int,
) {
if (messages.isEmpty()) return
for (imapMessage in messages) {
val uid = imapMessage.uid.toLong()
val flags = messageFlags[uid].orEmpty().toSet()
imapMessage.setFlags(flags, true)
val storedMessage = this.messages[uid] ?: error("Message $uid not found")
for (header in storedMessage.headers) {
imapMessage.addHeader(header.name, header.value)
}
imapMessage.body = storedMessage.body
listener?.onFetchResponse(imapMessage, isFirstResponse = true)
}
}
override fun fetchPart(
message: ImapMessage,
part: Part,
bodyFactory: BodyFactory,
maxDownloadSize: Int,
) {
throw UnsupportedOperationException("not implemented")
}
override fun search(
queryString: String?,
requiredFlags: Set<Flag>?,
forbiddenFlags: Set<Flag>?,
performFullTextSearch: Boolean,
): List<ImapMessage> {
throw UnsupportedOperationException("not implemented")
}
override fun appendMessages(messages: List<Message>): Map<String, String>? {
throw UnsupportedOperationException("not implemented")
}
override fun setFlagsForAllMessages(flags: Set<Flag>, value: Boolean) {
if (value) {
for (messageFlagSet in messageFlags.values) {
messageFlagSet.addAll(flags)
}
} else {
for (messageFlagSet in messageFlags.values) {
messageFlagSet.removeAll(flags)
}
}
}
override fun setFlags(messages: List<ImapMessage>, flags: Set<Flag>, value: Boolean) {
for (message in messages) {
val uid = message.uid.toLong()
val messageFlagSet = messageFlags[uid] ?: error("Unknown message with UID $uid")
if (value) {
messageFlagSet.addAll(flags)
} else {
messageFlagSet.removeAll(flags)
}
}
}
override fun copyMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? {
throw UnsupportedOperationException("not implemented")
}
override fun moveMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? {
throw UnsupportedOperationException("not implemented")
}
override fun deleteMessages(messages: List<ImapMessage>) {
setFlags(messages, setOf(Flag.DELETED), true)
}
override fun deleteAllMessages() {
setFlagsForAllMessages(setOf(Flag.DELETED), true)
}
override fun expunge() {
mode = OpenMode.READ_WRITE
wasExpunged = true
}
override fun expungeUids(uids: List<String>) {
throw UnsupportedOperationException("not implemented")
}
override fun create(folderType: FolderType): Boolean {
throw UnsupportedOperationException("not implemented")
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.store.imap.FolderListItem
import com.fsck.k9.mail.store.imap.ImapFolder
import com.fsck.k9.mail.store.imap.ImapStore
class TestImapStore : ImapStore {
private val folders = mutableMapOf<String, ImapFolder>()
override val combinedPrefix: String?
get() = throw UnsupportedOperationException("not implemented")
fun addFolder(serverId: String): TestImapFolder {
require(!folders.containsKey(serverId)) { "Folder '$serverId' already exists" }
return TestImapFolder(serverId).also { folder ->
folders[serverId] = folder
}
}
fun addFolder(folder: ImapFolder) {
val serverId = folder.serverId
require(!folders.containsKey(serverId)) { "Folder '$serverId' already exists" }
folders[serverId] = folder
}
override fun getFolder(name: String): ImapFolder {
return folders[name] ?: error("Folder '$name' not found")
}
override fun getFolders(): List<FolderListItem> {
return folders.values.map { folder ->
FolderListItem(
serverId = folder.serverId,
name = "irrelevant",
type = FolderType.REGULAR,
)
}
}
override fun checkSettings() {
throw UnsupportedOperationException("not implemented")
}
override fun closeAllConnections() {
throw UnsupportedOperationException("not implemented")
}
override fun fetchImapPrefix() {
throw UnsupportedOperationException("not implemented")
}
}

View file

@ -0,0 +1,3 @@
package com.fsck.k9.mail.store.imap
fun createImapMessage(uid: String) = ImapMessage(uid)

View file

@ -0,0 +1,145 @@
package net.thunderbird.backend.imap
import assertk.assertAll
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isTrue
import assertk.assertions.prop
import com.fsck.k9.backend.imap.TestImapFolder
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.folders.FolderServerId
import com.fsck.k9.mail.store.imap.FolderListItem
import com.fsck.k9.mail.store.imap.ImapFolder
import com.fsck.k9.mail.store.imap.ImapStore
import kotlinx.coroutines.test.runTest
import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome
import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder
import net.thunderbird.core.logging.testing.TestLogger
import net.thunderbird.core.outcome.Outcome
import org.junit.Test
class ImapRemoteFolderCreatorTest {
private val logger = TestLogger()
@Test
fun `when mustCreate true and folder exists, should return Error AlreadyExists`() = runTest {
// Arrange
val folderServerId = FolderServerId("New Folder")
val fakeFolder = object : TestImapFolder(folderServerId.serverId) {
override fun exists(): Boolean = true
}
val imapStore = FakeImapStore(fakeFolder)
val sut = ImapRemoteFolderCreator(logger, imapStore)
// Act
val outcome = sut.create(folderServerId, mustCreate = true)
// Assert
assertAll {
assertThat(outcome.isFailure).isTrue()
assertThat(outcome)
.isInstanceOf<Outcome.Failure<RemoteFolderCreationOutcome.Error>>()
.prop("error") { it.error }
.isEqualTo(RemoteFolderCreationOutcome.Error.AlreadyExists)
}
}
@Test
fun `when mustCreate false and folder exists, should return AlreadyExists`() = runTest {
// Arrange
val folderServerId = FolderServerId("New Folder")
val fakeFolder = object : TestImapFolder(folderServerId.serverId) {
override fun exists(): Boolean = true
}
val imapStore = FakeImapStore(fakeFolder)
val sut = ImapRemoteFolderCreator(logger, imapStore)
// Act
val outcome = sut.create(folderServerId, mustCreate = false)
// Assert
assertAll {
assertThat(outcome.isSuccess).isTrue()
assertThat(outcome)
.isInstanceOf<Outcome.Success<RemoteFolderCreationOutcome.Success>>()
.prop("data") { it.data }
.isEqualTo(RemoteFolderCreationOutcome.Success.AlreadyExists)
}
}
@Test
fun `when folder does not exist and creation succeeds, should return Created`() = runTest {
// Arrange
val folderServerId = FolderServerId("New Folder")
val fakeFolder = object : TestImapFolder(folderServerId.serverId) {
override fun exists(): Boolean = false
override fun create(folderType: FolderType): Boolean = true
}
val imapStore = FakeImapStore(fakeFolder)
val sut = ImapRemoteFolderCreator(logger, imapStore)
// Act
val outcome = sut.create(folderServerId, mustCreate = true)
// Assert
assertAll {
assertThat(outcome.isSuccess).isTrue()
assertThat(outcome)
.isInstanceOf<Outcome.Success<RemoteFolderCreationOutcome.Success>>()
.prop("data") { it.data }
.isEqualTo(RemoteFolderCreationOutcome.Success.Created)
}
}
@Test
fun `when folder does not exist and creation fails, should return FailedToCreateRemoteFolder`() = runTest {
// Arrange
val folderServerId = FolderServerId("New Folder")
val fakeFolder = object : TestImapFolder(folderServerId.serverId) {
override fun exists(): Boolean = false
override fun create(folderType: FolderType): Boolean = false
}
val imapStore = FakeImapStore(fakeFolder)
val sut = ImapRemoteFolderCreator(logger, imapStore)
// Act
val outcome = sut.create(folderServerId, mustCreate = true)
// Assert
assertAll {
assertThat(outcome.isFailure).isTrue()
assertThat(outcome)
.isInstanceOf<Outcome.Failure<FailedToCreateRemoteFolder>>()
.prop("error") { it.error }
.isInstanceOf<FailedToCreateRemoteFolder>()
.prop(FailedToCreateRemoteFolder::reason)
.isEqualTo("Failed to create folder on remote server.")
}
}
}
private class FakeImapStore(
private val folder: TestImapFolder,
) : ImapStore {
override val combinedPrefix: String?
get() = throw NotImplementedError("combinedPrefix not implemented")
override fun checkSettings() {
throw NotImplementedError("checkSettings not implemented")
}
override fun getFolder(name: String): ImapFolder = folder
override fun getFolders(): List<FolderListItem> {
throw NotImplementedError("getFolders not implemented")
}
override fun closeAllConnections() {
throw NotImplementedError("closeAllConnections not implemented")
}
override fun fetchImapPrefix() {
throw NotImplementedError("fetchImapPrefix not implemented")
}
}

View file

@ -0,0 +1,21 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.ksp)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.backend.api)
implementation(projects.core.common)
implementation(projects.feature.mail.folder.api)
api(libs.okhttp)
implementation(libs.jmap.client)
implementation(libs.moshi)
ksp(libs.moshi.kotlin.codegen)
testImplementation(projects.core.logging.testing)
testImplementation(projects.mail.testing)
testImplementation(projects.backend.testing)
testImplementation(libs.okhttp.mockwebserver)
}

View file

@ -0,0 +1,71 @@
package com.fsck.k9.backend.jmap
import net.thunderbird.core.logging.legacy.Log
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.common.Request.Invocation.ResultReference
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
class CommandDelete(
private val jmapClient: JmapClient,
private val accountId: String,
) {
fun deleteMessages(messageServerIds: List<String>) {
Log.v("Deleting messages %s", messageServerIds)
val session = jmapClient.session.get()
val maxObjectsInSet = session.maxObjectsInSet
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
val setEmailCall = jmapClient.call(
SetEmailMethodCall.builder()
.accountId(accountId)
.destroy(emailIds.toTypedArray())
.build(),
)
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
}
}
fun deleteAllMessages(folderServerId: String) {
Log.d("Deleting all messages from %s", folderServerId)
val session = jmapClient.session.get()
val limit = session.maxObjectsInSet.coerceAtMost(MAX_CHUNK_SIZE).toLong()
do {
Log.v("Trying to delete up to %d messages from %s", limit, folderServerId)
val multiCall = jmapClient.newMultiCall()
val queryEmailCall = multiCall.call(
QueryEmailMethodCall.builder()
.accountId(accountId)
.filter(EmailFilterCondition.builder().inMailbox(folderServerId).build())
.calculateTotal(true)
.limit(limit)
.build(),
)
val setEmailCall = multiCall.call(
SetEmailMethodCall.builder()
.accountId(accountId)
.destroyReference(queryEmailCall.createResultReference(ResultReference.Path.IDS))
.build(),
)
multiCall.execute()
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
val numberOfReturnedEmails = queryEmailResponse.ids.size
val totalNumberOfEmails = queryEmailResponse.total ?: error("Server didn't return property 'total'")
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
Log.v("Deleted %d messages from %s", numberOfReturnedEmails, folderServerId)
} while (totalNumberOfEmails > numberOfReturnedEmails)
}
}

View file

@ -0,0 +1,56 @@
package com.fsck.k9.backend.jmap
import net.thunderbird.core.logging.legacy.Log
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
import rs.ltt.jmap.common.util.Patches
class CommandMove(
private val jmapClient: JmapClient,
private val accountId: String,
) {
fun moveMessages(targetFolderServerId: String, messageServerIds: List<String>) {
Log.v("Moving %d messages to %s", messageServerIds.size, targetFolderServerId)
val mailboxPatch = Patches.set("mailboxIds", mapOf(targetFolderServerId to true))
updateEmails(messageServerIds, mailboxPatch)
}
fun moveMessagesAndMarkAsRead(targetFolderServerId: String, messageServerIds: List<String>) {
Log.v("Moving %d messages to %s and marking them as read", messageServerIds.size, targetFolderServerId)
val mailboxPatch = Patches.builder()
.set("mailboxIds", mapOf(targetFolderServerId to true))
.set("keywords/\$seen", true)
.build()
updateEmails(messageServerIds, mailboxPatch)
}
fun copyMessages(targetFolderServerId: String, messageServerIds: List<String>) {
Log.v("Copying %d messages to %s", messageServerIds.size, targetFolderServerId)
val mailboxPatch = Patches.set("mailboxIds/$targetFolderServerId", true)
updateEmails(messageServerIds, mailboxPatch)
}
private fun updateEmails(messageServerIds: List<String>, patch: Map<String, Any>?) {
val session = jmapClient.session.get()
val maxObjectsInSet = session.maxObjectsInSet
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
val updates = emailIds.map { emailId ->
emailId to patch
}.toMap()
val setEmailCall = jmapClient.call(
SetEmailMethodCall.builder()
.accountId(accountId)
.update(updates)
.build(),
)
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
}
}
}

View file

@ -0,0 +1,169 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.BackendFolderUpdater
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.FolderType
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.client.api.ErrorResponseException
import rs.ltt.jmap.client.api.InvalidSessionResourceException
import rs.ltt.jmap.client.api.MethodErrorResponseException
import rs.ltt.jmap.client.api.UnauthorizedException
import rs.ltt.jmap.common.Request.Invocation.ResultReference
import rs.ltt.jmap.common.entity.Mailbox
import rs.ltt.jmap.common.entity.Role
import rs.ltt.jmap.common.method.call.mailbox.ChangesMailboxMethodCall
import rs.ltt.jmap.common.method.call.mailbox.GetMailboxMethodCall
import rs.ltt.jmap.common.method.response.mailbox.ChangesMailboxMethodResponse
import rs.ltt.jmap.common.method.response.mailbox.GetMailboxMethodResponse
internal class CommandRefreshFolderList(
private val backendStorage: BackendStorage,
private val jmapClient: JmapClient,
private val accountId: String,
) {
@Suppress("ThrowsCount", "TooGenericExceptionCaught")
fun refreshFolderList(): FolderPathDelimiter? {
try {
backendStorage.createFolderUpdater().use { folderUpdater ->
val state = backendStorage.getExtraString(STATE)
if (state == null) {
fetchMailboxes(folderUpdater)
} else {
fetchMailboxUpdates(folderUpdater, state)
}
}
} catch (e: UnauthorizedException) {
throw AuthenticationFailedException("Authentication failed", e)
} catch (e: InvalidSessionResourceException) {
throw MessagingException(e.message, true, e)
} catch (e: ErrorResponseException) {
throw MessagingException(e.message, true, e)
} catch (e: MethodErrorResponseException) {
throw MessagingException(e.message, e.isPermanentError, e)
} catch (e: Exception) {
throw MessagingException(e)
}
return FOLDER_DEFAULT_PATH_DELIMITER
}
private fun fetchMailboxes(folderUpdater: BackendFolderUpdater) {
val call = jmapClient.call(
GetMailboxMethodCall.builder().accountId(accountId).build(),
)
val response = call.getMainResponseBlocking<GetMailboxMethodResponse>()
val foldersOnServer = response.list
val oldFolderServerIds = backendStorage.getFolderServerIds()
val (foldersToUpdate, foldersToCreate) = foldersOnServer.partition { it.id in oldFolderServerIds }
for (folder in foldersToUpdate) {
folderUpdater.changeFolder(folder.id, folder.name, folder.type)
}
val newFolders = foldersToCreate.map { folder ->
FolderInfo(folder.id, folder.name, folder.type)
}
folderUpdater.createFolders(newFolders)
val newFolderServerIds = foldersOnServer.map { it.id }
val removedFolderServerIds = oldFolderServerIds - newFolderServerIds
folderUpdater.deleteFolders(removedFolderServerIds)
backendStorage.setExtraString(STATE, response.state)
}
private fun fetchMailboxUpdates(folderUpdater: BackendFolderUpdater, state: String) {
try {
fetchAllMailboxChanges(folderUpdater, state)
} catch (e: MethodErrorResponseException) {
if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) {
fetchMailboxes(folderUpdater)
} else {
throw e
}
}
}
private fun fetchAllMailboxChanges(folderUpdater: BackendFolderUpdater, state: String) {
var currentState = state
do {
val (newState, hasMoreChanges) = fetchMailboxChanges(folderUpdater, currentState)
currentState = newState
} while (hasMoreChanges)
}
private fun fetchMailboxChanges(folderUpdater: BackendFolderUpdater, state: String): UpdateState {
val multiCall = jmapClient.newMultiCall()
val mailboxChangesCall = multiCall.call(
ChangesMailboxMethodCall.builder()
.accountId(accountId)
.sinceState(state)
.build(),
)
val createdMailboxesCall = multiCall.call(
GetMailboxMethodCall.builder()
.accountId(accountId)
.idsReference(mailboxChangesCall.createResultReference(ResultReference.Path.CREATED))
.build(),
)
val changedMailboxesCall = multiCall.call(
GetMailboxMethodCall.builder()
.accountId(accountId)
.idsReference(mailboxChangesCall.createResultReference(ResultReference.Path.UPDATED))
.build(),
)
multiCall.execute()
val mailboxChangesResponse = mailboxChangesCall.getMainResponseBlocking<ChangesMailboxMethodResponse>()
val createdMailboxResponse = createdMailboxesCall.getMainResponseBlocking<GetMailboxMethodResponse>()
val changedMailboxResponse = changedMailboxesCall.getMainResponseBlocking<GetMailboxMethodResponse>()
val foldersToCreate = createdMailboxResponse.list.map { folder ->
FolderInfo(folder.id, folder.name, folder.type)
}
folderUpdater.createFolders(foldersToCreate)
for (folder in changedMailboxResponse.list) {
folderUpdater.changeFolder(folder.id, folder.name, folder.type)
}
val destroyed = mailboxChangesResponse.destroyed
destroyed?.let {
folderUpdater.deleteFolders(it.toList())
}
backendStorage.setExtraString(STATE, mailboxChangesResponse.newState)
return UpdateState(
state = mailboxChangesResponse.newState,
hasMoreChanges = mailboxChangesResponse.isHasMoreChanges,
)
}
private val Mailbox.type: FolderType
get() = when (role) {
Role.INBOX -> FolderType.INBOX
Role.ARCHIVE -> FolderType.ARCHIVE
Role.DRAFTS -> FolderType.DRAFTS
Role.SENT -> FolderType.SENT
Role.TRASH -> FolderType.TRASH
Role.JUNK -> FolderType.SPAM
else -> FolderType.REGULAR
}
private val MethodErrorResponseException.isPermanentError: Boolean
get() = methodErrorResponse.type != ERROR_SERVER_UNAVAILABLE
companion object {
private const val STATE = "jmapState"
private const val ERROR_SERVER_UNAVAILABLE = "serverUnavailable"
private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges"
}
private data class UpdateState(val state: String, val hasMoreChanges: Boolean)
}

View file

@ -0,0 +1,108 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.mail.Flag
import net.thunderbird.core.logging.legacy.Log
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
import rs.ltt.jmap.common.util.Patches
class CommandSetFlag(
private val jmapClient: JmapClient,
private val accountId: String,
) {
fun setFlag(messageServerIds: List<String>, flag: Flag, newState: Boolean) {
if (newState) {
Log.v("Setting flag %s for messages %s", flag, messageServerIds)
} else {
Log.v("Removing flag %s for messages %s", flag, messageServerIds)
}
val keyword = flag.toKeyword()
val keywordsPatch = if (newState) {
Patches.set("keywords/$keyword", true)
} else {
Patches.remove("keywords/$keyword")
}
val session = jmapClient.session.get()
val maxObjectsInSet = session.maxObjectsInSet
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
val updates = emailIds.map { emailId ->
emailId to keywordsPatch
}.toMap()
val setEmailCall = jmapClient.call(
SetEmailMethodCall.builder()
.accountId(accountId)
.update(updates)
.build(),
)
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
}
}
fun markAllAsRead(folderServerId: String) {
Log.d("Marking all messages in %s as read", folderServerId)
val keywordsPatch = Patches.set("keywords/\$seen", true)
val session = jmapClient.session.get()
val limit = minOf(MAX_CHUNK_SIZE, session.maxObjectsInSet).toLong()
do {
Log.v("Trying to mark up to %d messages in %s as read", limit, folderServerId)
val queryEmailCall = jmapClient.call(
QueryEmailMethodCall.builder()
.accountId(accountId)
.filter(
EmailFilterCondition.builder()
.inMailbox(folderServerId)
.notKeyword("\$seen")
.build(),
)
.calculateTotal(true)
.limit(limit)
.build(),
)
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
val numberOfReturnedEmails = queryEmailResponse.ids.size
val totalNumberOfEmails = queryEmailResponse.total ?: error("Server didn't return property 'total'")
if (numberOfReturnedEmails == 0) {
Log.v("There were no messages in %s to mark as read", folderServerId)
} else {
val updates = queryEmailResponse.ids.map { emailId ->
emailId to keywordsPatch
}.toMap()
val setEmailCall = jmapClient.call(
SetEmailMethodCall.builder()
.accountId(accountId)
.update(updates)
.build(),
)
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
Log.v("Marked %d messages in %s as read", numberOfReturnedEmails, folderServerId)
}
} while (totalNumberOfEmails > numberOfReturnedEmails)
}
private fun Flag.toKeyword(): String = when (this) {
Flag.SEEN -> "\$seen"
Flag.FLAGGED -> "\$flagged"
Flag.DRAFT -> "\$draft"
Flag.ANSWERED -> "\$answered"
Flag.FORWARDED -> "\$forwarded"
else -> error("Unsupported flag: $name")
}
}

View file

@ -0,0 +1,322 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.BackendFolder
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.MessageDownloadState
import com.fsck.k9.mail.internet.MimeMessage
import java.util.Date
import net.thunderbird.core.logging.legacy.Log
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.client.api.MethodErrorResponseException
import rs.ltt.jmap.client.api.UnauthorizedException
import rs.ltt.jmap.client.http.HttpAuthentication
import rs.ltt.jmap.client.session.Session
import rs.ltt.jmap.common.entity.Email
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
import rs.ltt.jmap.common.entity.query.EmailQuery
import rs.ltt.jmap.common.method.call.email.GetEmailMethodCall
import rs.ltt.jmap.common.method.call.email.QueryChangesEmailMethodCall
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
import rs.ltt.jmap.common.method.response.email.GetEmailMethodResponse
import rs.ltt.jmap.common.method.response.email.QueryChangesEmailMethodResponse
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
class CommandSync(
private val backendStorage: BackendStorage,
private val jmapClient: JmapClient,
private val okHttpClient: OkHttpClient,
private val accountId: String,
private val httpAuthentication: HttpAuthentication,
) {
fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
try {
val backendFolder = backendStorage.getFolder(folderServerId)
listener.syncStarted(folderServerId)
val limit = if (backendFolder.visibleLimit > 0) backendFolder.visibleLimit.toLong() else null
val queryState = backendFolder.getFolderExtraString(EXTRA_QUERY_STATE)
if (queryState == null) {
fullSync(backendFolder, folderServerId, syncConfig, limit, listener)
} else {
deltaSync(backendFolder, folderServerId, syncConfig, limit, queryState, listener)
}
listener.syncFinished(folderServerId)
} catch (e: UnauthorizedException) {
Log.e(e, "Authentication failure during sync")
val exception = AuthenticationFailedException(e.message ?: "Authentication failed", e)
listener.syncFailed(folderServerId, "Authentication failed", exception)
} catch (e: Exception) {
Log.e(e, "Unexpected failure during sync")
listener.syncFailed(folderServerId, "Unexpected failure", e)
}
}
private fun fullSync(
backendFolder: BackendFolder,
folderServerId: String,
syncConfig: SyncConfig,
limit: Long?,
listener: SyncListener,
) {
val cachedServerIds: Set<String> = backendFolder.getMessageServerIds()
if (limit != null) {
Log.d("Fetching %d latest messages in %s (%s)", limit, backendFolder.name, folderServerId)
} else {
Log.d("Fetching all messages in %s (%s)", backendFolder.name, folderServerId)
}
val queryEmailCall = jmapClient.call(
QueryEmailMethodCall.builder()
.accountId(accountId)
.query(createEmailQuery(folderServerId))
.limit(limit)
.build(),
)
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
val queryState = if (queryEmailResponse.isCanCalculateChanges) queryEmailResponse.queryState else null
val remoteServerIds = queryEmailResponse.ids.toSet()
val destroyServerIds = (cachedServerIds - remoteServerIds).toList()
val newServerIds = remoteServerIds - cachedServerIds
handleFolderUpdates(backendFolder, folderServerId, destroyServerIds, newServerIds, queryState, listener)
val refreshServerIds = cachedServerIds.intersect(remoteServerIds)
refreshMessageFlags(backendFolder, syncConfig, refreshServerIds)
}
private fun createEmailQuery(folderServerId: String): EmailQuery? {
val filter = EmailFilterCondition.builder()
.inMailbox(folderServerId)
.build()
// FIXME: Add sort parameter
return EmailQuery.of(filter)
}
private fun deltaSync(
backendFolder: BackendFolder,
folderServerId: String,
syncConfig: SyncConfig,
limit: Long?,
queryState: String,
listener: SyncListener,
) {
Log.d("Updating messages in %s (%s)", backendFolder.name, folderServerId)
val emailQuery = createEmailQuery(folderServerId)
val queryChangesEmailCall = jmapClient.call(
QueryChangesEmailMethodCall.builder()
.accountId(accountId)
.sinceQueryState(queryState)
.query(emailQuery)
.build(),
)
val queryChangesEmailResponse = try {
queryChangesEmailCall.getMainResponseBlocking<QueryChangesEmailMethodResponse>()
} catch (e: MethodErrorResponseException) {
if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) {
Log.d("Server responded with '$ERROR_CANNOT_CALCULATE_CHANGES'; switching to full sync")
backendFolder.saveQueryState(null)
fullSync(backendFolder, folderServerId, syncConfig, limit, listener)
return
}
throw e
}
val cachedServerIds = backendFolder.getMessageServerIds()
val removedServerIds = queryChangesEmailResponse.removed.toSet()
val addedServerIds = queryChangesEmailResponse.added.map { it.item }.toSet()
val newQueryState = queryChangesEmailResponse.newQueryState
// An email can appear in both the 'removed' and the 'added' properties, e.g. when its position in the list
// changes. But we don't want to remove a message from the database only to download it again right away.
val retainedServerIds = removedServerIds.intersect(addedServerIds)
val destroyServerIds = (removedServerIds - retainedServerIds).toList()
val newServerIds = addedServerIds - retainedServerIds
handleFolderUpdates(backendFolder, folderServerId, destroyServerIds, newServerIds, newQueryState, listener)
val refreshServerIds = cachedServerIds - destroyServerIds
refreshMessageFlags(backendFolder, syncConfig, refreshServerIds)
}
private fun handleFolderUpdates(
backendFolder: BackendFolder,
folderServerId: String,
destroyServerIds: List<String>,
newServerIds: Set<String>,
newQueryState: String?,
listener: SyncListener,
) {
if (destroyServerIds.isNotEmpty()) {
Log.d("Removing messages no longer on server: %s", destroyServerIds)
backendFolder.destroyMessages(destroyServerIds)
}
if (newServerIds.isEmpty()) {
Log.d("No new messages on server")
backendFolder.saveQueryState(newQueryState)
return
}
Log.d("New messages on server: %s", newServerIds)
val session = jmapClient.session.get()
val maxObjectsInGet = session.maxObjectsInGet
val messageInfoList = fetchMessageInfo(session, maxObjectsInGet, newServerIds)
val total = messageInfoList.size
messageInfoList.forEachIndexed { index, messageInfo ->
Log.v("Downloading message %s (%s)", messageInfo.serverId, messageInfo.downloadUrl)
val message = downloadMessage(messageInfo.downloadUrl)
if (message != null) {
message.apply {
uid = messageInfo.serverId
setInternalSentDate(messageInfo.receivedAt)
setFlags(messageInfo.flags, true)
}
backendFolder.saveMessage(message, MessageDownloadState.FULL)
} else {
Log.d("Failed to download message: %s", messageInfo.serverId)
}
listener.syncProgress(folderServerId, index + 1, total)
}
backendFolder.saveQueryState(newQueryState)
}
private fun fetchMessageInfo(session: Session, maxObjectsInGet: Int, emailIds: Set<String>): List<MessageInfo> {
return emailIds
.chunked(maxObjectsInGet) { emailIdsChunk ->
getEmailPropertiesFromServer(emailIdsChunk, INFO_PROPERTIES)
}
.flatten()
.map { email ->
email.toMessageInfo(session)
}
}
private fun getEmailPropertiesFromServer(emailIdsChunk: List<String>, properties: Array<String>): List<Email> {
val getEmailCall = jmapClient.call(
GetEmailMethodCall.builder()
.accountId(accountId)
.ids(emailIdsChunk.toTypedArray())
.properties(properties)
.build(),
)
val getEmailResponse = getEmailCall.getMainResponseBlocking<GetEmailMethodResponse>()
return getEmailResponse.list.toList()
}
private fun Email.toMessageInfo(session: Session): MessageInfo {
val downloadUrl = session.getDownloadUrl(accountId, blobId, blobId, "application/octet-stream")
return MessageInfo(id, downloadUrl, receivedAt, keywords.toFlags())
}
private fun downloadMessage(downloadUrl: HttpUrl): MimeMessage? {
val request = Request.Builder()
.url(downloadUrl)
.apply {
httpAuthentication.authenticate(this)
}
.build()
return okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val inputStream = response.body!!.byteStream()
MimeMessage.parseMimeMessage(inputStream, false)
} else {
null
}
}
}
private fun refreshMessageFlags(backendFolder: BackendFolder, syncConfig: SyncConfig, emailIds: Set<String>) {
if (emailIds.isEmpty()) return
Log.v("Fetching flags for messages: %s", emailIds)
val session = jmapClient.session.get()
val maxObjectsInGet = session.maxObjectsInGet
emailIds
.asSequence()
.chunked(maxObjectsInGet) { emailIdsChunk ->
getEmailPropertiesFromServer(emailIdsChunk, FLAG_PROPERTIES)
}
.flatten()
.forEach { email ->
syncFlagsForMessage(backendFolder, syncConfig, email)
}
}
private fun syncFlagsForMessage(backendFolder: BackendFolder, syncConfig: SyncConfig, email: Email) {
val messageServerId = email.id
val localFlags = backendFolder.getMessageFlags(messageServerId)
val remoteFlags = email.keywords.toFlags()
for (flag in syncConfig.syncFlags) {
val flagSetOnServer = flag in remoteFlags
val flagSetLocally = flag in localFlags
if (flagSetOnServer != flagSetLocally) {
backendFolder.setMessageFlag(messageServerId, flag, flagSetOnServer)
}
}
}
private fun Map<String, Boolean>?.toFlags(): Set<Flag> {
return if (this == null) {
emptySet()
} else {
filterValues { it }.keys
.mapNotNull { keyword -> keyword.toFlag() }
.toSet()
}
}
private fun String.toFlag(): Flag? = when (this) {
"\$seen" -> Flag.SEEN
"\$flagged" -> Flag.FLAGGED
"\$draft" -> Flag.DRAFT
"\$answered" -> Flag.ANSWERED
"\$forwarded" -> Flag.FORWARDED
else -> null
}
private fun BackendFolder.saveQueryState(queryState: String?) {
setFolderExtraString(EXTRA_QUERY_STATE, queryState)
}
companion object {
private const val EXTRA_QUERY_STATE = "jmapQueryState"
private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges"
private val INFO_PROPERTIES = arrayOf("id", "blobId", "size", "receivedAt", "keywords")
private val FLAG_PROPERTIES = arrayOf("id", "keywords")
}
}
private data class MessageInfo(
val serverId: String,
val downloadUrl: HttpUrl,
val receivedAt: Date,
val flags: Set<Flag>,
)

View file

@ -0,0 +1,98 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.mail.Message
import com.squareup.moshi.Moshi
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.logging.legacy.Log
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.client.http.HttpAuthentication
import rs.ltt.jmap.common.entity.EmailImport
import rs.ltt.jmap.common.method.call.email.ImportEmailMethodCall
import rs.ltt.jmap.common.method.response.email.ImportEmailMethodResponse
class CommandUpload(
private val jmapClient: JmapClient,
private val okHttpClient: OkHttpClient,
private val httpAuthentication: HttpAuthentication,
private val accountId: String,
) {
private val moshi = Moshi.Builder().build()
fun uploadMessage(folderServerId: String, message: Message): String? {
Log.d("Uploading message to $folderServerId")
val uploadResponse = uploadMessageAsBlob(message)
return importEmailBlob(uploadResponse, folderServerId)
}
private fun uploadMessageAsBlob(message: Message): JmapUploadResponse {
val session = jmapClient.session.get()
val uploadUrl = session.getUploadUrl(accountId)
val request = Request.Builder()
.url(uploadUrl)
.post(MessageRequestBody(message))
.apply {
httpAuthentication.authenticate(this)
}
.build()
return okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw MessagingException("Uploading message as blob failed")
}
response.body!!.source().use { source ->
val adapter = moshi.adapter(JmapUploadResponse::class.java)
val uploadResponse = adapter.fromJson(source)
uploadResponse ?: throw MessagingException("Error reading upload response")
}
}
}
private fun importEmailBlob(uploadResponse: JmapUploadResponse, folderServerId: String): String? {
val importEmailRequest = ImportEmailMethodCall.builder()
.accountId(accountId)
.email(
LOCAL_EMAIL_ID,
EmailImport.builder()
.blobId(uploadResponse.blobId)
.keywords(mapOf("\$seen" to true))
.mailboxIds(mapOf(folderServerId to true))
.build(),
)
.build()
val importEmailCall = jmapClient.call(importEmailRequest)
val importEmailResponse = importEmailCall.getMainResponseBlocking<ImportEmailMethodResponse>()
return importEmailResponse.serverEmailId
}
private val ImportEmailMethodResponse.serverEmailId
get() = created?.get(LOCAL_EMAIL_ID)?.id
companion object {
private const val LOCAL_EMAIL_ID = "t1"
}
}
private class MessageRequestBody(private val message: Message) : RequestBody() {
override fun contentType(): MediaType? {
return "message/rfc822".toMediaType()
}
override fun contentLength(): Long {
return message.calculateSize()
}
override fun writeTo(sink: BufferedSink) {
message.writeTo(sink.outputStream())
}
}

View file

@ -0,0 +1,46 @@
package com.fsck.k9.backend.jmap
import java.net.UnknownHostException
import net.thunderbird.core.logging.legacy.Log
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.client.api.EndpointNotFoundException
import rs.ltt.jmap.client.api.UnauthorizedException
import rs.ltt.jmap.common.entity.capability.MailAccountCapability
class JmapAccountDiscovery {
fun discover(emailAddress: String, password: String): JmapDiscoveryResult {
val jmapClient = JmapClient(emailAddress, password)
val session = try {
jmapClient.session.futureGetOrThrow()
} catch (e: EndpointNotFoundException) {
return JmapDiscoveryResult.EndpointNotFoundFailure
} catch (e: UnknownHostException) {
return JmapDiscoveryResult.EndpointNotFoundFailure
} catch (e: UnauthorizedException) {
return JmapDiscoveryResult.AuthenticationFailure
} catch (e: Exception) {
Log.e(e, "Unable to get JMAP session")
return JmapDiscoveryResult.GenericFailure(e)
}
val accounts = session.getAccounts(MailAccountCapability::class.java)
val accountId = when {
accounts.isEmpty() -> return JmapDiscoveryResult.NoEmailAccountFoundFailure
accounts.size == 1 -> accounts.keys.first()
else -> session.getPrimaryAccount(MailAccountCapability::class.java)
}
val account = accounts[accountId]!!
val accountName = account.name ?: emailAddress
return JmapDiscoveryResult.JmapAccount(accountId, accountName)
}
}
sealed class JmapDiscoveryResult {
class GenericFailure(val cause: Throwable) : JmapDiscoveryResult()
object EndpointNotFoundFailure : JmapDiscoveryResult()
object AuthenticationFailure : JmapDiscoveryResult()
object NoEmailAccountFoundFailure : JmapDiscoveryResult()
data class JmapAccount(val accountId: String, val name: String) : JmapDiscoveryResult()
}

View file

@ -0,0 +1,153 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.Backend
import com.fsck.k9.backend.api.BackendPusher
import com.fsck.k9.backend.api.BackendPusherCallback
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.mail.BodyFactory
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Part
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.client.http.BasicAuthHttpAuthentication
import rs.ltt.jmap.client.http.HttpAuthentication
class JmapBackend(
backendStorage: BackendStorage,
okHttpClient: OkHttpClient,
config: JmapConfig,
) : Backend {
private val httpAuthentication = config.toHttpAuthentication()
private val jmapClient = createJmapClient(config, httpAuthentication)
private val accountId = config.accountId
private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage, jmapClient, accountId)
private val commandSync = CommandSync(backendStorage, jmapClient, okHttpClient, accountId, httpAuthentication)
private val commandSetFlag = CommandSetFlag(jmapClient, accountId)
private val commandDelete = CommandDelete(jmapClient, accountId)
private val commandMove = CommandMove(jmapClient, accountId)
private val commandUpload = CommandUpload(jmapClient, okHttpClient, httpAuthentication, accountId)
override val supportsFlags = true
override val supportsExpunge = false
override val supportsMove = true
override val supportsCopy = true
override val supportsUpload = true
override val supportsTrashFolder = true
override val supportsSearchByDate = true
override val supportsFolderSubscriptions = false // TODO: add support
override val isPushCapable = false // FIXME
override fun refreshFolderList(): FolderPathDelimiter? {
return commandRefreshFolderList.refreshFolderList()
}
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
commandSync.sync(folderServerId, syncConfig, listener)
}
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) {
commandSetFlag.setFlag(messageServerIds, flag, newState)
}
override fun markAllAsRead(folderServerId: String) {
commandSetFlag.markAllAsRead(folderServerId)
}
override fun expunge(folderServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
commandDelete.deleteMessages(messageServerIds)
}
override fun deleteAllMessages(folderServerId: String) {
commandDelete.deleteAllMessages(folderServerId)
}
override fun moveMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>? {
commandMove.moveMessages(targetFolderServerId, messageServerIds)
return messageServerIds.associateWith { it }
}
override fun moveMessagesAndMarkAsRead(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>? {
commandMove.moveMessagesAndMarkAsRead(targetFolderServerId, messageServerIds)
return messageServerIds.associateWith { it }
}
override fun copyMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>,
): Map<String, String>? {
commandMove.copyMessages(targetFolderServerId, messageServerIds)
return messageServerIds.associateWith { it }
}
override fun search(
folderServerId: String,
query: String?,
requiredFlags: Set<Flag>?,
forbiddenFlags: Set<Flag>?,
performFullTextSearch: Boolean,
): List<String> {
throw UnsupportedOperationException("not implemented")
}
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
throw UnsupportedOperationException("not implemented")
}
override fun findByMessageId(folderServerId: String, messageId: String): String? {
return null
}
override fun uploadMessage(folderServerId: String, message: Message): String? {
return commandUpload.uploadMessage(folderServerId, message)
}
override fun sendMessage(message: Message) {
throw UnsupportedOperationException("not implemented")
}
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
throw UnsupportedOperationException("not implemented")
}
private fun JmapConfig.toHttpAuthentication(): HttpAuthentication {
return BasicAuthHttpAuthentication(username, password)
}
private fun createJmapClient(jmapConfig: JmapConfig, httpAuthentication: HttpAuthentication): JmapClient {
return if (jmapConfig.baseUrl == null) {
JmapClient(httpAuthentication)
} else {
val baseHttpUrl = jmapConfig.baseUrl.toHttpUrlOrNull()
JmapClient(httpAuthentication, baseHttpUrl)
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,177 @@
package com.fsck.k9.backend.jmap
import app.k9mail.backend.testing.InMemoryBackendStorage
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isTrue
import assertk.fail
import com.fsck.k9.backend.api.BackendFolderUpdater
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.updateFolders
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.FolderType
import net.thunderbird.core.common.exception.MessagingException
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import org.junit.Test
import rs.ltt.jmap.client.JmapClient
class CommandRefreshFolderListTest {
private val backendStorage = InMemoryBackendStorage()
@Test
fun sessionResourceWithAuthenticationError() {
val command = createCommandRefreshFolderList(
MockResponse().setResponseCode(401),
)
assertFailure {
command.refreshFolderList()
}.isInstanceOf<AuthenticationFailedException>()
}
@Test
fun invalidSessionResource() {
val command = createCommandRefreshFolderList(
MockResponse().setBody("invalid"),
)
assertFailure {
command.refreshFolderList()
}.isInstanceOf<MessagingException>()
.transform { it.isPermanentFailure }.isTrue()
}
@Test
fun fetchMailboxes() {
val command = createCommandRefreshFolderList(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json"),
)
command.refreshFolderList()
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1")
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
assertFolderPresent("id_trash", "Trash", FolderType.TRASH)
assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR)
assertMailboxState("23")
}
@Test
fun fetchMailboxUpdates() {
val command = createCommandRefreshFolderList(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes.json"),
)
createFoldersInBackendStorage(state = "23")
command.refreshFolderList()
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2")
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH)
assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR)
assertMailboxState("42")
}
@Test
fun fetchMailboxUpdates_withHasMoreChanges() {
val command = createCommandRefreshFolderList(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_1.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_2.json"),
)
createFoldersInBackendStorage(state = "23")
command.refreshFolderList()
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2")
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH)
assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR)
assertMailboxState("42")
}
@Test
fun fetchMailboxUpdates_withCannotCalculateChangesError() {
val command = createCommandRefreshFolderList(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json"),
)
setMailboxState("unknownToServer")
command.refreshFolderList()
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1")
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
assertFolderPresent("id_trash", "Trash", FolderType.TRASH)
assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR)
assertMailboxState("23")
}
private fun createCommandRefreshFolderList(vararg mockResponses: MockResponse): CommandRefreshFolderList {
val server = createMockWebServer(*mockResponses)
return createCommandRefreshFolderList(server.url("/jmap/"))
}
private fun createCommandRefreshFolderList(
baseUrl: HttpUrl,
accountId: String = "test@example.com",
): CommandRefreshFolderList {
val jmapClient = JmapClient("test", "test", baseUrl)
return CommandRefreshFolderList(backendStorage, jmapClient, accountId)
}
@Suppress("SameParameterValue")
private fun createFoldersInBackendStorage(state: String) {
backendStorage.updateFolders {
createFolder("id_inbox", "Inbox", FolderType.INBOX)
createFolder("id_archive", "Archive", FolderType.ARCHIVE)
createFolder("id_drafts", "Drafts", FolderType.DRAFTS)
createFolder("id_sent", "Sent", FolderType.SENT)
createFolder("id_trash", "Trash", FolderType.TRASH)
createFolder("id_folder1", "folder1", FolderType.REGULAR)
}
setMailboxState(state)
}
private fun BackendFolderUpdater.createFolder(serverId: String, name: String, type: FolderType) {
createFolders(listOf(FolderInfo(serverId, name, type)))
}
private fun setMailboxState(state: String) {
backendStorage.setExtraString("jmapState", state)
}
private fun assertFolderList(vararg folderServerIds: String) {
assertThat(backendStorage.getFolderServerIds()).containsExactlyInAnyOrder(*folderServerIds)
}
private fun assertFolderPresent(serverId: String, name: String, type: FolderType) {
val folder = backendStorage.folders[serverId] ?: fail("Expected folder '$serverId' in BackendStorage")
assertThat(folder.name).isEqualTo(name)
assertThat(folder.type).isEqualTo(type)
}
private fun assertMailboxState(expected: String) {
assertThat(backendStorage.getExtraString("jmapState")).isEqualTo(expected)
}
}

View file

@ -0,0 +1,280 @@
package com.fsck.k9.backend.jmap
import app.k9mail.backend.testing.InMemoryBackendFolder
import app.k9mail.backend.testing.InMemoryBackendStorage
import assertk.assertThat
import assertk.assertions.containsOnly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
import com.fsck.k9.backend.api.updateFolders
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.internet.BinaryTempFileBody
import java.io.File
import java.util.EnumSet
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.logging.testing.TestLogger
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Before
import org.junit.Test
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.client.http.BasicAuthHttpAuthentication
class CommandSyncTest {
private val backendStorage = InMemoryBackendStorage()
private val okHttpClient = OkHttpClient.Builder().build()
private val syncListener = LoggingSyncListener()
private val syncConfig = SyncConfig(
expungePolicy = ExpungePolicy.IMMEDIATELY,
earliestPollDate = null,
syncRemoteDeletions = true,
maximumAutoDownloadMessageSize = 1000,
defaultVisibleLimit = 25,
syncFlags = EnumSet.of(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED),
)
@Before
fun setUp() {
BinaryTempFileBody.setTempDirectory(File(System.getProperty("java.io.tmpdir")))
createFolderInBackendStorage()
Log.logger = TestLogger()
}
@Test
fun sessionResourceWithAuthenticationError() {
val command = createCommandSync(
MockResponse().setResponseCode(401),
)
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(syncListener.getNextEvent()).isEqualTo(SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID))
val failedEvent = syncListener.getNextEvent() as SyncListenerEvent.SyncFailed
assertThat(failedEvent.exception).isNotNull().isInstanceOf<AuthenticationFailedException>()
}
@Test
fun fullSyncStartingWithEmptyLocalMailbox() {
val server = createMockWebServer(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/email/email_query_M001_and_M002.json"),
responseBodyFromResource("/jmap_responses/email/email_get_ids_M001_and_M002.json"),
responseBodyFromResource("/jmap_responses/blob/email/email_1.eml"),
responseBodyFromResource("/jmap_responses/blob/email/email_2.eml"),
)
val baseUrl = server.url("/jmap/")
val command = createCommandSync(baseUrl)
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
backendFolder.assertMessages(
"M001" to "/jmap_responses/blob/email/email_1.eml",
"M002" to "/jmap_responses/blob/email/email_2.eml",
)
backendFolder.assertQueryState("50:0")
syncListener.assertSyncEvents(
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
SyncListenerEvent.SyncProgress(FOLDER_SERVER_ID, completed = 1, total = 2),
SyncListenerEvent.SyncProgress(FOLDER_SERVER_ID, completed = 2, total = 2),
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID),
)
server.skipRequests(3)
server.assertRequestUrlPath("/jmap/download/test%40example.com/B001/B001?accept=application%2Foctet-stream")
server.assertRequestUrlPath("/jmap/download/test%40example.com/B002/B002?accept=application%2Foctet-stream")
}
@Test
fun fullSyncExceedingMaxObjectsInGet() {
val command = createCommandSync(
responseBodyFromResource("/jmap_responses/session/session_with_maxObjectsInGet_2.json"),
responseBodyFromResource("/jmap_responses/email/email_query_M001_to_M005.json"),
responseBodyFromResource("/jmap_responses/email/email_get_ids_M001_and_M002.json"),
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003_and_M004.json"),
responseBodyFromResource("/jmap_responses/email/email_get_ids_M005.json"),
responseBodyFromResource("/jmap_responses/blob/email/email_1.eml"),
responseBodyFromResource("/jmap_responses/blob/email/email_2.eml"),
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
)
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
assertThat(backendFolder.getMessageServerIds()).containsOnly(
"M001",
"M002",
"M003",
"M004",
"M005",
)
syncListener.assertSyncSuccess()
}
@Test
fun fullSyncWithLocalMessagesAndDifferentMessagesInRemoteMailbox() {
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
backendFolder.createMessages(
"M001" to "/jmap_responses/blob/email/email_1.eml",
"M002" to "/jmap_responses/blob/email/email_2.eml",
)
val command = createCommandSync(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/email/email_query_M002_and_M003.json"),
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"),
)
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
backendFolder.assertMessages(
"M002" to "/jmap_responses/blob/email/email_2.eml",
"M003" to "/jmap_responses/blob/email/email_3.eml",
)
syncListener.assertSyncSuccess()
}
@Test
fun fullSyncWithLocalMessagesAndEmptyRemoteMailbox() {
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
backendFolder.createMessages(
"M001" to "/jmap_responses/blob/email/email_1.eml",
"M002" to "/jmap_responses/blob/email/email_2.eml",
)
val command = createCommandSync(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/email/email_query_empty_result.json"),
)
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).isEmpty()
syncListener.assertSyncEvents(
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID),
)
}
@Test
fun deltaSyncWithoutChanges() {
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
backendFolder.createMessages(
"M001" to "/jmap_responses/blob/email/email_1.eml",
"M002" to "/jmap_responses/blob/email/email_2.eml",
)
backendFolder.setQueryState("50:0")
val command = createCommandSync(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/email/email_query_changes_empty_result.json"),
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M001_and_M002.json"),
)
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsOnly("M001", "M002")
assertThat(backendFolder.getMessageFlags("M001")).isEmpty()
assertThat(backendFolder.getMessageFlags("M002")).containsOnly(Flag.SEEN)
backendFolder.assertQueryState("50:0")
syncListener.assertSyncEvents(
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID),
)
}
@Test
fun deltaSyncWithLocalMessagesAndDifferentMessagesInRemoteMailbox() {
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
backendFolder.createMessages(
"M001" to "/jmap_responses/blob/email/email_1.eml",
"M002" to "/jmap_responses/blob/email/email_2.eml",
)
backendFolder.setQueryState("50:0")
val command = createCommandSync(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/email/email_query_changes_M001_deleted_M003_added.json"),
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"),
)
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsOnly("M002", "M003")
backendFolder.assertQueryState("51:0")
syncListener.assertSyncSuccess()
}
@Test
fun deltaSyncCannotCalculateChanges() {
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
backendFolder.createMessages(
"M001" to "/jmap_responses/blob/email/email_1.eml",
"M002" to "/jmap_responses/blob/email/email_2.eml",
)
backendFolder.setQueryState("10:0")
val command = createCommandSync(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/email/email_query_changes_cannot_calculate_changes_error.json"),
responseBodyFromResource("/jmap_responses/email/email_query_M002_and_M003.json"),
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"),
)
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
assertThat(backendFolder.getMessageServerIds()).containsOnly("M002", "M003")
backendFolder.assertQueryState("50:0")
syncListener.assertSyncSuccess()
}
private fun createCommandSync(vararg mockResponses: MockResponse): CommandSync {
val server = createMockWebServer(*mockResponses)
return createCommandSync(server.url("/jmap/"))
}
private fun createCommandSync(baseUrl: HttpUrl): CommandSync {
val httpAuthentication = BasicAuthHttpAuthentication(USERNAME, PASSWORD)
val jmapClient = JmapClient(httpAuthentication, baseUrl)
return CommandSync(backendStorage, jmapClient, okHttpClient, ACCOUNT_ID, httpAuthentication)
}
private fun createFolderInBackendStorage() {
backendStorage.updateFolders {
createFolders(listOf(FolderInfo(FOLDER_SERVER_ID, "Regular folder", FolderType.REGULAR)))
}
}
private fun MockWebServer.assertRequestUrlPath(expected: String) {
val request = takeRequest()
val requestUrl = request.requestUrl ?: error("No request URL")
val requestUrlPath = requestUrl.encodedPath + "?" + requestUrl.encodedQuery
assertThat(requestUrlPath).isEqualTo(expected)
}
private fun InMemoryBackendFolder.assertQueryState(expected: String) {
assertThat(getFolderExtraString("jmapQueryState")).isEqualTo(expected)
}
private fun InMemoryBackendFolder.setQueryState(queryState: String) {
setFolderExtraString("jmapQueryState", queryState)
}
companion object {
private const val FOLDER_SERVER_ID = "id_folder"
private const val USERNAME = "username"
private const val PASSWORD = "password"
private const val ACCOUNT_ID = "test@example.com"
}
}

View file

@ -0,0 +1,94 @@
package com.fsck.k9.backend.jmap
import assertk.assertThat
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.fail
import com.fsck.k9.backend.api.SyncListener
class LoggingSyncListener : SyncListener {
private val events = mutableListOf<SyncListenerEvent>()
fun assertSyncSuccess() {
events.filterIsInstance<SyncListenerEvent.SyncFailed>().firstOrNull()?.let { syncFailed ->
throw AssertionError("Expected sync success", syncFailed.exception)
}
if (events.none { it is SyncListenerEvent.SyncFinished }) {
fail("Expected SyncFinished, but only got: $events")
}
}
fun assertSyncEvents(vararg events: SyncListenerEvent) {
for (event in events) {
assertThat(getNextEvent()).isEqualTo(event)
}
assertThat(this.events).isEmpty()
}
fun getNextEvent(): SyncListenerEvent {
require(events.isNotEmpty()) { "No events left" }
return events.removeAt(0)
}
override fun syncStarted(folderServerId: String) {
events.add(SyncListenerEvent.SyncStarted(folderServerId))
}
override fun syncAuthenticationSuccess() {
throw UnsupportedOperationException("not implemented")
}
override fun syncHeadersStarted(folderServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun syncHeadersProgress(folderServerId: String, completed: Int, total: Int) {
throw UnsupportedOperationException("not implemented")
}
override fun syncHeadersFinished(folderServerId: String, totalMessagesInMailbox: Int, numNewMessages: Int) {
throw UnsupportedOperationException("not implemented")
}
override fun syncProgress(folderServerId: String, completed: Int, total: Int) {
events.add(SyncListenerEvent.SyncProgress(folderServerId, completed, total))
}
override fun syncNewMessage(folderServerId: String, messageServerId: String, isOldMessage: Boolean) {
throw UnsupportedOperationException("not implemented")
}
override fun syncRemovedMessage(folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun syncFlagChanged(folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun syncFinished(folderServerId: String) {
events.add(SyncListenerEvent.SyncFinished(folderServerId))
}
override fun syncFailed(folderServerId: String, message: String, exception: Exception?) {
events.add(SyncListenerEvent.SyncFailed(folderServerId, message, exception))
}
override fun folderStatusChanged(folderServerId: String) {
throw UnsupportedOperationException("not implemented")
}
}
sealed class SyncListenerEvent {
data class SyncStarted(val folderServerId: String) : SyncListenerEvent()
data class SyncFinished(val folderServerId: String) : SyncListenerEvent()
data class SyncFailed(
val folderServerId: String,
val message: String,
val exception: Exception?,
) : SyncListenerEvent()
data class SyncProgress(val folderServerId: String, val completed: Int, val total: Int) : SyncListenerEvent()
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,32 @@
{
"methodResponses": [
[
"Email/get",
{
"state": "50",
"list": [
{
"id": "M001",
"blobId": "B001",
"keywords": {},
"size": 280,
"receivedAt": "2020-02-11T11:00:00Z"
},
{
"id": "M002",
"blobId": "B002",
"keywords": {
"$seen": true
},
"size": 365,
"receivedAt": "2020-01-11T12:00:00Z"
}
],
"notFound": [],
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

Some files were not shown because too many files have changed in this diff Show more