Add JMAP message sync (part 1)
This only supports doing a full sync and downloading complete messages.
This commit is contained in:
parent
2eccfd34b1
commit
ab7feffa68
29 changed files with 909 additions and 47 deletions
|
@ -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}%'" +
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.backend.jmap
|
||||
|
||||
import java.io.InputStream
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
|
||||
fun createMockWebServer(vararg mockResponses: MockResponse): MockWebServer {
|
||||
return MockWebServer().apply {
|
||||
for (mockResponse in mockResponses) {
|
||||
enqueue(mockResponse)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun responseBodyFromResource(name: String): MockResponse {
|
||||
return MockResponse().setBody(loadResource(name))
|
||||
}
|
||||
|
||||
fun MockWebServer.skipRequests(count: Int) {
|
||||
repeat(count) {
|
||||
takeRequest()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadResource(name: String): String {
|
||||
val resourceAsStream = ResourceLoader.getResourceAsStream(name) ?: error("Couldn't load resource: $name")
|
||||
return resourceAsStream.use { it.source().buffer().readUtf8() }
|
||||
}
|
||||
|
||||
private object ResourceLoader {
|
||||
fun getResourceAsStream(name: String): InputStream? = javaClass.getResourceAsStream(name)
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
From: alice@domain.example
|
||||
To: bob@domain.example
|
||||
Message-ID: <message001@domain.example>
|
||||
Date: Mon, 10 Feb 2020 10:20:30 +0100
|
||||
Subject: Hello there
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
Hi Bob,
|
||||
|
||||
this is a message from me to you.
|
||||
|
||||
Cheers,
|
||||
Alice
|
|
@ -0,0 +1,16 @@
|
|||
From: Bob <bob@domain.example>
|
||||
To: alice@domain.example
|
||||
Message-ID: <message002@domain.example>
|
||||
In-Reply-To: <message001@domain.example>
|
||||
References: <message001@domain.example>
|
||||
Date: Mon, 10 Feb 2020 10:20:30 +0100
|
||||
Subject: Re: Hello there
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
Hi Alice,
|
||||
|
||||
I've received your message.
|
||||
|
||||
Best,
|
||||
Bob
|
|
@ -0,0 +1,9 @@
|
|||
From: alice@domain.example
|
||||
To: alice@domain.example
|
||||
Message-ID: <message003@domain.example>
|
||||
Date: Mon, 10 Feb 2020 12:20:30 +0100
|
||||
Subject: Dummy
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Mime-Version: 1.0
|
||||
|
||||
-
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M001",
|
||||
"blobId": "B001",
|
||||
"keywords": {},
|
||||
"size": 280,
|
||||
"receivedAt": "2020-02-11T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "M002",
|
||||
"blobId": "B002",
|
||||
"keywords": {
|
||||
"$seen": true
|
||||
},
|
||||
"size": 365,
|
||||
"receivedAt": "2020-01-11T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M003",
|
||||
"blobId": "B003",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-02-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M003",
|
||||
"blobId": "B003",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-02-11T13:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "M004",
|
||||
"blobId": "B004",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-01-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/get",
|
||||
{
|
||||
"state": "50",
|
||||
"list": [
|
||||
{
|
||||
"id": "M005",
|
||||
"blobId": "B005",
|
||||
"keywords": {},
|
||||
"size": 215,
|
||||
"receivedAt": "2020-01-11T13:00:00Z"
|
||||
}
|
||||
],
|
||||
"notFound": [],
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 2,
|
||||
"ids": [
|
||||
"M001",
|
||||
"M002"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 5,
|
||||
"ids": [
|
||||
"M001",
|
||||
"M002",
|
||||
"M003",
|
||||
"M004",
|
||||
"M005"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 2,
|
||||
"ids": [
|
||||
"M002",
|
||||
"M003"
|
||||
],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"methodResponses": [
|
||||
[
|
||||
"Email/query",
|
||||
{
|
||||
"filter": {
|
||||
"inMailbox": "id_folder"
|
||||
},
|
||||
"queryState": "50:0",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"total": 0,
|
||||
"ids": [],
|
||||
"collapseThreads": false,
|
||||
"accountId": "test@example.com"
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "0"
|
||||
}
|
|
@ -0,0 +1,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"
|
||||
}
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue