Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 18:55:42 +01:00
parent a629de6271
commit 3cef7c5092
2161 changed files with 246605 additions and 2 deletions

View file

@ -0,0 +1,9 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.mail.common)
}

View file

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

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,27 @@
package com.fsck.k9.backend.api
import com.fsck.k9.mail.FolderType
import java.io.Closeable
interface BackendStorage {
fun getFolder(folderServerId: String): BackendFolder
fun getFolderServerIds(): List<String>
fun createFolderUpdater(): BackendFolderUpdater
fun getExtraString(name: String): String?
fun setExtraString(name: String, value: String)
fun getExtraNumber(name: String): Long?
fun setExtraNumber(name: String, value: Long)
}
interface BackendFolderUpdater : Closeable {
fun createFolders(folders: List<FolderInfo>)
fun deleteFolders(folderServerIds: List<String>)
fun changeFolder(folderServerId: String, name: String, type: FolderType)
}
inline fun BackendStorage.updateFolders(block: BackendFolderUpdater.() -> Unit) {
createFolderUpdater().use { it.block() }
}

View file

@ -0,0 +1,5 @@
package com.fsck.k9.backend.api
import com.fsck.k9.mail.FolderType
data class FolderInfo(val serverId: String, val name: String, val type: FolderType)

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,16 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.ksp)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.backend.api)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.moshi)
ksp(libs.moshi.kotlin.codegen)
testImplementation(projects.mail.testing)
}

View file

