Repo created
This commit is contained in:
parent
a629de6271
commit
3cef7c5092
2161 changed files with 246605 additions and 2 deletions
19
backend/jmap/build.gradle.kts
Normal file
19
backend/jmap/build.gradle.kts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.backend.api)
|
||||
|
||||
api(libs.okhttp)
|
||||
implementation(libs.jmap.client)
|
||||
implementation(libs.moshi)
|
||||
ksp(libs.moshi.kotlin.codegen)
|
||||
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(projects.backend.testing)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.common.Request.Invocation.ResultReference
|
||||
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
|
||||
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
|
||||
|
||||
class CommandDelete(
|
||||
private val jmapClient: JmapClient,
|
||||
private val accountId: String
|
||||
) {
|
||||
fun deleteMessages(messageServerIds: List<String>) {
|
||||
Timber.v("Deleting messages %s", messageServerIds)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInSet = session.maxObjectsInSet
|
||||
|
||||
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
|
||||
val setEmailCall = jmapClient.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.destroy(emailIds.toTypedArray())
|
||||
.build()
|
||||
)
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAllMessages(folderServerId: String) {
|
||||
Timber.d("Deleting all messages from %s", folderServerId)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val limit = session.maxObjectsInSet.coerceAtMost(MAX_CHUNK_SIZE).toLong()
|
||||
|
||||
do {
|
||||
Timber.v("Trying to delete up to %d messages from %s", limit, folderServerId)
|
||||
val multiCall = jmapClient.newMultiCall()
|
||||
|
||||
val queryEmailCall = multiCall.call(
|
||||
QueryEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.filter(EmailFilterCondition.builder().inMailbox(folderServerId).build())
|
||||
.calculateTotal(true)
|
||||
.limit(limit)
|
||||
.build()
|
||||
)
|
||||
|
||||
val setEmailCall = multiCall.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.destroyReference(queryEmailCall.createResultReference(ResultReference.Path.IDS))
|
||||
.build()
|
||||
)
|
||||
|
||||
multiCall.execute()
|
||||
|
||||
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
|
||||
val numberOfReturnedEmails = queryEmailResponse.ids.size
|
||||
val totalNumberOfEmails = queryEmailResponse.total ?: error("Server didn't return property 'total'")
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
|
||||
Timber.v("Deleted %d messages from %s", numberOfReturnedEmails, folderServerId)
|
||||
} while (totalNumberOfEmails > numberOfReturnedEmails)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
|
||||
import rs.ltt.jmap.common.util.Patches
|
||||
|
||||
class CommandMove(
|
||||
private val jmapClient: JmapClient,
|
||||
private val accountId: String
|
||||
) {
|
||||
fun moveMessages(targetFolderServerId: String, messageServerIds: List<String>) {
|
||||
Timber.v("Moving %d messages to %s", messageServerIds.size, targetFolderServerId)
|
||||
|
||||
val mailboxPatch = Patches.set("mailboxIds", mapOf(targetFolderServerId to true))
|
||||
updateEmails(messageServerIds, mailboxPatch)
|
||||
}
|
||||
|
||||
fun moveMessagesAndMarkAsRead(targetFolderServerId: String, messageServerIds: List<String>) {
|
||||
Timber.v("Moving %d messages to %s and marking them as read", messageServerIds.size, targetFolderServerId)
|
||||
|
||||
val mailboxPatch = Patches.builder()
|
||||
.set("mailboxIds", mapOf(targetFolderServerId to true))
|
||||
.set("keywords/\$seen", true)
|
||||
.build()
|
||||
updateEmails(messageServerIds, mailboxPatch)
|
||||
}
|
||||
|
||||
fun copyMessages(targetFolderServerId: String, messageServerIds: List<String>) {
|
||||
Timber.v("Copying %d messages to %s", messageServerIds.size, targetFolderServerId)
|
||||
|
||||
val mailboxPatch = Patches.set("mailboxIds/$targetFolderServerId", true)
|
||||
updateEmails(messageServerIds, mailboxPatch)
|
||||
}
|
||||
|
||||
private fun updateEmails(messageServerIds: List<String>, patch: Map<String, Any>?) {
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInSet = session.maxObjectsInSet
|
||||
|
||||
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
|
||||
val updates = emailIds.map { emailId ->
|
||||
emailId to patch
|
||||
}.toMap()
|
||||
|
||||
val setEmailCall = jmapClient.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.update(updates)
|
||||
.build()
|
||||
)
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.backend.api.BackendFolderUpdater
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.api.ErrorResponseException
|
||||
import rs.ltt.jmap.client.api.InvalidSessionResourceException
|
||||
import rs.ltt.jmap.client.api.MethodErrorResponseException
|
||||
import rs.ltt.jmap.client.api.UnauthorizedException
|
||||
import rs.ltt.jmap.common.Request.Invocation.ResultReference
|
||||
import rs.ltt.jmap.common.entity.Mailbox
|
||||
import rs.ltt.jmap.common.entity.Role
|
||||
import rs.ltt.jmap.common.method.call.mailbox.ChangesMailboxMethodCall
|
||||
import rs.ltt.jmap.common.method.call.mailbox.GetMailboxMethodCall
|
||||
import rs.ltt.jmap.common.method.response.mailbox.ChangesMailboxMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.mailbox.GetMailboxMethodResponse
|
||||
|
||||
internal class CommandRefreshFolderList(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val jmapClient: JmapClient,
|
||||
private val accountId: String
|
||||
) {
|
||||
fun refreshFolderList() {
|
||||
try {
|
||||
backendStorage.createFolderUpdater().use { folderUpdater ->
|
||||
val state = backendStorage.getExtraString(STATE)
|
||||
if (state == null) {
|
||||
fetchMailboxes(folderUpdater)
|
||||
} else {
|
||||
fetchMailboxUpdates(folderUpdater, state)
|
||||
}
|
||||
}
|
||||
} catch (e: UnauthorizedException) {
|
||||
throw AuthenticationFailedException("Authentication failed", e)
|
||||
} catch (e: InvalidSessionResourceException) {
|
||||
throw MessagingException(e.message, true, e)
|
||||
} catch (e: ErrorResponseException) {
|
||||
throw MessagingException(e.message, true, e)
|
||||
} catch (e: MethodErrorResponseException) {
|
||||
throw MessagingException(e.message, e.isPermanentError, e)
|
||||
} catch (e: Exception) {
|
||||
throw MessagingException(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchMailboxes(folderUpdater: BackendFolderUpdater) {
|
||||
val call = jmapClient.call(
|
||||
GetMailboxMethodCall.builder().accountId(accountId).build()
|
||||
)
|
||||
val response = call.getMainResponseBlocking<GetMailboxMethodResponse>()
|
||||
val foldersOnServer = response.list
|
||||
|
||||
val oldFolderServerIds = backendStorage.getFolderServerIds()
|
||||
val (foldersToUpdate, foldersToCreate) = foldersOnServer.partition { it.id in oldFolderServerIds }
|
||||
|
||||
for (folder in foldersToUpdate) {
|
||||
folderUpdater.changeFolder(folder.id, folder.name, folder.type)
|
||||
}
|
||||
|
||||
val newFolders = foldersToCreate.map { folder ->
|
||||
FolderInfo(folder.id, folder.name, folder.type)
|
||||
}
|
||||
folderUpdater.createFolders(newFolders)
|
||||
|
||||
val newFolderServerIds = foldersOnServer.map { it.id }
|
||||
val removedFolderServerIds = oldFolderServerIds - newFolderServerIds
|
||||
folderUpdater.deleteFolders(removedFolderServerIds)
|
||||
|
||||
backendStorage.setExtraString(STATE, response.state)
|
||||
}
|
||||
|
||||
private fun fetchMailboxUpdates(folderUpdater: BackendFolderUpdater, state: String) {
|
||||
try {
|
||||
fetchAllMailboxChanges(folderUpdater, state)
|
||||
} catch (e: MethodErrorResponseException) {
|
||||
if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) {
|
||||
fetchMailboxes(folderUpdater)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAllMailboxChanges(folderUpdater: BackendFolderUpdater, state: String) {
|
||||
var currentState = state
|
||||
do {
|
||||
val (newState, hasMoreChanges) = fetchMailboxChanges(folderUpdater, currentState)
|
||||
currentState = newState
|
||||
} while (hasMoreChanges)
|
||||
}
|
||||
|
||||
private fun fetchMailboxChanges(folderUpdater: BackendFolderUpdater, state: String): UpdateState {
|
||||
val multiCall = jmapClient.newMultiCall()
|
||||
val mailboxChangesCall = multiCall.call(
|
||||
ChangesMailboxMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.sinceState(state)
|
||||
.build()
|
||||
)
|
||||
val createdMailboxesCall = multiCall.call(
|
||||
GetMailboxMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.idsReference(mailboxChangesCall.createResultReference(ResultReference.Path.CREATED))
|
||||
.build()
|
||||
)
|
||||
val changedMailboxesCall = multiCall.call(
|
||||
GetMailboxMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.idsReference(mailboxChangesCall.createResultReference(ResultReference.Path.UPDATED))
|
||||
.build()
|
||||
)
|
||||
multiCall.execute()
|
||||
|
||||
val mailboxChangesResponse = mailboxChangesCall.getMainResponseBlocking<ChangesMailboxMethodResponse>()
|
||||
val createdMailboxResponse = createdMailboxesCall.getMainResponseBlocking<GetMailboxMethodResponse>()
|
||||
val changedMailboxResponse = changedMailboxesCall.getMainResponseBlocking<GetMailboxMethodResponse>()
|
||||
|
||||
val foldersToCreate = createdMailboxResponse.list.map { folder ->
|
||||
FolderInfo(folder.id, folder.name, folder.type)
|
||||
}
|
||||
folderUpdater.createFolders(foldersToCreate)
|
||||
|
||||
for (folder in changedMailboxResponse.list) {
|
||||
folderUpdater.changeFolder(folder.id, folder.name, folder.type)
|
||||
}
|
||||
|
||||
val destroyed = mailboxChangesResponse.destroyed
|
||||
destroyed?.let {
|
||||
folderUpdater.deleteFolders(it.toList())
|
||||
}
|
||||
|
||||
backendStorage.setExtraString(STATE, mailboxChangesResponse.newState)
|
||||
|
||||
return UpdateState(
|
||||
state = mailboxChangesResponse.newState,
|
||||
hasMoreChanges = mailboxChangesResponse.isHasMoreChanges
|
||||
)
|
||||
}
|
||||
|
||||
private val Mailbox.type: FolderType
|
||||
get() = when (role) {
|
||||
Role.INBOX -> FolderType.INBOX
|
||||
Role.ARCHIVE -> FolderType.ARCHIVE
|
||||
Role.DRAFTS -> FolderType.DRAFTS
|
||||
Role.SENT -> FolderType.SENT
|
||||
Role.TRASH -> FolderType.TRASH
|
||||
Role.JUNK -> FolderType.SPAM
|
||||
else -> FolderType.REGULAR
|
||||
}
|
||||
|
||||
private val MethodErrorResponseException.isPermanentError: Boolean
|
||||
get() = methodErrorResponse.type != ERROR_SERVER_UNAVAILABLE
|
||||
|
||||
companion object {
|
||||
private const val STATE = "jmapState"
|
||||
private const val ERROR_SERVER_UNAVAILABLE = "serverUnavailable"
|
||||
private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges"
|
||||
}
|
||||
|
||||
private data class UpdateState(val state: String, val hasMoreChanges: Boolean)
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.Flag
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
|
||||
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse
|
||||
import rs.ltt.jmap.common.util.Patches
|
||||
|
||||
class CommandSetFlag(
|
||||
private val jmapClient: JmapClient,
|
||||
private val accountId: String
|
||||
) {
|
||||
fun setFlag(messageServerIds: List<String>, flag: Flag, newState: Boolean) {
|
||||
if (newState) {
|
||||
Timber.v("Setting flag %s for messages %s", flag, messageServerIds)
|
||||
} else {
|
||||
Timber.v("Removing flag %s for messages %s", flag, messageServerIds)
|
||||
}
|
||||
|
||||
val keyword = flag.toKeyword()
|
||||
val keywordsPatch = if (newState) {
|
||||
Patches.set("keywords/$keyword", true)
|
||||
} else {
|
||||
Patches.remove("keywords/$keyword")
|
||||
}
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInSet = session.maxObjectsInSet
|
||||
|
||||
messageServerIds.chunked(maxObjectsInSet).forEach { emailIds ->
|
||||
val updates = emailIds.map { emailId ->
|
||||
emailId to keywordsPatch
|
||||
}.toMap()
|
||||
|
||||
val setEmailCall = jmapClient.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.update(updates)
|
||||
.build()
|
||||
)
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllAsRead(folderServerId: String) {
|
||||
Timber.d("Marking all messages in %s as read", folderServerId)
|
||||
|
||||
val keywordsPatch = Patches.set("keywords/\$seen", true)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val limit = minOf(MAX_CHUNK_SIZE, session.maxObjectsInSet).toLong()
|
||||
|
||||
do {
|
||||
Timber.v("Trying to mark up to %d messages in %s as read", limit, folderServerId)
|
||||
|
||||
val queryEmailCall = jmapClient.call(
|
||||
QueryEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.filter(
|
||||
EmailFilterCondition.builder()
|
||||
.inMailbox(folderServerId)
|
||||
.notKeyword("\$seen")
|
||||
.build()
|
||||
)
|
||||
.calculateTotal(true)
|
||||
.limit(limit)
|
||||
.build()
|
||||
)
|
||||
|
||||
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
|
||||
val numberOfReturnedEmails = queryEmailResponse.ids.size
|
||||
val totalNumberOfEmails = queryEmailResponse.total ?: error("Server didn't return property 'total'")
|
||||
|
||||
if (numberOfReturnedEmails == 0) {
|
||||
Timber.v("There were no messages in %s to mark as read", folderServerId)
|
||||
} else {
|
||||
val updates = queryEmailResponse.ids.map { emailId ->
|
||||
emailId to keywordsPatch
|
||||
}.toMap()
|
||||
|
||||
val setEmailCall = jmapClient.call(
|
||||
SetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.update(updates)
|
||||
.build()
|
||||
)
|
||||
|
||||
setEmailCall.getMainResponseBlocking<SetEmailMethodResponse>()
|
||||
|
||||
Timber.v("Marked %d messages in %s as read", numberOfReturnedEmails, folderServerId)
|
||||
}
|
||||
} while (totalNumberOfEmails > numberOfReturnedEmails)
|
||||
}
|
||||
|
||||
private fun Flag.toKeyword(): String = when (this) {
|
||||
Flag.SEEN -> "\$seen"
|
||||
Flag.FLAGGED -> "\$flagged"
|
||||
Flag.DRAFT -> "\$draft"
|
||||
Flag.ANSWERED -> "\$answered"
|
||||
Flag.FORWARDED -> "\$forwarded"
|
||||
else -> error("Unsupported flag: $name")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.backend.api.BackendFolder
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import com.fsck.k9.mail.internet.MimeMessage
|
||||
import java.util.Date
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.api.MethodErrorResponseException
|
||||
import rs.ltt.jmap.client.api.UnauthorizedException
|
||||
import rs.ltt.jmap.client.http.HttpAuthentication
|
||||
import rs.ltt.jmap.client.session.Session
|
||||
import rs.ltt.jmap.common.entity.Email
|
||||
import rs.ltt.jmap.common.entity.filter.EmailFilterCondition
|
||||
import rs.ltt.jmap.common.entity.query.EmailQuery
|
||||
import rs.ltt.jmap.common.method.call.email.GetEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.call.email.QueryChangesEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.GetEmailMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.email.QueryChangesEmailMethodResponse
|
||||
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
|
||||
|
||||
class CommandSync(
|
||||
private val backendStorage: BackendStorage,
|
||||
private val jmapClient: JmapClient,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val accountId: String,
|
||||
private val httpAuthentication: HttpAuthentication
|
||||
) {
|
||||
|
||||
fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
try {
|
||||
val backendFolder = backendStorage.getFolder(folderServerId)
|
||||
listener.syncStarted(folderServerId)
|
||||
|
||||
val limit = if (backendFolder.visibleLimit > 0) backendFolder.visibleLimit.toLong() else null
|
||||
|
||||
val queryState = backendFolder.getFolderExtraString(EXTRA_QUERY_STATE)
|
||||
if (queryState == null) {
|
||||
fullSync(backendFolder, folderServerId, syncConfig, limit, listener)
|
||||
} else {
|
||||
deltaSync(backendFolder, folderServerId, syncConfig, limit, queryState, listener)
|
||||
}
|
||||
|
||||
listener.syncFinished(folderServerId)
|
||||
} catch (e: UnauthorizedException) {
|
||||
Timber.e(e, "Authentication failure during sync")
|
||||
|
||||
val exception = AuthenticationFailedException(e.message ?: "Authentication failed", e)
|
||||
listener.syncFailed(folderServerId, "Authentication failed", exception)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unexpected failure during sync")
|
||||
|
||||
listener.syncFailed(folderServerId, "Unexpected failure", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fullSync(
|
||||
backendFolder: BackendFolder,
|
||||
folderServerId: String,
|
||||
syncConfig: SyncConfig,
|
||||
limit: Long?,
|
||||
listener: SyncListener
|
||||
) {
|
||||
val cachedServerIds: Set<String> = backendFolder.getMessageServerIds()
|
||||
|
||||
if (limit != null) {
|
||||
Timber.d("Fetching %d latest messages in %s (%s)", limit, backendFolder.name, folderServerId)
|
||||
} else {
|
||||
Timber.d("Fetching all messages in %s (%s)", backendFolder.name, folderServerId)
|
||||
}
|
||||
|
||||
val queryEmailCall = jmapClient.call(
|
||||
QueryEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.query(createEmailQuery(folderServerId))
|
||||
.limit(limit)
|
||||
.build()
|
||||
)
|
||||
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
|
||||
val queryState = if (queryEmailResponse.isCanCalculateChanges) queryEmailResponse.queryState else null
|
||||
val remoteServerIds = queryEmailResponse.ids.toSet()
|
||||
|
||||
val destroyServerIds = (cachedServerIds - remoteServerIds).toList()
|
||||
val newServerIds = remoteServerIds - cachedServerIds
|
||||
|
||||
handleFolderUpdates(backendFolder, folderServerId, destroyServerIds, newServerIds, queryState, listener)
|
||||
|
||||
val refreshServerIds = cachedServerIds.intersect(remoteServerIds)
|
||||
refreshMessageFlags(backendFolder, syncConfig, refreshServerIds)
|
||||
}
|
||||
|
||||
private fun createEmailQuery(folderServerId: String): EmailQuery? {
|
||||
val filter = EmailFilterCondition.builder()
|
||||
.inMailbox(folderServerId)
|
||||
.build()
|
||||
|
||||
// FIXME: Add sort parameter
|
||||
return EmailQuery.of(filter)
|
||||
}
|
||||
|
||||
private fun deltaSync(
|
||||
backendFolder: BackendFolder,
|
||||
folderServerId: String,
|
||||
syncConfig: SyncConfig,
|
||||
limit: Long?,
|
||||
queryState: String,
|
||||
listener: SyncListener
|
||||
) {
|
||||
Timber.d("Updating messages in %s (%s)", backendFolder.name, folderServerId)
|
||||
|
||||
val emailQuery = createEmailQuery(folderServerId)
|
||||
val queryChangesEmailCall = jmapClient.call(
|
||||
QueryChangesEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.sinceQueryState(queryState)
|
||||
.query(emailQuery)
|
||||
.build()
|
||||
)
|
||||
|
||||
val queryChangesEmailResponse = try {
|
||||
queryChangesEmailCall.getMainResponseBlocking<QueryChangesEmailMethodResponse>()
|
||||
} catch (e: MethodErrorResponseException) {
|
||||
if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) {
|
||||
Timber.d("Server responded with '$ERROR_CANNOT_CALCULATE_CHANGES'; switching to full sync")
|
||||
|
||||
backendFolder.saveQueryState(null)
|
||||
fullSync(backendFolder, folderServerId, syncConfig, limit, listener)
|
||||
return
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
|
||||
val cachedServerIds = backendFolder.getMessageServerIds()
|
||||
|
||||
val removedServerIds = queryChangesEmailResponse.removed.toSet()
|
||||
val addedServerIds = queryChangesEmailResponse.added.map { it.item }.toSet()
|
||||
val newQueryState = queryChangesEmailResponse.newQueryState
|
||||
|
||||
// An email can appear in both the 'removed' and the 'added' properties, e.g. when its position in the list
|
||||
// changes. But we don't want to remove a message from the database only to download it again right away.
|
||||
val retainedServerIds = removedServerIds.intersect(addedServerIds)
|
||||
val destroyServerIds = (removedServerIds - retainedServerIds).toList()
|
||||
val newServerIds = addedServerIds - retainedServerIds
|
||||
|
||||
handleFolderUpdates(backendFolder, folderServerId, destroyServerIds, newServerIds, newQueryState, listener)
|
||||
|
||||
val refreshServerIds = cachedServerIds - destroyServerIds
|
||||
refreshMessageFlags(backendFolder, syncConfig, refreshServerIds)
|
||||
}
|
||||
|
||||
private fun handleFolderUpdates(
|
||||
backendFolder: BackendFolder,
|
||||
folderServerId: String,
|
||||
destroyServerIds: List<String>,
|
||||
newServerIds: Set<String>,
|
||||
newQueryState: String?,
|
||||
listener: SyncListener
|
||||
) {
|
||||
if (destroyServerIds.isNotEmpty()) {
|
||||
Timber.d("Removing messages no longer on server: %s", destroyServerIds)
|
||||
backendFolder.destroyMessages(destroyServerIds)
|
||||
}
|
||||
|
||||
if (newServerIds.isEmpty()) {
|
||||
Timber.d("No new messages on server")
|
||||
backendFolder.saveQueryState(newQueryState)
|
||||
return
|
||||
}
|
||||
|
||||
Timber.d("New messages on server: %s", newServerIds)
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInGet = session.maxObjectsInGet
|
||||
val messageInfoList = fetchMessageInfo(session, maxObjectsInGet, newServerIds)
|
||||
|
||||
val total = messageInfoList.size
|
||||
messageInfoList.forEachIndexed { index, messageInfo ->
|
||||
Timber.v("Downloading message %s (%s)", messageInfo.serverId, messageInfo.downloadUrl)
|
||||
val message = downloadMessage(messageInfo.downloadUrl)
|
||||
if (message != null) {
|
||||
message.apply {
|
||||
uid = messageInfo.serverId
|
||||
setInternalSentDate(messageInfo.receivedAt)
|
||||
setFlags(messageInfo.flags, true)
|
||||
}
|
||||
|
||||
backendFolder.saveMessage(message, MessageDownloadState.FULL)
|
||||
} else {
|
||||
Timber.d("Failed to download message: %s", messageInfo.serverId)
|
||||
}
|
||||
|
||||
listener.syncProgress(folderServerId, index + 1, total)
|
||||
}
|
||||
|
||||
backendFolder.saveQueryState(newQueryState)
|
||||
}
|
||||
|
||||
private fun fetchMessageInfo(session: Session, maxObjectsInGet: Int, emailIds: Set<String>): List<MessageInfo> {
|
||||
return emailIds
|
||||
.chunked(maxObjectsInGet) { emailIdsChunk ->
|
||||
getEmailPropertiesFromServer(emailIdsChunk, INFO_PROPERTIES)
|
||||
}
|
||||
.flatten()
|
||||
.map { email ->
|
||||
email.toMessageInfo(session)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmailPropertiesFromServer(emailIdsChunk: List<String>, properties: Array<String>): List<Email> {
|
||||
val getEmailCall = jmapClient.call(
|
||||
GetEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.ids(emailIdsChunk.toTypedArray())
|
||||
.properties(properties)
|
||||
.build()
|
||||
)
|
||||
|
||||
val getEmailResponse = getEmailCall.getMainResponseBlocking<GetEmailMethodResponse>()
|
||||
return getEmailResponse.list.toList()
|
||||
}
|
||||
|
||||
private fun Email.toMessageInfo(session: Session): MessageInfo {
|
||||
val downloadUrl = session.getDownloadUrl(accountId, blobId, blobId, "application/octet-stream")
|
||||
return MessageInfo(id, downloadUrl, receivedAt, keywords.toFlags())
|
||||
}
|
||||
|
||||
private fun downloadMessage(downloadUrl: HttpUrl): MimeMessage? {
|
||||
val request = Request.Builder()
|
||||
.url(downloadUrl)
|
||||
.apply {
|
||||
httpAuthentication.authenticate(this)
|
||||
}
|
||||
.build()
|
||||
|
||||
return okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val inputStream = response.body!!.byteStream()
|
||||
MimeMessage.parseMimeMessage(inputStream, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMessageFlags(backendFolder: BackendFolder, syncConfig: SyncConfig, emailIds: Set<String>) {
|
||||
if (emailIds.isEmpty()) return
|
||||
|
||||
Timber.v("Fetching flags for messages: %s", emailIds)
|
||||
|
||||
val session = jmapClient.session.get()
|
||||
val maxObjectsInGet = session.maxObjectsInGet
|
||||
|
||||
emailIds
|
||||
.asSequence()
|
||||
.chunked(maxObjectsInGet) { emailIdsChunk ->
|
||||
getEmailPropertiesFromServer(emailIdsChunk, FLAG_PROPERTIES)
|
||||
}
|
||||
.flatten()
|
||||
.forEach { email ->
|
||||
syncFlagsForMessage(backendFolder, syncConfig, email)
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncFlagsForMessage(backendFolder: BackendFolder, syncConfig: SyncConfig, email: Email) {
|
||||
val messageServerId = email.id
|
||||
val localFlags = backendFolder.getMessageFlags(messageServerId)
|
||||
val remoteFlags = email.keywords.toFlags()
|
||||
for (flag in syncConfig.syncFlags) {
|
||||
val flagSetOnServer = flag in remoteFlags
|
||||
val flagSetLocally = flag in localFlags
|
||||
if (flagSetOnServer != flagSetLocally) {
|
||||
backendFolder.setMessageFlag(messageServerId, flag, flagSetOnServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Boolean>?.toFlags(): Set<Flag> {
|
||||
return if (this == null) {
|
||||
emptySet()
|
||||
} else {
|
||||
filterValues { it }.keys
|
||||
.mapNotNull { keyword -> keyword.toFlag() }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toFlag(): Flag? = when (this) {
|
||||
"\$seen" -> Flag.SEEN
|
||||
"\$flagged" -> Flag.FLAGGED
|
||||
"\$draft" -> Flag.DRAFT
|
||||
"\$answered" -> Flag.ANSWERED
|
||||
"\$forwarded" -> Flag.FORWARDED
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun BackendFolder.saveQueryState(queryState: String?) {
|
||||
setFolderExtraString(EXTRA_QUERY_STATE, queryState)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_QUERY_STATE = "jmapQueryState"
|
||||
private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges"
|
||||
private val INFO_PROPERTIES = arrayOf("id", "blobId", "size", "receivedAt", "keywords")
|
||||
private val FLAG_PROPERTIES = arrayOf("id", "keywords")
|
||||
}
|
||||
}
|
||||
|
||||
private data class MessageInfo(
|
||||
val serverId: String,
|
||||
val downloadUrl: HttpUrl,
|
||||
val receivedAt: Date,
|
||||
val flags: Set<Flag>
|
||||
)
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.squareup.moshi.Moshi
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.http.HttpAuthentication
|
||||
import rs.ltt.jmap.common.entity.EmailImport
|
||||
import rs.ltt.jmap.common.method.call.email.ImportEmailMethodCall
|
||||
import rs.ltt.jmap.common.method.response.email.ImportEmailMethodResponse
|
||||
|
||||
class CommandUpload(
|
||||
private val jmapClient: JmapClient,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val httpAuthentication: HttpAuthentication,
|
||||
private val accountId: String
|
||||
) {
|
||||
private val moshi = Moshi.Builder().build()
|
||||
|
||||
fun uploadMessage(folderServerId: String, message: Message): String? {
|
||||
Timber.d("Uploading message to $folderServerId")
|
||||
|
||||
val uploadResponse = uploadMessageAsBlob(message)
|
||||
return importEmailBlob(uploadResponse, folderServerId)
|
||||
}
|
||||
|
||||
private fun uploadMessageAsBlob(message: Message): JmapUploadResponse {
|
||||
val session = jmapClient.session.get()
|
||||
val uploadUrl = session.getUploadUrl(accountId)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(uploadUrl)
|
||||
.post(MessageRequestBody(message))
|
||||
.apply {
|
||||
httpAuthentication.authenticate(this)
|
||||
}
|
||||
.build()
|
||||
|
||||
return okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw MessagingException("Uploading message as blob failed")
|
||||
}
|
||||
|
||||
response.body!!.source().use { source ->
|
||||
val adapter = moshi.adapter(JmapUploadResponse::class.java)
|
||||
val uploadResponse = adapter.fromJson(source)
|
||||
uploadResponse ?: throw MessagingException("Error reading upload response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun importEmailBlob(uploadResponse: JmapUploadResponse, folderServerId: String): String? {
|
||||
val importEmailRequest = ImportEmailMethodCall.builder()
|
||||
.accountId(accountId)
|
||||
.email(
|
||||
LOCAL_EMAIL_ID,
|
||||
EmailImport.builder()
|
||||
.blobId(uploadResponse.blobId)
|
||||
.keywords(mapOf("\$seen" to true))
|
||||
.mailboxIds(mapOf(folderServerId to true))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
val importEmailCall = jmapClient.call(importEmailRequest)
|
||||
val importEmailResponse = importEmailCall.getMainResponseBlocking<ImportEmailMethodResponse>()
|
||||
|
||||
return importEmailResponse.serverEmailId
|
||||
}
|
||||
|
||||
private val ImportEmailMethodResponse.serverEmailId
|
||||
get() = created?.get(LOCAL_EMAIL_ID)?.id
|
||||
|
||||
companion object {
|
||||
private const val LOCAL_EMAIL_ID = "t1"
|
||||
}
|
||||
}
|
||||
|
||||
private class MessageRequestBody(private val message: Message) : RequestBody() {
|
||||
override fun contentType(): MediaType? {
|
||||
return "message/rfc822".toMediaType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return message.calculateSize()
|
||||
}
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
message.writeTo(sink.outputStream())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import java.net.UnknownHostException
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.api.EndpointNotFoundException
|
||||
import rs.ltt.jmap.client.api.UnauthorizedException
|
||||
import rs.ltt.jmap.common.entity.capability.MailAccountCapability
|
||||
|
||||
class JmapAccountDiscovery {
|
||||
fun discover(emailAddress: String, password: String): JmapDiscoveryResult {
|
||||
val jmapClient = JmapClient(emailAddress, password)
|
||||
val session = try {
|
||||
jmapClient.session.futureGetOrThrow()
|
||||
} catch (e: EndpointNotFoundException) {
|
||||
return JmapDiscoveryResult.EndpointNotFoundFailure
|
||||
} catch (e: UnknownHostException) {
|
||||
return JmapDiscoveryResult.EndpointNotFoundFailure
|
||||
} catch (e: UnauthorizedException) {
|
||||
return JmapDiscoveryResult.AuthenticationFailure
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unable to get JMAP session")
|
||||
return JmapDiscoveryResult.GenericFailure(e)
|
||||
}
|
||||
|
||||
val accounts = session.getAccounts(MailAccountCapability::class.java)
|
||||
val accountId = when {
|
||||
accounts.isEmpty() -> return JmapDiscoveryResult.NoEmailAccountFoundFailure
|
||||
accounts.size == 1 -> accounts.keys.first()
|
||||
else -> session.getPrimaryAccount(MailAccountCapability::class.java)
|
||||
}
|
||||
|
||||
val account = accounts[accountId]!!
|
||||
val accountName = account.name ?: emailAddress
|
||||
return JmapDiscoveryResult.JmapAccount(accountId, accountName)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class JmapDiscoveryResult {
|
||||
class GenericFailure(val cause: Throwable) : JmapDiscoveryResult()
|
||||
object EndpointNotFoundFailure : JmapDiscoveryResult()
|
||||
object AuthenticationFailure : JmapDiscoveryResult()
|
||||
object NoEmailAccountFoundFailure : JmapDiscoveryResult()
|
||||
|
||||
data class JmapAccount(val accountId: String, val name: String) : JmapDiscoveryResult()
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.backend.api.BackendPusher
|
||||
import com.fsck.k9.backend.api.BackendPusherCallback
|
||||
import com.fsck.k9.backend.api.BackendStorage
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import com.fsck.k9.mail.BodyFactory
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Part
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.http.BasicAuthHttpAuthentication
|
||||
import rs.ltt.jmap.client.http.HttpAuthentication
|
||||
import rs.ltt.jmap.common.method.call.core.EchoMethodCall
|
||||
|
||||
class JmapBackend(
|
||||
backendStorage: BackendStorage,
|
||||
okHttpClient: OkHttpClient,
|
||||
config: JmapConfig
|
||||
) : Backend {
|
||||
private val httpAuthentication = config.toHttpAuthentication()
|
||||
private val jmapClient = createJmapClient(config, httpAuthentication)
|
||||
private val accountId = config.accountId
|
||||
private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage, jmapClient, accountId)
|
||||
private val commandSync = CommandSync(backendStorage, jmapClient, okHttpClient, accountId, httpAuthentication)
|
||||
private val commandSetFlag = CommandSetFlag(jmapClient, accountId)
|
||||
private val commandDelete = CommandDelete(jmapClient, accountId)
|
||||
private val commandMove = CommandMove(jmapClient, accountId)
|
||||
private val commandUpload = CommandUpload(jmapClient, okHttpClient, httpAuthentication, accountId)
|
||||
override val supportsFlags = true
|
||||
override val supportsExpunge = false
|
||||
override val supportsMove = true
|
||||
override val supportsCopy = true
|
||||
override val supportsUpload = true
|
||||
override val supportsTrashFolder = true
|
||||
override val supportsSearchByDate = true
|
||||
override val isPushCapable = false // FIXME
|
||||
|
||||
override fun refreshFolderList() {
|
||||
commandRefreshFolderList.refreshFolderList()
|
||||
}
|
||||
|
||||
override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) {
|
||||
commandSync.sync(folderServerId, syncConfig, listener)
|
||||
}
|
||||
|
||||
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun downloadMessageStructure(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) {
|
||||
commandSetFlag.setFlag(messageServerIds, flag, newState)
|
||||
}
|
||||
|
||||
override fun markAllAsRead(folderServerId: String) {
|
||||
commandSetFlag.markAllAsRead(folderServerId)
|
||||
}
|
||||
|
||||
override fun expunge(folderServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun expungeMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
|
||||
commandDelete.deleteMessages(messageServerIds)
|
||||
}
|
||||
|
||||
override fun deleteAllMessages(folderServerId: String) {
|
||||
commandDelete.deleteAllMessages(folderServerId)
|
||||
}
|
||||
|
||||
override fun moveMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>
|
||||
): Map<String, String>? {
|
||||
commandMove.moveMessages(targetFolderServerId, messageServerIds)
|
||||
return messageServerIds.associateWith { it }
|
||||
}
|
||||
|
||||
override fun moveMessagesAndMarkAsRead(sourceFolderServerId: String, targetFolderServerId: String, messageServerIds: List<String>): Map<String, String>? {
|
||||
commandMove.moveMessagesAndMarkAsRead(targetFolderServerId, messageServerIds)
|
||||
return messageServerIds.associateWith { it }
|
||||
}
|
||||
|
||||
override fun copyMessages(
|
||||
sourceFolderServerId: String,
|
||||
targetFolderServerId: String,
|
||||
messageServerIds: List<String>
|
||||
): Map<String, String>? {
|
||||
commandMove.copyMessages(targetFolderServerId, messageServerIds)
|
||||
return messageServerIds.associateWith { it }
|
||||
}
|
||||
|
||||
override fun search(
|
||||
folderServerId: String,
|
||||
query: String?,
|
||||
requiredFlags: Set<Flag>?,
|
||||
forbiddenFlags: Set<Flag>?,
|
||||
performFullTextSearch: Boolean
|
||||
): List<String> {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun findByMessageId(folderServerId: String, messageId: String): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun uploadMessage(folderServerId: String, message: Message): String? {
|
||||
return commandUpload.uploadMessage(folderServerId, message)
|
||||
}
|
||||
|
||||
override fun checkIncomingServerSettings() {
|
||||
jmapClient.call(EchoMethodCall()).get()
|
||||
}
|
||||
|
||||
override fun sendMessage(message: Message) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun checkOutgoingServerSettings() {
|
||||
checkIncomingServerSettings()
|
||||
}
|
||||
|
||||
override fun createPusher(callback: BackendPusherCallback): BackendPusher {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
private fun JmapConfig.toHttpAuthentication(): HttpAuthentication {
|
||||
return BasicAuthHttpAuthentication(username, password)
|
||||
}
|
||||
|
||||
private fun createJmapClient(jmapConfig: JmapConfig, httpAuthentication: HttpAuthentication): JmapClient {
|
||||
return if (jmapConfig.baseUrl == null) {
|
||||
JmapClient(httpAuthentication)
|
||||
} else {
|
||||
val baseHttpUrl = jmapConfig.baseUrl.toHttpUrlOrNull()
|
||||
JmapClient(httpAuthentication, baseHttpUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
data class JmapConfig(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val baseUrl: String?,
|
||||
val accountId: String
|
||||
)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import java.util.concurrent.ExecutionException
|
||||
import rs.ltt.jmap.client.JmapRequest
|
||||
import rs.ltt.jmap.client.MethodResponses
|
||||
import rs.ltt.jmap.client.session.Session
|
||||
import rs.ltt.jmap.common.entity.capability.CoreCapability
|
||||
import rs.ltt.jmap.common.method.MethodResponse
|
||||
|
||||
internal const val MAX_CHUNK_SIZE = 5000
|
||||
|
||||
internal inline fun <reified T : MethodResponse> ListenableFuture<MethodResponses>.getMainResponseBlocking(): T {
|
||||
return futureGetOrThrow().getMain(T::class.java)
|
||||
}
|
||||
|
||||
internal inline fun <reified T : MethodResponse> JmapRequest.Call.getMainResponseBlocking(): T {
|
||||
return methodResponses.getMainResponseBlocking()
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
internal inline fun <T> ListenableFuture<T>.futureGetOrThrow(): T {
|
||||
return try {
|
||||
get()
|
||||
} catch (e: ExecutionException) {
|
||||
throw e.cause ?: e
|
||||
}
|
||||
}
|
||||
|
||||
internal val Session.maxObjectsInGet: Int
|
||||
get() {
|
||||
val coreCapability = getCapability(CoreCapability::class.java)
|
||||
return coreCapability.maxObjectsInGet.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
||||
internal val Session.maxObjectsInSet: Int
|
||||
get() {
|
||||
val coreCapability = getCapability(CoreCapability::class.java)
|
||||
return coreCapability.maxObjectsInSet.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class JmapUploadResponse(
|
||||
val accountId: String,
|
||||
val blobId: String,
|
||||
val type: String,
|
||||
val size: Long
|
||||
)
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import app.k9mail.backend.testing.InMemoryBackendStorage
|
||||
import com.fsck.k9.backend.api.BackendFolderUpdater
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.backend.api.updateFolders
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import junit.framework.AssertionFailedError
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
|
||||
class CommandRefreshFolderListTest {
|
||||
private val backendStorage = InMemoryBackendStorage()
|
||||
|
||||
@Test
|
||||
fun sessionResourceWithAuthenticationError() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
MockResponse().setResponseCode(401)
|
||||
)
|
||||
|
||||
try {
|
||||
command.refreshFolderList()
|
||||
fail("Expected exception")
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidSessionResource() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
MockResponse().setBody("invalid")
|
||||
)
|
||||
|
||||
try {
|
||||
command.refreshFolderList()
|
||||
fail("Expected exception")
|
||||
} catch (e: MessagingException) {
|
||||
assertTrue(e.isPermanentFailure)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchMailboxes() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json")
|
||||
)
|
||||
|
||||
command.refreshFolderList()
|
||||
|
||||
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1")
|
||||
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
|
||||
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
|
||||
assertFolderPresent("id_trash", "Trash", FolderType.TRASH)
|
||||
assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR)
|
||||
assertMailboxState("23")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchMailboxUpdates() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes.json")
|
||||
)
|
||||
createFoldersInBackendStorage(state = "23")
|
||||
|
||||
command.refreshFolderList()
|
||||
|
||||
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2")
|
||||
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
|
||||
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
|
||||
assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH)
|
||||
assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR)
|
||||
assertMailboxState("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchMailboxUpdates_withHasMoreChanges() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_1.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_2.json")
|
||||
)
|
||||
createFoldersInBackendStorage(state = "23")
|
||||
|
||||
command.refreshFolderList()
|
||||
|
||||
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2")
|
||||
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
|
||||
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
|
||||
assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH)
|
||||
assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR)
|
||||
assertMailboxState("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchMailboxUpdates_withCannotCalculateChangesError() {
|
||||
val command = createCommandRefreshFolderList(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json"),
|
||||
responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json")
|
||||
)
|
||||
setMailboxState("unknownToServer")
|
||||
|
||||
command.refreshFolderList()
|
||||
|
||||
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1")
|
||||
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
|
||||
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
|
||||
assertFolderPresent("id_trash", "Trash", FolderType.TRASH)
|
||||
assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR)
|
||||
assertMailboxState("23")
|
||||
}
|
||||
|
||||
private fun createCommandRefreshFolderList(vararg mockResponses: MockResponse): CommandRefreshFolderList {
|
||||
val server = createMockWebServer(*mockResponses)
|
||||
return createCommandRefreshFolderList(server.url("/jmap/"))
|
||||
}
|
||||
|
||||
private fun createCommandRefreshFolderList(
|
||||
baseUrl: HttpUrl,
|
||||
accountId: String = "test@example.com"
|
||||
): CommandRefreshFolderList {
|
||||
val jmapClient = JmapClient("test", "test", baseUrl)
|
||||
return CommandRefreshFolderList(backendStorage, jmapClient, accountId)
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun createFoldersInBackendStorage(state: String) {
|
||||
backendStorage.updateFolders {
|
||||
createFolder("id_inbox", "Inbox", FolderType.INBOX)
|
||||
createFolder("id_archive", "Archive", FolderType.ARCHIVE)
|
||||
createFolder("id_drafts", "Drafts", FolderType.DRAFTS)
|
||||
createFolder("id_sent", "Sent", FolderType.SENT)
|
||||
createFolder("id_trash", "Trash", FolderType.TRASH)
|
||||
createFolder("id_folder1", "folder1", FolderType.REGULAR)
|
||||
}
|
||||
setMailboxState(state)
|
||||
}
|
||||
|
||||
private fun BackendFolderUpdater.createFolder(serverId: String, name: String, type: FolderType) {
|
||||
createFolders(listOf(FolderInfo(serverId, name, type)))
|
||||
}
|
||||
|
||||
private fun setMailboxState(state: String) {
|
||||
backendStorage.setExtraString("jmapState", state)
|
||||
}
|
||||
|
||||
private fun assertFolderList(vararg folderServerIds: String) {
|
||||
assertEquals(folderServerIds.toSet(), backendStorage.getFolderServerIds().toSet())
|
||||
}
|
||||
|
||||
private fun assertFolderPresent(serverId: String, name: String, type: FolderType) {
|
||||
val folder = backendStorage.folders[serverId]
|
||||
?: throw AssertionFailedError("Expected folder '$serverId' in BackendStorage")
|
||||
|
||||
assertEquals(name, folder.name)
|
||||
assertEquals(type, folder.type)
|
||||
}
|
||||
|
||||
private fun assertMailboxState(expected: String) {
|
||||
assertEquals(expected, backendStorage.getExtraString("jmapState"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import app.k9mail.backend.testing.InMemoryBackendFolder
|
||||
import app.k9mail.backend.testing.InMemoryBackendStorage
|
||||
import com.fsck.k9.backend.api.FolderInfo
|
||||
import com.fsck.k9.backend.api.SyncConfig
|
||||
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
|
||||
import com.fsck.k9.backend.api.updateFolders
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.internet.BinaryTempFileBody
|
||||
import java.io.File
|
||||
import java.util.EnumSet
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import rs.ltt.jmap.client.JmapClient
|
||||
import rs.ltt.jmap.client.http.BasicAuthHttpAuthentication
|
||||
|
||||
class CommandSyncTest {
|
||||
private val backendStorage = InMemoryBackendStorage()
|
||||
private val okHttpClient = OkHttpClient.Builder().build()
|
||||
private val syncListener = LoggingSyncListener()
|
||||
private val syncConfig = SyncConfig(
|
||||
expungePolicy = ExpungePolicy.IMMEDIATELY,
|
||||
earliestPollDate = null,
|
||||
syncRemoteDeletions = true,
|
||||
maximumAutoDownloadMessageSize = 1000,
|
||||
defaultVisibleLimit = 25,
|
||||
syncFlags = EnumSet.of(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED)
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
BinaryTempFileBody.setTempDirectory(File(System.getProperty("java.io.tmpdir")))
|
||||
createFolderInBackendStorage()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionResourceWithAuthenticationError() {
|
||||
val command = createCommandSync(
|
||||
MockResponse().setResponseCode(401)
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertEquals(SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID), syncListener.getNextEvent())
|
||||
val failedEvent = syncListener.getNextEvent() as SyncListenerEvent.SyncFailed
|
||||
assertEquals(AuthenticationFailedException::class.java, failedEvent.exception!!.javaClass)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncStartingWithEmptyLocalMailbox() {
|
||||
val server = createMockWebServer(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_M001_and_M002.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M001_and_M002.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_1.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_2.eml")
|
||||
)
|
||||
val baseUrl = server.url("/jmap/")
|
||||
val command = createCommandSync(baseUrl)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.assertMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml"
|
||||
)
|
||||
backendFolder.assertQueryState("50:0")
|
||||
syncListener.assertSyncEvents(
|
||||
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
|
||||
SyncListenerEvent.SyncProgress(FOLDER_SERVER_ID, completed = 1, total = 2),
|
||||
SyncListenerEvent.SyncProgress(FOLDER_SERVER_ID, completed = 2, total = 2),
|
||||
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID)
|
||||
)
|
||||
server.skipRequests(3)
|
||||
server.assertRequestUrlPath("/jmap/download/test%40example.com/B001/B001?accept=application%2Foctet-stream")
|
||||
server.assertRequestUrlPath("/jmap/download/test%40example.com/B002/B002?accept=application%2Foctet-stream")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncExceedingMaxObjectsInGet() {
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/session_with_maxObjectsInGet_2.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_M001_to_M005.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M001_and_M002.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003_and_M004.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M005.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_1.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_2.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml")
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
assertEquals(setOf("M001", "M002", "M003", "M004", "M005"), backendFolder.getMessageServerIds())
|
||||
syncListener.assertSyncSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncWithLocalMessagesAndDifferentMessagesInRemoteMailbox() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml"
|
||||
)
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_M002_and_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json")
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
backendFolder.assertMessages(
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml",
|
||||
"M003" to "/jmap_responses/blob/email/email_3.eml"
|
||||
)
|
||||
syncListener.assertSyncSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncWithLocalMessagesAndEmptyRemoteMailbox() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml"
|
||||
)
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_empty_result.json")
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertEquals(emptySet<String>(), backendFolder.getMessageServerIds())
|
||||
syncListener.assertSyncEvents(
|
||||
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
|
||||
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deltaSyncWithoutChanges() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml"
|
||||
)
|
||||
backendFolder.setQueryState("50:0")
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_changes_empty_result.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M001_and_M002.json")
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertEquals(setOf("M001", "M002"), backendFolder.getMessageServerIds())
|
||||
assertEquals(emptySet<Flag>(), backendFolder.getMessageFlags("M001"))
|
||||
assertEquals(setOf(Flag.SEEN), backendFolder.getMessageFlags("M002"))
|
||||
backendFolder.assertQueryState("50:0")
|
||||
syncListener.assertSyncEvents(
|
||||
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
|
||||
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deltaSyncWithLocalMessagesAndDifferentMessagesInRemoteMailbox() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml"
|
||||
)
|
||||
backendFolder.setQueryState("50:0")
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_changes_M001_deleted_M003_added.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json")
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertEquals(setOf("M002", "M003"), backendFolder.getMessageServerIds())
|
||||
backendFolder.assertQueryState("51:0")
|
||||
syncListener.assertSyncSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deltaSyncCannotCalculateChanges() {
|
||||
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
|
||||
backendFolder.createMessages(
|
||||
"M001" to "/jmap_responses/blob/email/email_1.eml",
|
||||
"M002" to "/jmap_responses/blob/email/email_2.eml"
|
||||
)
|
||||
backendFolder.setQueryState("10:0")
|
||||
val command = createCommandSync(
|
||||
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_changes_cannot_calculate_changes_error.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_query_M002_and_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"),
|
||||
responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"),
|
||||
responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json")
|
||||
)
|
||||
|
||||
command.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
|
||||
|
||||
assertEquals(setOf("M002", "M003"), backendFolder.getMessageServerIds())
|
||||
backendFolder.assertQueryState("50:0")
|
||||
syncListener.assertSyncSuccess()
|
||||
}
|
||||
|
||||
private fun createCommandSync(vararg mockResponses: MockResponse): CommandSync {
|
||||
val server = createMockWebServer(*mockResponses)
|
||||
return createCommandSync(server.url("/jmap/"))
|
||||
}
|
||||
|
||||
private fun createCommandSync(baseUrl: HttpUrl): CommandSync {
|
||||
val httpAuthentication = BasicAuthHttpAuthentication(USERNAME, PASSWORD)
|
||||
val jmapClient = JmapClient(httpAuthentication, baseUrl)
|
||||
return CommandSync(backendStorage, jmapClient, okHttpClient, ACCOUNT_ID, httpAuthentication)
|
||||
}
|
||||
|
||||
private fun createFolderInBackendStorage() {
|
||||
backendStorage.updateFolders {
|
||||
createFolders(listOf(FolderInfo(FOLDER_SERVER_ID, "Regular folder", FolderType.REGULAR)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MockWebServer.assertRequestUrlPath(expected: String) {
|
||||
val request = takeRequest()
|
||||
val requestUrl = request.requestUrl ?: error("No request URL")
|
||||
val requestUrlPath = requestUrl.encodedPath + "?" + requestUrl.encodedQuery
|
||||
assertEquals(expected, requestUrlPath)
|
||||
}
|
||||
|
||||
private fun InMemoryBackendFolder.assertQueryState(expected: String) {
|
||||
assertEquals(expected, getFolderExtraString("jmapQueryState"))
|
||||
}
|
||||
|
||||
private fun InMemoryBackendFolder.setQueryState(queryState: String) {
|
||||
setFolderExtraString("jmapQueryState", queryState)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOLDER_SERVER_ID = "id_folder"
|
||||
private const val USERNAME = "username"
|
||||
private const val PASSWORD = "password"
|
||||
private const val ACCOUNT_ID = "test@example.com"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import com.fsck.k9.backend.api.SyncListener
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
|
||||
class LoggingSyncListener : SyncListener {
|
||||
private val events = mutableListOf<SyncListenerEvent>()
|
||||
|
||||
fun assertSyncSuccess() {
|
||||
events.filterIsInstance<SyncListenerEvent.SyncFailed>().firstOrNull()?.let { syncFailed ->
|
||||
throw AssertionError("Expected sync success", syncFailed.exception)
|
||||
}
|
||||
|
||||
if (events.none { it is SyncListenerEvent.SyncFinished }) {
|
||||
fail("Expected SyncFinished, but only got: $events")
|
||||
}
|
||||
}
|
||||
|
||||
fun assertSyncEvents(vararg events: SyncListenerEvent) {
|
||||
for (event in events) {
|
||||
assertEquals(event, getNextEvent())
|
||||
}
|
||||
assertNoMoreEventsLeft()
|
||||
}
|
||||
|
||||
fun getNextEvent(): SyncListenerEvent {
|
||||
require(events.isNotEmpty()) { "No events left" }
|
||||
return events.removeAt(0)
|
||||
}
|
||||
|
||||
private fun assertNoMoreEventsLeft() {
|
||||
assertTrue("Expected no more events; but still have: $events", events.isEmpty())
|
||||
}
|
||||
|
||||
override fun syncStarted(folderServerId: String) {
|
||||
events.add(SyncListenerEvent.SyncStarted(folderServerId))
|
||||
}
|
||||
|
||||
override fun syncAuthenticationSuccess() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncHeadersStarted(folderServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncHeadersProgress(folderServerId: String, completed: Int, total: Int) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncHeadersFinished(folderServerId: String, totalMessagesInMailbox: Int, numNewMessages: Int) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncProgress(folderServerId: String, completed: Int, total: Int) {
|
||||
events.add(SyncListenerEvent.SyncProgress(folderServerId, completed, total))
|
||||
}
|
||||
|
||||
override fun syncNewMessage(folderServerId: String, messageServerId: String, isOldMessage: Boolean) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncRemovedMessage(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncFlagChanged(folderServerId: String, messageServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun syncFinished(folderServerId: String) {
|
||||
events.add(SyncListenerEvent.SyncFinished(folderServerId))
|
||||
}
|
||||
|
||||
override fun syncFailed(folderServerId: String, message: String, exception: Exception?) {
|
||||
events.add(SyncListenerEvent.SyncFailed(folderServerId, message, exception))
|
||||
}
|
||||
|
||||
override fun folderStatusChanged(folderServerId: String) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SyncListenerEvent {
|
||||
data class SyncStarted(val folderServerId: String) : SyncListenerEvent()
|
||||
data class SyncFinished(val folderServerId: String) : SyncListenerEvent()
|
||||
data class SyncFailed(
|
||||
val folderServerId: String,
|
||||
val message: String,
|
||||
val exception: Exception?
|
||||
) : SyncListenerEvent()
|
||||
|
||||
data class SyncProgress(val folderServerId: String, val completed: Int, val total: Int) : SyncListenerEvent()
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import java.io.InputStream
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
|
||||
fun createMockWebServer(vararg mockResponses: MockResponse): MockWebServer {
|
||||
return MockWebServer().apply {
|
||||
for (mockResponse in mockResponses) {
|
||||
enqueue(mockResponse)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun responseBodyFromResource(name: String): MockResponse {
|
||||
return MockResponse().setBody(loadResource(name))
|
||||
}
|
||||
|
||||
fun MockWebServer.skipRequests(count: Int) {
|
||||
repeat(count) {
|
||||
takeRequest()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadResource(name: String): String {
|
||||
val resourceAsStream = ResourceLoader.getResourceAsStream(name) ?: error("Couldn't load resource: $name")
|
||||
return resourceAsStream.use { it.source().buffer().readUtf8() }
|
||||
}
|
||||
|
||||
private object ResourceLoader {
|
||||
fun getResourceAsStream(name: String): InputStream? = javaClass.getResourceAsStream(name)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
From: alice@domain.example
|
||||
To: bob@domain.example
|
||||
Message-ID: <message001@domain.example>
|
||||
Date: Mon, 10 Feb 2020 10:20:30 +0100
|
||||
Subject: Hello there
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
Hi Bob,
|
||||
|
||||
this is a message from me to you.
|
||||
|
||||
Cheers,
|
||||
Alice
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
From: Bob <bob@domain.example>
|
||||
To: alice@domain.example
|
||||
Message-ID: <message002@domain.example>
|
||||
In-Reply-To: <message001@domain.example>
|
||||
References: <message001@domain.example>
|
||||
Date: Mon, 10 Feb 2020 10:20:30 +0100
|
||||
Subject: Re: Hello there
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
Hi Alice,
|
||||
|
||||
I've received your message.
|
||||
|
||||
Best,
|
||||
Bob
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
From: alice@domain.example
|
||||
To: alice@domain.example
|
||||
Message-ID: <message003@domain.example>
|
||||
Date: Mon, 10 Feb 2020 12:20:30 +0100
|
||||
Subject: Dummy
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
-
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M001",
|
||||
"blobId": "B001",
|
||||
"keywords": {},
|
||||
"size": 280,
|
||||
"receivedAt": "2020-02-11T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "M002",
|
||||
"blobId": "B002",
|
||||
"keywords": {
|
||||
"$seen": true
|
||||
},
|
||||
"size": 365,
|
||||
"receivedAt": "2020-01-11T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M003",
|
||||
"blobId": "B003",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-02-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M003",
|
||||
"blobId": "B003",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-02-11T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "M004",
|
||||
"blobId": "B004",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-01-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M005",
|
||||
"blobId": "B005",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-01-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M001",
|
||||
"keywords": {}
|
||||
},
|
||||
{
|
||||
"id": "M002",
|
||||
"keywords": {
|
||||
"$seen": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M002",
|
||||
"keywords": {}
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 2,
|
||||
"ids": [
|
||||
"M001",
|
||||
"M002"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 5,
|
||||
"ids": [
|
||||
"M001",
|
||||
"M002",
|
||||
"M003",
|
||||
"M004",
|
||||
"M005"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 2,
|
||||
"ids": [
|
||||
"M002",
|
||||
"M003"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/queryChanges",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldQueryState": "50:0",
|
||||
"newQueryState": "51:0",
|
||||
"removed": ["M001"],
|
||||
"added": [
|
||||
{
|
||||
"id": "M003",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"error",
|
||||
{
|
||||
"type": "cannotCalculateChanges"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/queryChanges",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldQueryState": "50:0",
|
||||
"newQueryState": "50:0",
|
||||
"removed": [],
|
||||
"added": []
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 0,
|
||||
"ids": [],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/changes",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldState": "23",
|
||||
"newState": "42",
|
||||
"hasMoreChanges": false,
|
||||
"created": [ "id_folder2" ],
|
||||
"updated": [ "id_trash" ],
|
||||
"destroyed": [ "id_folder1" ]
|
||||
},
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "42",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_folder2",
|
||||
"name": "folder2",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": null,
|
||||
"totalEmails": 0,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 0,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 10,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "42",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_trash",
|
||||
"name": "Deleted messages",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "trash",
|
||||
"totalEmails": 2,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 2,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 7,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"2"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/changes",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldState": "23",
|
||||
"newState": "27",
|
||||
"hasMoreChanges": true,
|
||||
"created": [ "id_folder2" ],
|
||||
"updated": [],
|
||||
"destroyed": []
|
||||
},
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "27",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_folder2",
|
||||
"name": "folder2",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": null,
|
||||
"totalEmails": 0,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 0,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 10,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "27",
|
||||
"list": []
|
||||
},
|
||||
"2"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/changes",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"oldState": "27",
|
||||
"newState": "42",
|
||||
"hasMoreChanges": false,
|
||||
"created": [],
|
||||
"updated": [ "id_trash" ],
|
||||
"destroyed": [ "id_folder1" ]
|
||||
},
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "42",
|
||||
"list": []
|
||||
},
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "42",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_trash",
|
||||
"name": "Deleted messages",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "trash",
|
||||
"totalEmails": 2,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 2,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 7,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"2"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"error",
|
||||
{
|
||||
"type": "cannotCalculateChanges"
|
||||
},
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"error",
|
||||
{
|
||||
"type": "resultReference"
|
||||
},
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"error",
|
||||
{
|
||||
"type": "resultReference"
|
||||
},
|
||||
"2"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "test@example.com",
|
||||
"state": "23",
|
||||
"list": [
|
||||
{
|
||||
"id": "id_inbox",
|
||||
"name": "Inbox",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": false,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": false
|
||||
},
|
||||
"role": "inbox",
|
||||
"totalEmails": 238,
|
||||
"unreadEmails": 6,
|
||||
"totalThreads": 80,
|
||||
"unreadThreads": 4,
|
||||
"sortOrder": 1,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_archive",
|
||||
"name": "Archive",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "archive",
|
||||
"totalEmails": 295,
|
||||
"unreadEmails": 36,
|
||||
"totalThreads": 136,
|
||||
"unreadThreads": 17,
|
||||
"sortOrder": 3,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_drafts",
|
||||
"name": "Drafts",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "drafts",
|
||||
"totalEmails": 0,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 0,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 4,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_sent",
|
||||
"name": "Sent",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "sent",
|
||||
"totalEmails": 2,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 2,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 5,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_trash",
|
||||
"name": "Trash",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": "trash",
|
||||
"totalEmails": 2,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 2,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 7,
|
||||
"isSubscribed": false
|
||||
},
|
||||
{
|
||||
"id": "id_folder1",
|
||||
"name": "folder1",
|
||||
"parentId": null,
|
||||
"myRights": {
|
||||
"mayReadItems": true,
|
||||
"mayAddItems": true,
|
||||
"mayRemoveItems": true,
|
||||
"mayCreateChild": true,
|
||||
"mayDelete": true,
|
||||
"maySubmit": true,
|
||||
"maySetSeen": true,
|
||||
"maySetKeywords": true,
|
||||
"mayAdmin": true,
|
||||
"mayRename": true
|
||||
},
|
||||
"role": null,
|
||||
"totalEmails": 0,
|
||||
"unreadEmails": 0,
|
||||
"totalThreads": 0,
|
||||
"unreadThreads": 0,
|
||||
"sortOrder": 10,
|
||||
"isSubscribed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"username": "test",
|
||||
"apiUrl": "/jmap/",
|
||||
"downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
|
||||
"uploadUrl": "/jmap/upload/{accountId}/",
|
||||
"eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
|
||||
"accounts": {
|
||||
"test@example.com": {
|
||||
"name": "test@example.com",
|
||||
"isPersonal": true,
|
||||
"isReadOnly": false,
|
||||
"accountCapabilities": {
|
||||
"urn:ietf:params:jmap:core": {},
|
||||
"urn:ietf:params:jmap:submission": {
|
||||
"maxDelayedSend": 44236800,
|
||||
"submissionExtensions": {
|
||||
"size": [
|
||||
"10240000"
|
||||
],
|
||||
"dsn": []
|
||||
}
|
||||
},
|
||||
"urn:ietf:params:jmap:mail": {
|
||||
"emailQuerySortOptions": [
|
||||
"receivedAt",
|
||||
"sentAt",
|
||||
"from",
|
||||
"id",
|
||||
"emailstate",
|
||||
"size",
|
||||
"subject",
|
||||
"to",
|
||||
"hasKeyword",
|
||||
"someInThreadHaveKeyword",
|
||||
"addedDates",
|
||||
"threadSize",
|
||||
"spamScore",
|
||||
"snoozedUntil"
|
||||
],
|
||||
"maxKeywordsPerEmail": 100,
|
||||
"maxSizeAttachmentsPerEmail": 10485760,
|
||||
"maxMailboxesPerEmail": 20,
|
||||
"mayCreateTopLevelMailbox": true,
|
||||
"maxSizeMailboxName": 500
|
||||
},
|
||||
"urn:ietf:params:jmap:vacationresponse": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"urn:ietf:params:jmap:core": {
|
||||
"maxSizeUpload": 1073741824,
|
||||
"maxConcurrentUpload": 5,
|
||||
"maxCallsInRequest": 50,
|
||||
"maxObjectsInGet": 2,
|
||||
"maxObjectsInSet": 4096,
|
||||
"collationAlgorithms": []
|
||||
},
|
||||
"urn:ietf:params:jmap:submission": {},
|
||||
"urn:ietf:params:jmap:mail": {},
|
||||
"urn:ietf:params:jmap:vacationresponse": {}
|
||||
},
|
||||
"state": "0"
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"username": "test",
|
||||
"apiUrl": "/jmap/",
|
||||
"downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
|
||||
"uploadUrl": "/jmap/upload/{accountId}/",
|
||||
"eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
|
||||
"accounts": {
|
||||
"test@example.com": {
|
||||
"name": "test@example.com",
|
||||
"isPersonal": true,
|
||||
"isReadOnly": false,
|
||||
"accountCapabilities": {
|
||||
"urn:ietf:params:jmap:core": {},
|
||||
"urn:ietf:params:jmap:submission": {
|
||||
"maxDelayedSend": 44236800,
|
||||
"submissionExtensions": {
|
||||
"size": [
|
||||
"10240000"
|
||||
],
|
||||
"dsn": []
|
||||
}
|
||||
},
|
||||
"urn:ietf:params:jmap:mail": {
|
||||
"emailQuerySortOptions": [
|
||||
"receivedAt",
|
||||
"sentAt",
|
||||
"from",
|
||||
"id",
|
||||
"emailstate",
|
||||
"size",
|
||||
"subject",
|
||||
"to",
|
||||
"hasKeyword",
|
||||
"someInThreadHaveKeyword",
|
||||
"addedDates",
|
||||
"threadSize",
|
||||
"spamScore",
|
||||
"snoozedUntil"
|
||||
],
|
||||
"maxKeywordsPerEmail": 100,
|
||||
"maxSizeAttachmentsPerEmail": 10485760,
|
||||
"maxMailboxesPerEmail": 20,
|
||||
"mayCreateTopLevelMailbox": true,
|
||||
"maxSizeMailboxName": 500
|
||||
},
|
||||
"urn:ietf:params:jmap:vacationresponse": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"urn:ietf:params:jmap:core": {
|
||||
"maxSizeUpload": 1073741824,
|
||||
"maxConcurrentUpload": 5,
|
||||
"maxCallsInRequest": 50,
|
||||
"maxObjectsInGet": 4096,
|
||||
"maxObjectsInSet": 4096,
|
||||
"collationAlgorithms": []
|
||||
},
|
||||
"urn:ietf:params:jmap:submission": {},
|
||||
"urn:ietf:params:jmap:mail": {},
|
||||
"urn:ietf:params:jmap:vacationresponse": {}
|
||||
},
|
||||
"state": "0"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue