summaryrefslogtreecommitdiff
path: root/backend/jmap/src/main/java
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /backend/jmap/src/main/java
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'backend/jmap/src/main/java')
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandDelete.kt71
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandMove.kt56
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandRefreshFolderList.kt165
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSetFlag.kt108
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSync.kt322
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandUpload.kt98
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapAccountDiscovery.kt46
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt160
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapConfig.kt8
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt40
-rw-r--r--backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapUploadResponse.kt11
11 files changed, 1085 insertions, 0 deletions
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandDelete.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandDelete.kt
new file mode 100644
index 0000000..0db6100
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandDelete.kt
@@ -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)
+ }
+}
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandMove.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandMove.kt
new file mode 100644
index 0000000..f45461c
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandMove.kt
@@ -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>()
+ }
+ }
+}
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandRefreshFolderList.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandRefreshFolderList.kt
new file mode 100644
index 0000000..2b6f9ce
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandRefreshFolderList.kt
@@ -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)
+}
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSetFlag.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSetFlag.kt
new file mode 100644
index 0000000..1a16463
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSetFlag.kt
@@ -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")
+ }
+}
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSync.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSync.kt
new file mode 100644
index 0000000..876d3b8
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSync.kt
@@ -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>
+)
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandUpload.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandUpload.kt
new file mode 100644
index 0000000..89da6e6
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandUpload.kt
@@ -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())
+ }
+}
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapAccountDiscovery.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapAccountDiscovery.kt
new file mode 100644
index 0000000..c3f4bf8
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapAccountDiscovery.kt
@@ -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()
+}
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt
new file mode 100644
index 0000000..70350e0
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt
@@ -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)
+ }
+ }
+}
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapConfig.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapConfig.kt
new file mode 100644
index 0000000..6ac5239
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapConfig.kt
@@ -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
+)
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt
new file mode 100644
index 0000000..0fcc196
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt
@@ -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()
+ }
diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapUploadResponse.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapUploadResponse.kt
new file mode 100644
index 0000000..f93b43e
--- /dev/null
+++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapUploadResponse.kt
@@ -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
+)