@ -0,0 +1,210 @@
package app.k9mail.backend.demo
import com.fsck.k9.backend.api.Backend
import com.fsck.k9.backend.api.BackendFolder.MoreMessages
import com.fsck.k9.backend.api.BackendPusher
import com.fsck.k9.backend.api.BackendPusherCallback
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.backend.api.updateFolders
import com.fsck.k9.mail.BodyFactory
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.MessageDownloadState
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.internet.MimeMessage
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.util.UUID
import okio.buffer
import okio.source
class DemoBackend(private val backendStorage: BackendStorage) : Backend {
private val messageStoreInfo by lazy { readMessageStoreInfo() }
override val supportsFlags: Boolean = true
override val supportsExpunge: Boolean = false
override val supportsMove: Boolean = true
override val supportsCopy: Boolean = true
override val supportsUpload: Boolean = true
override val supportsTrashFolder: Boolean = true
override val supportsSearchByDate: Boolean = false
override val isPushCapable: Boolean = false
override fun refreshFolderList() {
val localFolderServerIds = backendStorage.getFolderServerIds().toSet()
backendStorage.updateFolders {
val remoteFolderServerIds = messageStoreInfo.keys
val foldersServerIdsToCreate = remoteFolderServerIds - localFolderServerIds
val foldersToCreate = foldersServerIdsToCreate.mapNotNull { folderServerId ->
messageStoreInfo[folderServerId]?.let { folderData ->
FolderInfo(folderServerId, folderData.name, folderData.type)
}
}
createFolders(foldersToCreate)
val folderServerIdsToRemove = (localFolderServerIds - remoteFolderServerIds).toList()
deleteFolders(folderServerIdsToRemove)
}
}
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
listener.syncStarted(folderServerId)
val folderData = messageStoreInfo[folderServerId]
if (folderData == null) {
listener.syncFailed(folderServerId, "Folder $folderServerId doesn't exist", null)
return
}
val backendFolder = backendStorage.getFolder(folderServerId)
val localMessageServerIds = backendFolder.getMessageServerIds()
if (localMessageServerIds.isNotEmpty()) {
listener.syncFinished(folderServerId)
return
}
for (messageServerId in folderData.messageServerIds) {
val message = loadMessage(folderServerId, messageServerId)
backendFolder.saveMessage(message, MessageDownloadState.FULL)
listener.syncNewMessage(folderServerId, messageServerId, isOldMessage = false)
}
backendFolder.setMoreMessages(MoreMessages.FALSE)
listener.syncFinished(folderServerId)
}
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) = Unit
override fun markAllAsRead(folderServerId: String) = Unit
override fun expunge(folderServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun expungeMessages(folderServerId: String, messageServerIds: List<String>) {
throw UnsupportedOperationException("not implemented")
}
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) = Unit
override fun deleteAllMessages(folderServerId: String) = Unit
override fun moveMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>
): Map<String, String> {
// We do just enough to simulate a successful operation on the server.
return messageServerIds.associateWith { createNewServerId() }
}
override fun moveMessagesAndMarkAsRead(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>
): Map<String, String> {
// We do just enough to simulate a successful operation on the server.
return messageServerIds.associateWith { createNewServerId() }
}
override fun copyMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>
): Map<String, String> {
// We do just enough to simulate a successful operation on the server.
return messageServerIds.associateWith { createNewServerId() }
}
override fun search(
folderServerId: String,
query: String?,
requiredFlags: Set<Flag>?,
forbiddenFlags: Set<Flag>?,
performFullTextSearch: Boolean
): List<String> {
throw UnsupportedOperationException("not implemented")
}
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
throw UnsupportedOperationException("not implemented")
}
override fun findByMessageId(folderServerId: String, messageId: String): String? {
throw UnsupportedOperationException("not implemented")
}
override fun uploadMessage(folderServerId: String, message: Message): String {
return createNewServerId()
}
override fun checkIncomingServerSettings() = Unit
override fun checkOutgoingServerSettings() = Unit
override fun sendMessage(message: Message) {
val inboxServerId = messageStoreInfo.filterValues { it.type == FolderType.INBOX }.keys.first()
val backendFolder = backendStorage.getFolder(inboxServerId)
val newMessage = message.copy(uid = createNewServerId())
backendFolder.saveMessage(newMessage, MessageDownloadState.FULL)
}
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
throw UnsupportedOperationException("not implemented")
}
private fun createNewServerId() = UUID.randomUUID().toString()
private fun Message.copy(uid: String): MimeMessage {
val outputStream = ByteArrayOutputStream()
writeTo(outputStream)
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
return MimeMessage.parseMimeMessage(inputStream, false).apply {
this.uid = uid
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun readMessageStoreInfo(): MessageStoreInfo {
return getResourceAsStream("/contents.json").source().buffer().use { bufferedSource ->
val moshi = Moshi.Builder().build()
val adapter = moshi.adapter<MessageStoreInfo>()
adapter.fromJson(bufferedSource)
} ?: error("Couldn't read message store info")
}
private fun loadMessage(folderServerId: String, messageServerId: String): Message {
return getResourceAsStream("/$folderServerId/$messageServerId.eml").use { inputStream ->
MimeMessage.parseMimeMessage(inputStream, false).apply {
uid = messageServerId
}
}
}
private fun getResourceAsStream(name: String): InputStream {
return DemoBackend::class.java.getResourceAsStream(name) ?: error("Resource '$name' not found")
}
}

View file

@ -0,0 +1,13 @@
package app.k9mail.backend.demo
import com.fsck.k9.mail.FolderType
import com.squareup.moshi.JsonClass
typealias MessageStoreInfo = Map<String, FolderData>
@JsonClass(generateAdapter = true)
data class FolderData(
val name: String,
val type: FolderType,
val messageServerIds: List<String>
)

View file

@ -0,0 +1,60 @@
{
"inbox": {
"name": "Inbox",
"type": "INBOX",
"messageServerIds": [
"intro",
"many_recipients",
"thread_1",
"thread_2",
"inline_image_data_uri",
"inline_image_attachment"
]
},
"trash": {
"name": "Trash",
"type": "TRASH",
"messageServerIds": []
},
"drafts": {
"name": "Drafts",
"type": "DRAFTS",
"messageServerIds": []
},
"sent": {
"name": "Sent",
"type": "SENT",
"messageServerIds": []
},
"archive": {
"name": "Archive",
"type": "ARCHIVE",
"messageServerIds": []
},
"spam": {
"name": "Spam",
"type": "SPAM",
"messageServerIds": []
},
"turing": {
"name": "Turing Awards",
"type": "REGULAR",
"messageServerIds": [
"turing_award_1966",
"turing_award_1967",
"turing_award_1968",
"turing_award_1970",
"turing_award_1971",
"turing_award_1972",
"turing_award_1975",
"turing_award_1977",
"turing_award_1978",
"turing_award_1979",
"turing_award_1981",
"turing_award_1983",
"turing_award_1987",
"turing_award_1991",
"turing_award_1996"
]
}
}

View file

@ -0,0 +1,248 @@
MIME-Version: 1.0
From: "Test data" <data@k9mail.example>
Date: Tue, 14 Feb 2023 15:00:00 +0100
Message-ID: <inbox-6@k9mail.example>
Subject: Inline image attachment
To: User <user@k9mail.example>
Content-Type: multipart/related; boundary=BOUNDARY
--BOUNDARY
Content-Type: text/html; charset=UTF-8
<html>
<body>
<p>Inline image using a cid: URI to reference an attached image:</p>
<img src="cid:part1@k9mail.example">
</body>
</html>
--BOUNDARY
Content-Type: image/png; name="k9mail.png"
Content-Transfer-Encoding: base64
Content-ID: <part1@k9mail.example>
Content-Disposition: inline; filename="k9mail.png"
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
--BOUNDARY--

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

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@cketti.de>
Subject: The Synthesis of Algorithmic Systems
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320
--047d7b450b100959e604d85a5320
Content-Type: text/plain; charset=UTF-8
Both knowledge and wisdom extend man's reach. Knowledge led to computers,
wisdom to chopsticks. Unfortunately our association is overinvolved with
the former. The latter will have to wait for a more sublime day.
On what does and will the fame of Turing rest? That he proved a theorem
showing that for a general computing device--later dubbed a "Turing
machine"--there existed functions which it could not compute? I doubt it.
More likely it rests on the model he invented and employed: his formal
mechanism.
This model has captured the imagination and mobilized the thoughts of a
generation of scientists. It has provided a basis for arguments leading to
theories. His model has proved so useful that its generated activity has
been distributed not only in mathematics, but through several technologies
as well. The arguments that have been employed are not always formal and
the consequent creations not all abstract.
Indeed a most fruitful consequence of the Turing machine has been with the
creation, study and computation of functions which are computable, i.e., in
computer programming. This is not surprising since computers can compute so
much more than we yet know how to specify.
I am sure that all will agree that this model has been enormously valuable.
History will forgive me for not devoting any attention in this lecture to
the effect which Turing had on the development of the general-purpose
digital computer, which has further accelerated our involvement with the
theory and practice of computation.
Since the appearance of Turing's model there have, of course, been others
which have concerned and benefited us in computing. I think, however, that
only one has had an effect as great as Turing's: the formal mechanism
called ALGOL Many will immediately disagree, pointing out that too few of
us have understood it or used it.
While such has, unhappily, been the case, it is not the point. The impulse
given by ALGOL to the development of research in computer science is
relevant while the number of adherents is not. ALGOL, too, has mobilized
our thoughts and has provided us with a basis for our arguments.
--047d7b450b100959e604d85a5320
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Both knowledge and wisdom extend man&#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@cketti.de>
Subject: Computers Then and Now
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=047d7b5d9bdd0d571a04d85aec30
--047d7b5d9bdd0d571a04d85aec30
Content-Type: text/plain; charset=UTF-8
I do not imagine that many of the Turing lecturers who will follow me will
be people who were acquainted with Alan Turing. The work on computable
numbers, for which he is famous, was published in 1936 before digital
computers existed. Later he became one of the first of a distinguished
succession of able mathematicians who have made contributions to the
computer field. He was a colorful figure in the early days of digital
computer development in England, and I would find it difficult to speak of
that period without making some references to him.
--047d7b5d9bdd0d571a04d85aec30
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>I do not imagine that many of the Turing lecturers wh=
o will follow me will be people who were acquainted with Alan Turing. The w=
ork on computable numbers, for which he is famous, was published in 1936 be=
fore digital computers existed. Later he became one of the first of a disti=
nguished succession of able mathematicians who have made contributions to t=
he computer field. He was a colorful figure in the early days of digital co=
mputer development in England, and I would find it difficult to speak of th=
at period without making some references to him.</div>
</div>
--047d7b5d9bdd0d571a04d85aec30--

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@cketti.de>
Subject: One Man's View of Computer Science
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=089e01227b30f6f60004d85af2ae
--089e01227b30f6f60004d85af2ae
Content-Type: text/plain; charset=UTF-8
Let me begin with a few personal words. When one is notified that he has
been elected the ACM Turing lecturer for the year, he is at first
surprised--especially is the nonacademic person surprised by an ACM award.
After a little while the surprise is replaced by a feeling of pleasure.
Still later comes a feeling of "Why me?" With all that has been done and is
being done in computing, why single out me and my work? Well, I suppose
that it has to happen to someone each year, and this
time I am the lucky person. Anyway, let me thank you for the honor you have
given to me and by inference to the Bell Telephone Laboratories where I
work and which has made possible so much of what I have done.
--089e01227b30f6f60004d85af2ae
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Let me begin with a few personal words. When one is n=
otified that he has been elected the ACM Turing lecturer for the year, he i=
s at first surprised--especially is the nonacademic person surprised by an =
ACM award. After a little while the surprise is replaced by a feeling of pl=
easure. Still later comes a feeling of &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@cketti.de>
Subject: Some Comments from a Numerical Analyst
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=047d7b5d9bdd9697d504d85ac65f
--047d7b5d9bdd9697d504d85ac65f
Content-Type: text/plain; charset=UTF-8
When at last I recovered from the feeling of shocked elation at being
invited to give the 1970 Turing Award Lecture, I became aware that I must
indeed prepare an appropriate lecture. There appears to be a tradition that
a Turing Lecturer should decide for himself what is expected from him, and
probably for this reason previous lectures have differed considerably in
style and content. However, it was made quite clear that I was to give an
after-luncheon speech and that I would not have the benefit of an overhead
projector or a blackboard.
--047d7b5d9bdd9697d504d85ac65f
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>When at last I recovered from the feeling of shocked =
elation at being invited to give the 1970 Turing Award Lecture, I became aw=
are that I must indeed prepare an appropriate lecture. There appears to be =
a tradition that a Turing Lecturer should decide for himself what is expect=
ed from him, and probably for this reason previous lectures have differed c=
onsiderably in style and content. However, it was made quite clear that I w=
as to give an after-luncheon speech and that I would not have the benefit o=
f an overhead projector or a blackboard.</div>
</div>
--047d7b5d9bdd9697d504d85ac65f--

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@cketti.de>
Subject: Generality in Artificial Intelligence
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=089e01030106b6942904d85ad870
--089e01030106b6942904d85ad870
Content-Type: text/plain; charset=UTF-8
Postscript
My 1971 Turing Award Lecture was entitled "Generality in Artificial
Intelligence." The topic turned out to have been overambitious in that I
discovered that I was unable to put my thoughts on the subject in a
satisfactory written form at that time. It would have been better to have
reviewed previous work rather than attempt something new, but such wasn't
my custom at that time.
--089e01030106b6942904d85ad870
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Postscript</div><div>My 1971 Turing Award Lecture was=
entitled &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@cketti.de>
Subject: The Humble Programmer
To: Alan Turing <alan@turing.example>
Content-Type: text/plain; charset=UTF-8; format=flowed
As a result of a long sequence of coincidences I entered the programming
profession officially on the first spring morning of 1952, and as far as
I have been able to trace, I was the first Dutchman to do so in my
country. In retrospect the most amazing thing is the slowness with which,
at least in my part of the world, the programming profession emerged, a
slowness which is now hard to believe. But I am grateful for two vivid
recollections from that period that establish that slowness beyond any
doubt.
After having programmed for some three years, I had a discussion with
van Wijngaarden, who was then my boss at the Mathematical Centre in
Amsterdam - a discussion for which I shall remain grateful to him
as long as I live. The point was that I was supposed to study theoretical
physics at the University of Leiden simultaneously, and as I found the
two activities harder and harder to combine, I had to make up my
mind, either to stop programming and become a real, respectable theoretical
physicist, or to carry my study of physics to a formal completion only,
with a minimum of effort, and to become..., yes what? A programmer?
But was that a respectable profession? After all, what was programming?

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@cketti.de>
Subject: Computer Science as Empirical Inquiry: Symbols and Search
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=047d7b450b1092035304d85abf33
--047d7b450b1092035304d85abf33
Content-Type: text/plain; charset=UTF-8
Computer science is the study of the phenomena surrounding computers. The
founders of this society understood this very well when they called
themselves the Association for Computing Machinery. The machine---not just
the hardware, but the programmed, living machine--is the organism we study.
--047d7b450b1092035304d85abf33
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr">Computer science is the study of the phenomena surrounding=
computers. The founders of this society understood this very well when the=
y called themselves the Association for Computing Machinery. The machine---=
not just the hardware, but the programmed, living machine--is the organism =
we study.<br>
</div>
--047d7b450b1092035304d85abf33--

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

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@cketti.de>
Subject: The Paradigms of Programming
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=089e0118419206e64304d85af860
--089e0118419206e64304d85af860
Content-Type: text/plain; charset=UTF-8
Today I want to talk about the paradigms of programming, how they affect
our success as designers of computer programs, how they should be taught,
and how they should be embodied in our programming languages.
A familiar example of a paradigm of programming is the technique of
structured programming, which appears to be the dominant paradigm in most
current treatments of programming methodology. Structured programming, as
formulated by Dijkstra, Wirth, and Parnas, among others, consists of two
phases.
--089e0118419206e64304d85af860
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Today I want to talk about the paradigms of programmi=
ng, how they affect our success as designers of computer programs, how they=
should be taught, and how they should be embodied in our programming langu=
ages.=C2=A0</div>
<div>A familiar example of a paradigm of programming is the technique of st=
ructured programming, which appears to be the dominant paradigm in most cur=
rent treatments of programming methodology. Structured programming, as form=
ulated by Dijkstra, Wirth, and Parnas, among others, consists of two phases=
.=C2=A0</div>
</div>
--089e0118419206e64304d85af860--

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@cketti.de>
Subject: Notation as a Tool of Thought
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=20cf30549cad76254e04d85ae4df
--20cf30549cad76254e04d85ae4df
Content-Type: text/plain; charset=UTF-8
The importance of nomenclature, notation, and language as tools of thought
has long been recognized. In chemistry and in botany, for example, the
establishment of systems of nomenclature by Lavoisier and Linnaeus did much
to stimulate and to channel later investigation. Concerning language,
George Boole in his Laws off Thought asserted "That language is an
instrument of human reason, and not merely a medium for the expression of
thought, is a truth generally admitted."
--20cf30549cad76254e04d85ae4df
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>The importance of nomenclature, notation, and languag=
e as tools of thought has long been recognized. In chemistry and in botany,=
for example, the establishment of systems of nomenclature by Lavoisier and=
Linnaeus did much to stimulate and to channel later investigation. Concern=
ing language, George Boole in his Laws off Thought asserted &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@cketti.de>
Subject: Relational Database: A Practical Foundation for Productivity
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=047d7bfd026c782f2404d85ab4b8
--047d7bfd026c782f2404d85ab4b8
Content-Type: text/plain; charset=UTF-8
It is well known that the growth in demands from end users for new
applications is outstripping the capability of data processing departments
to implement the corresponding application programs. There are two
complementary approaches to attacking this problem (and both approaches are
needed): one is to put end users into direct touch with the information
stored in computers; the other is to increase the productivity of data
processing professionals in the development of application programs. It is
less well known that a single technology, relational database management,
provides a practical foundation for both approaches. It is explained why
this
is so.
While developing this productivity theme, it is noted that the time has
come to draw a very sharp line between relational and non-relational
database systems, so that the label "relational" will not be used in
misleading ways.
The key to drawing this line is something called a "relational processing
capability."
--047d7bfd026c782f2404d85ab4b8
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>It is well known that the growth in demands from end =
users for new applications is outstripping the capability of data processin=
g departments to implement the corresponding application programs. There ar=
e two complementary approaches to attacking this problem (and both approach=
es are needed): one is to put end users into direct touch with the informat=
ion stored in computers; the other is to increase the productivity of data =
processing professionals in the development of application programs. It is =
less well known that a single technology, relational database management, p=
rovides a practical foundation for both approaches. It is explained why thi=
s=C2=A0</div>
<div><div>is so.=C2=A0</div><div>While developing this productivity theme, =
it is noted that the time has come to draw a very sharp line between relati=
onal and non-relational database systems, so that the label &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@cketti.de>
Subject: Reflections on Software Research
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=bcaec54fbb2250035a04d85aabcd
--bcaec54fbb2250035a04d85aabcd
Content-Type: text/plain; charset=UTF-8
The UNIX operating system has suddenly become news, but it is not new. It
began in 1969 when Ken Thompson discovered a little-used PDP-7 computer and
set out to fashion a computing environment that he liked, His work soon
attracted me; I joined in the enterprise, though most of the ideas, and
most of the work for that matter, were his. Before long, others from our
group in the research area of AT&T Bell Laboratories were using the system;
Joe Ossanna, Doug Mcllroy, and
Bob Morris were especially enthusiastic critics and contributors, tn 1971,
we acquired a PDP-11, and by the end of that year we were supporting our
first real users: three typists entering patent applications. In 1973, the
system was rewritten in the C language, and in that year, too, it was first
described publicly at the Operating Systems Principles conference; the
resulting paper appeared in Communications of the ACM the next year.
--bcaec54fbb2250035a04d85aabcd
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>The UNIX operating system has suddenly become news, b=
ut it is not new. It began in 1969 when Ken Thompson discovered a little-us=
ed PDP-7 computer and set out to fashion a computing environment that he li=
ked, His work soon attracted me; I joined in the enterprise, though most of=
the ideas, and most of the work for that matter, were his. Before long, ot=
hers from our group in the research area of AT&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@cketti.de>
Subject: The Search for Performance in Scientific Processors
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=047d7bfd079665fb2c04d85ad0bc
--047d7bfd079665fb2c04d85ad0bc
Content-Type: text/plain; charset=UTF-8
I am honored and grateful to have been selected to join the ranks of ACM
Turing Award winners. I probably have spent too much of my life thinking
about computers, but I do not regret it a bit. I was fortunate to enter the
field of computing in its infancy and participate in its explosive growth.
The rapid evolution of the underlying technologies in the past 30 years has
not only provided an exciting environment, but has also presented a
constant stream of intellectual challenges to those of us trying to harness
this power and squeeze it to the last ounce. I hasten to say, especially to
the
younger members of the audience, there is no end in sight. As a matter of
fact, I believe the next thirty years will be even more exciting and rich
with challenges.
--047d7bfd079665fb2c04d85ad0bc
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>I am honored and grateful to have been selected to jo=
in the ranks of ACM Turing Award winners. I probably have spent too much of=
my life thinking about computers, but I do not regret it a bit. I was fort=
unate to enter the field of computing in its infancy and participate in its=
explosive growth. The rapid evolution of the underlying technologies in th=
e past 30 years has not only provided an exciting environment, but has also=
presented a constant stream of intellectual challenges to those of us tryi=
ng to harness this power and squeeze it to the last ounce. I hasten to say,=
especially to the=C2=A0</div>
<div>younger members of the audience, there is no end in sight. As a matter=
of fact, I believe the next thirty years will be even more exciting and ri=
ch with challenges.=C2=A0</div></div>
--047d7bfd079665fb2c04d85ad0bc--

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@cketti.de>
Subject: Elements of Interaction
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=047d7b86e6de64aecb04d85affff
--047d7b86e6de64aecb04d85affff
Content-Type: text/plain; charset=UTF-8
I am greatly honored to receive this award, bearing the name of Alan
Turing. Perhaps Turing would be pleased that it should go to someone
educated at his old college, King's College at Cambridge. While there in
1956 I wrote my first computer program; it was on the EDSAC. Of course
EDSAC made history. But I am ashamed to say it did not lure me into
computing, and I ignored computers for four years. In 1960 I thought that
computers might be more peaceful to handle than schoolchildren--I was then
a teacher--so I applied for a job at Ferranti in London, at the time of
Pegasus. I was asked at the interview whether I would like to devote my
life to computers. This daunting notion had never crossed my mind. Well,
here I am still, and I have had the lucky chance to grow alongside computer
science.
--047d7b86e6de64aecb04d85affff
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>I am greatly honored to receive this award, bearing t=
he name of Alan Turing. Perhaps Turing would be pleased that it should go t=
o someone educated at his old college, King&#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@cketti.de>
Subject: Verification Engineering: A Future Profession
To: Alan Turing <alan@turing.example>
Content-Type: multipart/alternative; boundary=bcaec54fbb222acf6704d85aa523
--bcaec54fbb222acf6704d85aa523
Content-Type: text/plain; charset=UTF-8
It is time that formal verification (of both software and hardware systems)
be demoted from an art practiced by the enlightened few to an activity
routinely and mundanely performed by a cadre of Verification Engineers (a
new profession), as a standard part of the system development process.
--bcaec54fbb222acf6704d85aa523
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>It is time that formal verification (of both software=
and hardware systems) be demoted from an art practiced by the enlightened =
few to an activity routinely and mundanely performed by a cadre of Verifica=
tion Engineers (a new profession), as a standard part of the system develop=
ment process.</div>
</div>
--bcaec54fbb222acf6704d85aa523--

View file

@ -0,0 +1,17 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.backend.api)
api(projects.mail.protocols.imap)
api(projects.mail.protocols.smtp)
implementation(libs.kotlinx.coroutines.core)
testImplementation(projects.mail.testing)
testImplementation(projects.backend.testing)
testImplementation(libs.mime4j.dom)
}

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,24 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.MessagingException
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
internal class CommandDeleteAll(private val imapStore: ImapStore) {
@Throws(MessagingException::class)
fun deleteAll(folderServerId: String) {
val remoteFolder = imapStore.getFolder(folderServerId)
if (!remoteFolder.exists()) {
return
}
try {
remoteFolder.open(OpenMode.READ_WRITE)
remoteFolder.setFlags(setOf(Flag.DELETED), true)
} finally {
remoteFolder.close()
}
}
}

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,40 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.logging.Timber
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
internal class CommandExpunge(private val imapStore: ImapStore) {
fun expunge(folderServerId: String) {
Timber.d("processPendingExpunge: folder = %s", folderServerId)
val remoteFolder = imapStore.getFolder(folderServerId)
try {
if (!remoteFolder.exists()) return
remoteFolder.open(OpenMode.READ_WRITE)
if (remoteFolder.mode != OpenMode.READ_WRITE) return
remoteFolder.expunge()
Timber.d("processPendingExpunge: complete for folder = %s", folderServerId)
} finally {
remoteFolder.close()
}
}
fun expungeMessages(folderServerId: String, messageServerIds: List<String>) {
val remoteFolder = imapStore.getFolder(folderServerId)
try {
if (!remoteFolder.exists()) return
remoteFolder.open(OpenMode.READ_WRITE)
if (remoteFolder.mode != OpenMode.READ_WRITE) return
remoteFolder.expungeUids(messageServerIds)
} finally {
remoteFolder.close()
}
}
}

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,22 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
internal class CommandMarkAllAsRead(private val imapStore: ImapStore) {
fun markAllAsRead(folderServerId: String) {
val remoteFolder = imapStore.getFolder(folderServerId)
if (!remoteFolder.exists()) return
try {
remoteFolder.open(OpenMode.READ_WRITE)
if (remoteFolder.mode != OpenMode.READ_WRITE) return
remoteFolder.setFlags(setOf(Flag.SEEN), true)
} finally {
remoteFolder.close()
}
}
}

