Add JMAP message sync (part 1)

This only supports doing a full sync and downloading complete messages.
This commit is contained in:
cketti 2020-01-20 15:43:28 +01:00
parent 2eccfd34b1
commit ab7feffa68
29 changed files with 909 additions and 47 deletions

View file

@ -60,6 +60,19 @@ class K9BackendFolder(
}
}
override fun getMessageServerIds(): Set<String> {
return database.rawQuery("SELECT uid FROM messages" +
" WHERE empty = 0 AND deleted = 0 AND folder_id = ? AND uid NOT LIKE '${K9.LOCAL_UID_PREFIX}%'" +
" ORDER BY date DESC", databaseId) { cursor ->
val result = mutableSetOf<String>()
while (cursor.moveToNext()) {
val uid = cursor.getString(0)
result.add(uid)
}
result
}
}
override fun getAllMessagesAndEffectiveDates(): Map<String, Long?> {
return database.rawQuery("SELECT uid, date FROM messages" +
" WHERE empty = 0 AND deleted = 0 AND folder_id = ? AND uid NOT LIKE '${K9.LOCAL_UID_PREFIX}%'" +

View file

@ -129,7 +129,7 @@ class K9BackendFolderTest : K9RobolectricTest() {
val message = createMessage(messageServerId, flags)
backendFolder.saveCompleteMessage(message)
val messageServerIds = backendFolder.getAllMessagesAndEffectiveDates().keys
val messageServerIds = backendFolder.getMessageServerIds()
assertTrue(messageServerId in messageServerIds)
}

View file

@ -79,8 +79,7 @@ android {
applicationIdSuffix ".debug"
testCoverageEnabled rootProject.testCoverage
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
minifyEnabled false
buildConfigField "boolean", "DEVELOPER_MODE", "true"
}

View file

@ -11,11 +11,15 @@ import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mailstore.K9BackendStorageFactory
class JmapBackendFactory(private val backendStorageFactory: K9BackendStorageFactory) : BackendFactory {
class JmapBackendFactory(
private val backendStorageFactory: K9BackendStorageFactory,
private val okHttpClientProvider: OkHttpClientProvider
) : BackendFactory {
override val transportUriPrefix = "jmap"
override fun createBackend(account: Account): Backend {
val backendStorage = backendStorageFactory.createBackendStorage(account)
val okHttpClient = okHttpClientProvider.getOkHttpClient()
val serverSettings = decodeStoreUri(account.storeUri)
val jmapConfig = JmapConfig(
@ -25,7 +29,7 @@ class JmapBackendFactory(private val backendStorageFactory: K9BackendStorageFact
accountId = serverSettings.extra["accountId"]!!
)
return JmapBackend(backendStorage, jmapConfig)
return JmapBackend(backendStorage, okHttpClient, jmapConfig)
}
override fun decodeStoreUri(storeUri: String): ServerSettings {

View file

@ -17,7 +17,8 @@ val backendsModule = module {
single { ImapBackendFactory(get(), get(), get(), get()) }
single { Pop3BackendFactory(get(), get()) }
single { WebDavBackendFactory(get(), get()) }
single { JmapBackendFactory(get()) }
single { JmapBackendFactory(get(), get()) }
factory { JmapAccountDiscovery() }
factory { JmapAccountCreator(get(), get(), get(), get()) }
single { OkHttpClientProvider() }
}

View file

@ -0,0 +1,16 @@
package com.fsck.k9.backends
import okhttp3.OkHttpClient
class OkHttpClientProvider {
private var okHttpClient: OkHttpClient? = null
@Synchronized
fun getOkHttpClient(): OkHttpClient {
return okHttpClient ?: createOkHttpClient().also { okHttpClient = it }
}
private fun createOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder().build()
}
}

View file

@ -9,6 +9,7 @@ interface BackendFolder {
val name: String
val visibleLimit: Int
fun getMessageServerIds(): Set<String>
fun getAllMessagesAndEffectiveDates(): Map<String, Long?>
fun destroyMessages(messageServerIds: List<String>)
fun getLastUid(): Long?

View file

@ -11,11 +11,11 @@ dependencies {
api project(":backend:api")
implementation 'rs.ltt.jmap:jmap-client:0.2.2'
api "com.squareup.okhttp3:okhttp:${versions.okhttp}"
implementation "rs.ltt.jmap:jmap-client:0.2.4"
implementation "com.jakewharton.timber:timber:${versions.timber}"
testImplementation project(":mail:testing")
testImplementation "junit:junit:${versions.junit}"
testImplementation "org.mockito:mockito-core:${versions.mockito}"
testImplementation("com.squareup.okhttp3:mockwebserver:4.2.1")
}

View file

@ -0,0 +1,204 @@
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.SyncListener
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.Flag
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.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.capability.CoreCapability
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.QueryEmailMethodCall
import rs.ltt.jmap.common.method.response.email.GetEmailMethodResponse
import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse
import timber.log.Timber
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, listener: SyncListener) {
try {
val backendFolder = backendStorage.getFolder(folderServerId)
listener.syncStarted(folderServerId)
val limit = if (backendFolder.visibleLimit > 0) backendFolder.visibleLimit.toLong() else null
// FIXME: Only do full sync when we can't do a delta sync
fullSync(backendFolder, folderServerId, limit, 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, 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 remoteServerIds = fetchMessageServerIds(folderServerId, limit)
val destroyServerIds = cachedServerIds - remoteServerIds
if (destroyServerIds.isNotEmpty()) {
Timber.d("Removing messages no longer on server: %s", destroyServerIds)
backendFolder.destroyMessages(destroyServerIds.toList())
}
val newServerIds = remoteServerIds - cachedServerIds
if (newServerIds.isEmpty()) {
Timber.d("No new messages on server")
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.saveCompleteMessage(message)
} else {
Timber.d("Failed to download message: %s", messageInfo.serverId)
}
listener.syncProgress(folderServerId, index + 1, total)
}
// TODO: Refresh flags of messages we've already downloaded before
}
private fun fetchMessageServerIds(folderServerId: String, limit: Long?): Set<String> {
val filter = EmailFilterCondition.builder()
.inMailbox(folderServerId)
.build()
// FIXME: Add sort parameter
val queryEmailMethod = QueryEmailMethodCall(accountId, EmailQuery.of(filter), limit)
val queryEmailCall = jmapClient.call(queryEmailMethod)
val queryEmailResponse = queryEmailCall.getMainResponseBlocking<QueryEmailMethodResponse>()
return queryEmailResponse.ids.toSet()
}
private fun fetchMessageInfo(session: Session, maxObjectsInGet: Int, emailIds: Set<String>): List<MessageInfo> {
return emailIds
.chunked(maxObjectsInGet) { emailIdsChunk ->
fetchEmailsForMessageInfo(emailIdsChunk)
}
.flatten()
.map { email ->
email.toMessageInfo(session)
}
}
private fun fetchEmailsForMessageInfo(emailIdsChunk: List<String>): List<Email> {
val getEmailMethod = GetEmailMethodCall(
accountId,
emailIdsChunk.toTypedArray(),
arrayOf(
"id",
"blobId",
"size",
"receivedAt",
"keywords"
)
)
val getEmailCall = jmapClient.call(getEmailMethod)
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 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 Session.maxObjectsInGet(): Int {
val coreCapability = getCapability(CoreCapability::class.java)
return minOf(Int.MAX_VALUE.toLong(), coreCapability.maxObjectsInGet).toInt()
}
}
private data class MessageInfo(
val serverId: String,
val downloadUrl: HttpUrl,
val receivedAt: Date,
val flags: Set<Flag>
)

View file

@ -11,17 +11,23 @@ import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.PushReceiver
import com.fsck.k9.mail.Pusher
import okhttp3.HttpUrl
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(
private val backendStorage: BackendStorage,
backendStorage: BackendStorage,
okHttpClient: OkHttpClient,
config: JmapConfig
) : Backend {
private val jmapClient = config.toJmapClient()
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)
override val supportsSeenFlag = true
override val supportsExpunge = false
override val supportsMove = true
@ -37,7 +43,7 @@ class JmapBackend(
}
override fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) {
// TODO: implement
commandSync.sync(folder, listener)
}
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
@ -116,12 +122,16 @@ class JmapBackend(
checkIncomingServerSettings()
}
private fun JmapConfig.toJmapClient(): JmapClient {
return if (baseUrl == null) {
JmapClient(username, password)
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 = HttpUrl.parse(baseUrl)
JmapClient(username, password, baseHttpUrl)
val baseHttpUrl = jmapConfig.baseUrl.toHttpUrlOrNull()
JmapClient(httpAuthentication, baseHttpUrl)
}
}
}

View file

@ -7,9 +7,6 @@ import com.fsck.k9.mail.MessagingException
import junit.framework.AssertionFailedError
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.buffer
import okio.source
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
@ -132,15 +129,6 @@ class CommandRefreshFolderListTest {
return createCommandRefreshFolderList(server.url("/jmap/"))
}
private fun createMockWebServer(vararg mockResponses: MockResponse): MockWebServer {
return MockWebServer().apply {
for (mockResponse in mockResponses) {
enqueue(mockResponse)
}
start()
}
}
private fun createCommandRefreshFolderList(
baseUrl: HttpUrl,
accountId: String = "test@example.com"
@ -149,15 +137,6 @@ class CommandRefreshFolderListTest {
return CommandRefreshFolderList(backendStorage, jmapClient, accountId)
}
private fun responseBodyFromResource(name: String): MockResponse {
return MockResponse().setBody(loadResource(name))
}
private fun loadResource(name: String): String {
val resourceAsStream = javaClass.getResourceAsStream(name) ?: error("Couldn't load resource: $name")
return resourceAsStream.use { it.source().buffer().readUtf8() }
}
@Suppress("SameParameterValue")
private fun createFoldersInBackendStorage(state: String) {
createFolderInBackendStorage("id_inbox", "Inbox", FolderType.INBOX)

View file

@ -0,0 +1,164 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.internet.BinaryTempFileBody
import java.io.File
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()
@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, 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, 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"
)
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, syncListener)
val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
assertEquals(setOf("M001", "M002", "M003", "M004", "M005"), backendFolder.getMessageServerIds())
}
@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")
)
command.sync(FOLDER_SERVER_ID, syncListener)
backendFolder.assertMessages(
"M002" to "/jmap_responses/blob/email/email_2.eml",
"M003" to "/jmap_responses/blob/email/email_3.eml"
)
}
@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, syncListener)
assertEquals(emptySet<String>(), backendFolder.getMessageServerIds())
syncListener.assertSyncEvents(
SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID),
SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID)
)
}
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.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)
}
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

