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