View file

@ -0,0 +1,77 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.logging.Timber
import com.fsck.k9.mail.MessagingException
import com.fsck.k9.mail.store.imap.ImapFolder
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.OpenMode
internal class CommandMoveOrCopyMessages(private val imapStore: ImapStore) {
fun moveMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>
): Map<String, String>? {
return moveOrCopyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds, false)
}
fun copyMessages(
sourceFolderServerId: String,
targetFolderServerId: String,
messageServerIds: List<String>
): Map<String, String>? {
return moveOrCopyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds, true)
}
private fun moveOrCopyMessages(
srcFolder: String,
destFolder: String,
uids: Collection<String>,
isCopy: Boolean
): Map<String, String>? {
var remoteSrcFolder: ImapFolder? = null
var remoteDestFolder: ImapFolder? = null
return try {
remoteSrcFolder = imapStore.getFolder(srcFolder)
if (uids.isEmpty()) {
Timber.i("moveOrCopyMessages: no remote messages to move, skipping")
return null
}
if (!remoteSrcFolder.exists()) {
throw MessagingException("moveOrCopyMessages: remoteFolder $srcFolder does not exist", true)
}
remoteSrcFolder.open(OpenMode.READ_WRITE)
if (remoteSrcFolder.mode != OpenMode.READ_WRITE) {
throw MessagingException(
"moveOrCopyMessages: could not open remoteSrcFolder $srcFolder read/write",
true
)
}
val messages = uids.map { uid -> remoteSrcFolder.getMessage(uid) }
Timber.d(
"moveOrCopyMessages: source folder = %s, %d messages, destination folder = %s, isCopy = %s",
srcFolder,
messages.size,
destFolder,
isCopy
)
remoteDestFolder = imapStore.getFolder(destFolder)
if (isCopy) {
remoteSrcFolder.copyMessages(messages, remoteDestFolder)
} else {
remoteSrcFolder.moveMessages(messages, remoteDestFolder)
}
} finally {
remoteSrcFolder?.close()
remoteDestFolder?.close()
}
}
}

