diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /backend/jmap/src/main/java | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'backend/jmap/src/main/java')
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 +) |