@ -4,20 +4,64 @@ import com.fsck.k9.backend.api.BackendFolder
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.internet.MimeMessage
import java.util.Date
import okio.Buffer
import org.junit.Assert.assertEquals
class InMemoryBackendFolder(override var name: String, var type: FolderType) : BackendFolder {
val extraStrings: MutableMap<String, String> = mutableMapOf()
val extraNumbers: MutableMap<String, Long> = mutableMapOf()
private val messages = mutableMapOf<String, Message>()
override var visibleLimit: Int = 25
fun assertMessages(vararg messagePairs: Pair<String, String>) {
for ((messageServerId, resourceName) in messagePairs) {
assertMessageContents(messageServerId, resourceName)
}
val messageServerIds = messagePairs.map { it.first }.toSet()
assertEquals(messageServerIds, messages.keys)
}
private fun assertMessageContents(messageServerId: String, resourceName: String) {
val message = messages[messageServerId] ?: error("Message $messageServerId not found")
assertEquals(loadResource(resourceName), getMessageContents(message))
}
fun createMessages(vararg messagePairs: Pair<String, String>) {
for ((messageServerId, resourceName) in messagePairs) {
val inputStream = javaClass.getResourceAsStream(resourceName)
?: error("Couldn't load resource: $resourceName")
val message = inputStream.use {
MimeMessage.parseMimeMessage(inputStream, false)
}
messages[messageServerId] = message
}
}
private fun getMessageContents(message: Message): String {
val buffer = Buffer()
buffer.outputStream().use {
message.writeTo(it)
}
return buffer.readUtf8()
}
override fun getMessageServerIds(): Set<String> {
return messages.keys.toSet()
}
override fun getAllMessagesAndEffectiveDates(): Map<String, Long?> {
throw UnsupportedOperationException("not implemented")
}
override fun destroyMessages(messageServerIds: List<String>) {
throw UnsupportedOperationException("not implemented")
for (messageServerId in messageServerIds) {
messages.remove(messageServerId)
}
}
override fun getLastUid(): Long? {
@ -61,11 +105,13 @@ class InMemoryBackendFolder(override var name: String, var type: FolderType) : B
}
override fun savePartialMessage(message: Message) {
throw UnsupportedOperationException("not implemented")
val messageServerId = checkNotNull(message.uid)
messages[messageServerId] = message
}
override fun saveCompleteMessage(message: Message) {
throw UnsupportedOperationException("not implemented")
val messageServerId = checkNotNull(message.uid)
messages[messageServerId] = message
}
override fun getLatestOldMessageSeenTime(): Date {

View file

@ -1,6 +1,5 @@
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.FolderInfo
import com.fsck.k9.mail.FolderType
@ -10,7 +9,7 @@ class InMemoryBackendStorage : BackendStorage {
val extraStrings: MutableMap<String, String> = mutableMapOf()
val extraNumbers: MutableMap<String, Long> = mutableMapOf()
override fun getFolder(folderServerId: String): BackendFolder {
override fun getFolder(folderServerId: String): InMemoryBackendFolder {
return folders[folderServerId] ?: error("Folder $folderServerId not found")
}

View file

@ -0,0 +1,84 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.SyncListener
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
class LoggingSyncListener : SyncListener {
private val events = mutableListOf<SyncListenerEvent>()
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,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/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,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

@ -5,11 +5,11 @@ apply plugin: 'org.jlleitschuh.gradle.ktlint'
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
implementation project(":mail:common")
api project(":mail:common")
implementation "com.squareup.okio:okio:${versions.okio}"
implementation "org.robolectric:robolectric:${versions.robolectric}"
implementation "junit:junit:${versions.junit}"
api "com.squareup.okio:okio:${versions.okio}"
api "org.robolectric:robolectric:${versions.robolectric}"
api "junit:junit:${versions.junit}"
}
android {