View file

@ -0,0 +1,47 @@
package com.fsck.k9.backend.imap
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.updateFolders
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.store.imap.FolderListItem
import com.fsck.k9.mail.store.imap.ImapStore
internal class CommandRefreshFolderList(
private val backendStorage: BackendStorage,
private val imapStore: ImapStore
) {
fun refreshFolderList() {
// TODO: Start using the proper server ID.
// For now we still use the old server ID format (decoded, with prefix removed).
val foldersOnServer = imapStore.getFolders().toLegacyFolderList()
val oldFolderServerIds = backendStorage.getFolderServerIds()
backendStorage.updateFolders {
val foldersToCreate = mutableListOf<FolderInfo>()
for (folder in foldersOnServer) {
if (folder.serverId !in oldFolderServerIds) {
foldersToCreate.add(FolderInfo(folder.serverId, folder.name, folder.type))
} else {
changeFolder(folder.serverId, folder.name, folder.type)
}
}
createFolders(foldersToCreate)
val newFolderServerIds = foldersOnServer.map { it.serverId }
val removedFolderServerIds = oldFolderServerIds - newFolderServerIds
deleteFolders(removedFolderServerIds)
}
}
}
private fun List<FolderListItem>.toLegacyFolderList(): List<LegacyFolderListItem> {
return this.filterNot { it.oldServerId == null }
.map { LegacyFolderListItem(it.oldServerId!!, it.name, it.type) }
}
private data class LegacyFolderListItem(
val serverId: String,
val name: String,
val type: FolderType
)

View file

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.ksp)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.backend.api)
api(libs.okhttp)
implementation(libs.jmap.client)
implementation(libs.moshi)
ksp(libs.moshi.kotlin.codegen)
testImplementation(projects.mail.testing)
testImplementation(projects.backend.testing)
testImplementation(libs.okhttp.mockwebserver)
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

@ -0,0 +1,23 @@
{
"methodResponses": [
[
"Email/get",
{
"state": "50",
"list": [
{
"id": "M003",
"blobId": "B003",
"keywords": {},
"size": 215,
"receivedAt": "2020-02-11T13:00:00Z"
}
],
"notFound": [],
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,30 @@
{
"methodResponses": [
[
"Email/get",
{
"state": "50",
"list": [
{
"id": "M003",
"blobId": "B003",
"keywords": {},
"size": 215,
"receivedAt": "2020-02-11T13:00:00Z"
},
{
"id": "M004",
"blobId": "B004",
"keywords": {},
"size": 215,
"receivedAt": "2020-01-11T13:00:00Z"
}
],
"notFound": [],
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,23 @@
{
"methodResponses": [
[
"Email/get",
{
"state": "50",
"list": [
{
"id": "M005",
"blobId": "B005",
"keywords": {},
"size": 215,
"receivedAt": "2020-01-11T13:00:00Z"
}
],
"notFound": [],
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,26 @@
{
"methodResponses": [
[
"Email/get",
{
"state": "50",
"list": [
{
"id": "M001",
"keywords": {}
},
{
"id": "M002",
"keywords": {
"$seen": true
}
}
],
"notFound": [],
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,20 @@
{
"methodResponses": [
[
"Email/get",
{
"state": "50",
"list": [
{
"id": "M002",
"keywords": {}
}
],
"notFound": [],
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,24 @@
{
"methodResponses": [
[
"Email/query",
{
"filter": {
"inMailbox": "id_folder"
},
"queryState": "50:0",
"canCalculateChanges": true,
"position": 0,
"total": 2,
"ids": [
"M001",
"M002"
],
"collapseThreads": false,
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,27 @@
{
"methodResponses": [
[
"Email/query",
{
"filter": {
"inMailbox": "id_folder"
},
"queryState": "50:0",
"canCalculateChanges": true,
"position": 0,
"total": 5,
"ids": [
"M001",
"M002",
"M003",
"M004",
"M005"
],
"collapseThreads": false,
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,24 @@
{
"methodResponses": [
[
"Email/query",
{
"filter": {
"inMailbox": "id_folder"
},
"queryState": "50:0",
"canCalculateChanges": true,
"position": 0,
"total": 2,
"ids": [
"M002",
"M003"
],
"collapseThreads": false,
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,21 @@
{
"methodResponses": [
[
"Email/queryChanges",
{
"accountId": "test@example.com",
"oldQueryState": "50:0",
"newQueryState": "51:0",
"removed": ["M001"],
"added": [
{
"id": "M003",
"index": 1
}
]
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,12 @@
{
"methodResponses": [
[
"error",
{
"type": "cannotCalculateChanges"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,16 @@
{
"methodResponses": [
[
"Email/queryChanges",
{
"accountId": "test@example.com",
"oldQueryState": "50:0",
"newQueryState": "50:0",
"removed": [],
"added": []
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,21 @@
{
"methodResponses": [
[
"Email/query",
{
"filter": {
"inMailbox": "id_folder"
},
"queryState": "50:0",
"canCalculateChanges": true,
"position": 0,
"total": 0,
"ids": [],
"collapseThreads": false,
"accountId": "test@example.com"
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,86 @@
{
"methodResponses": [
[
"Mailbox/changes",
{
"accountId": "test@example.com",
"oldState": "23",
"newState": "42",
"hasMoreChanges": false,
"created": [ "id_folder2" ],
"updated": [ "id_trash" ],
"destroyed": [ "id_folder1" ]
},
"0"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "42",
"list": [
{
"id": "id_folder2",
"name": "folder2",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": null,
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"sortOrder": 10,
"isSubscribed": false
}
]
},
"1"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "42",
"list": [
{
"id": "id_trash",
"name": "Deleted messages",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "trash",
"totalEmails": 2,
"unreadEmails": 0,
"totalThreads": 2,
"unreadThreads": 0,
"sortOrder": 7,
"isSubscribed": false
}
]
},
"2"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,61 @@
{
"methodResponses": [
[
"Mailbox/changes",
{
"accountId": "test@example.com",
"oldState": "23",
"newState": "27",
"hasMoreChanges": true,
"created": [ "id_folder2" ],
"updated": [],
"destroyed": []
},
"0"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "27",
"list": [
{
"id": "id_folder2",
"name": "folder2",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": null,
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"sortOrder": 10,
"isSubscribed": false
}
]
},
"1"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "27",
"list": []
},
"2"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,61 @@
{
"methodResponses": [
[
"Mailbox/changes",
{
"accountId": "test@example.com",
"oldState": "27",
"newState": "42",
"hasMoreChanges": false,
"created": [],
"updated": [ "id_trash" ],
"destroyed": [ "id_folder1" ]
},
"0"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "42",
"list": []
},
"1"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "42",
"list": [
{
"id": "id_trash",
"name": "Deleted messages",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "trash",
"totalEmails": 2,
"unreadEmails": 0,
"totalThreads": 2,
"unreadThreads": 0,
"sortOrder": 7,
"isSubscribed": false
}
]
},
"2"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,26 @@
{
"methodResponses": [
[
"error",
{
"type": "cannotCalculateChanges"
},
"0"
],
[
"error",
{
"type": "resultReference"
},
"1"
],
[
"error",
{
"type": "resultReference"
},
"2"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,159 @@
{
"methodResponses": [
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "23",
"list": [
{
"id": "id_inbox",
"name": "Inbox",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": false,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": false
},
"role": "inbox",
"totalEmails": 238,
"unreadEmails": 6,
"totalThreads": 80,
"unreadThreads": 4,
"sortOrder": 1,
"isSubscribed": false
},
{
"id": "id_archive",
"name": "Archive",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "archive",
"totalEmails": 295,
"unreadEmails": 36,
"totalThreads": 136,
"unreadThreads": 17,
"sortOrder": 3,
"isSubscribed": false
},
{
"id": "id_drafts",
"name": "Drafts",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "drafts",
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"sortOrder": 4,
"isSubscribed": false
},
{
"id": "id_sent",
"name": "Sent",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "sent",
"totalEmails": 2,
"unreadEmails": 0,
"totalThreads": 2,
"unreadThreads": 0,
"sortOrder": 5,
"isSubscribed": false
},
{
"id": "id_trash",
"name": "Trash",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "trash",
"totalEmails": 2,
"unreadEmails": 0,
"totalThreads": 2,
"unreadThreads": 0,
"sortOrder": 7,
"isSubscribed": false
},
{
"id": "id_folder1",
"name": "folder1",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": null,
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"sortOrder": 10,
"isSubscribed": false
}
]
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,64 @@
{
"username": "test",
"apiUrl": "/jmap/",
"downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "/jmap/upload/{accountId}/",
"eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
"accounts": {
"test@example.com": {
"name": "test@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:core": {},
"urn:ietf:params:jmap:submission": {
"maxDelayedSend": 44236800,
"submissionExtensions": {
"size": [
"10240000"
],
"dsn": []
}
},
"urn:ietf:params:jmap:mail": {
"emailQuerySortOptions": [
"receivedAt",
"sentAt",
"from",
"id",
"emailstate",
"size",
"subject",
"to",
"hasKeyword",
"someInThreadHaveKeyword",
"addedDates",
"threadSize",
"spamScore",
"snoozedUntil"
],
"maxKeywordsPerEmail": 100,
"maxSizeAttachmentsPerEmail": 10485760,
"maxMailboxesPerEmail": 20,
"mayCreateTopLevelMailbox": true,
"maxSizeMailboxName": 500
},
"urn:ietf:params:jmap:vacationresponse": {}
}
}
},
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 1073741824,
"maxConcurrentUpload": 5,
"maxCallsInRequest": 50,
"maxObjectsInGet": 2,
"maxObjectsInSet": 4096,
"collationAlgorithms": []
},
"urn:ietf:params:jmap:submission": {},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:vacationresponse": {}
},
"state": "0"
}

View file

@ -0,0 +1,64 @@
{
"username": "test",
"apiUrl": "/jmap/",
"downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "/jmap/upload/{accountId}/",
"eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
"accounts": {
"test@example.com": {
"name": "test@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:core": {},
"urn:ietf:params:jmap:submission": {
"maxDelayedSend": 44236800,
"submissionExtensions": {
"size": [
"10240000"
],
"dsn": []
}
},
"urn:ietf:params:jmap:mail": {
"emailQuerySortOptions": [
"receivedAt",
"sentAt",
"from",
"id",
"emailstate",
"size",
"subject",
"to",
"hasKeyword",
"someInThreadHaveKeyword",
"addedDates",
"threadSize",
"spamScore",
"snoozedUntil"
],
"maxKeywordsPerEmail": 100,
"maxSizeAttachmentsPerEmail": 10485760,
"maxMailboxesPerEmail": 20,
"mayCreateTopLevelMailbox": true,
"maxSizeMailboxName": 500
},
"urn:ietf:params:jmap:vacationresponse": {}
}
}
},
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 1073741824,
"maxConcurrentUpload": 5,
"maxCallsInRequest": 50,
"maxObjectsInGet": 4096,
"maxObjectsInSet": 4096,
"collationAlgorithms": []
},
"urn:ietf:params:jmap:submission": {},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:vacationresponse": {}
},
"state": "0"
}

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