Change K9BackendFolder.saveMessage() to use MessageStore

This commit is contained in:
cketti 2021-04-12 01:55:16 +02:00
parent 78e616ed37
commit 4e4babeea6
24 changed files with 1230 additions and 30 deletions

View file

@ -14,12 +14,12 @@ import com.fsck.k9.mailstore.MoreMessages as StoreMoreMessages
class K9BackendFolder(
localStore: LocalStore,
private val messageStore: MessageStore,
private val saveMessageDataCreator: SaveMessageDataCreator,
folderServerId: String
) : BackendFolder {
private val database = localStore.database
private val databaseId: String
private val folderId: Long
private val localFolder = localStore.getFolder(folderServerId)
override val name: String
override val visibleLimit: Int
@ -91,24 +91,19 @@ class K9BackendFolder(
messageStore.setMessageFlag(folderId, messageServerId, flag, value)
}
// TODO: Move implementation from LocalFolder to this class
override fun saveCompleteMessage(message: Message) {
requireMessageServerId(message)
localFolder.appendMessages(listOf(message))
val localMessage = localFolder.getMessage(message.uid)
localMessage.setFlag(Flag.X_DOWNLOADED_FULL, true)
saveMessage(message, partialMessage = false)
}
// TODO: Move implementation from LocalFolder to this class
override fun savePartialMessage(message: Message) {
saveMessage(message, partialMessage = true)
}
private fun saveMessage(message: Message, partialMessage: Boolean) {
requireMessageServerId(message)
localFolder.appendMessages(listOf(message))
val localMessage = localFolder.getMessage(message.uid)
localMessage.setFlag(Flag.X_DOWNLOADED_PARTIAL, true)
val messageData = saveMessageDataCreator.createSaveMessageData(message, partialMessage)
messageStore.saveRemoteMessage(folderId, message.uid, messageData)
}
override fun getOldestMessageDate(): Date? {

View file

@ -10,10 +10,11 @@ class K9BackendStorage(
private val localStore: LocalStore,
private val messageStore: MessageStore,
private val folderSettingsProvider: FolderSettingsProvider,
private val saveMessageDataCreator: SaveMessageDataCreator,
private val listeners: List<BackendFoldersRefreshListener>
) : BackendStorage {
override fun getFolder(folderServerId: String): BackendFolder {
return K9BackendFolder(localStore, messageStore, folderServerId)
return K9BackendFolder(localStore, messageStore, saveMessageDataCreator, folderServerId)
}
override fun getFolderServerIds(): List<String> {

View file

@ -8,7 +8,8 @@ class K9BackendStorageFactory(
private val folderRepositoryManager: FolderRepositoryManager,
private val localStoreProvider: LocalStoreProvider,
private val messageStoreManager: MessageStoreManager,
private val specialFolderSelectionStrategy: SpecialFolderSelectionStrategy
private val specialFolderSelectionStrategy: SpecialFolderSelectionStrategy,
private val saveMessageDataCreator: SaveMessageDataCreator
) {
fun createBackendStorage(account: Account): K9BackendStorage {
val folderRepository = folderRepositoryManager.getFolderRepository(account)
@ -24,6 +25,6 @@ class K9BackendStorageFactory(
val specialFolderListener = SpecialFolderBackendFoldersRefreshListener(specialFolderUpdater)
val autoExpandFolderListener = AutoExpandFolderBackendFoldersRefreshListener(preferences, account, folderRepository)
val listeners = listOf(specialFolderListener, autoExpandFolderListener)
return K9BackendStorage(localStore, messageStore, folderSettingsProvider, listeners)
return K9BackendStorage(localStore, messageStore, folderSettingsProvider, saveMessageDataCreator, listeners)
}
}

View file

@ -1,5 +1,8 @@
package com.fsck.k9.mailstore
import com.fsck.k9.message.extractors.AttachmentCounter
import com.fsck.k9.message.extractors.MessageFulltextCreator
import com.fsck.k9.message.extractors.MessagePreviewCreator
import org.koin.dsl.module
val mailStoreModule = module {
@ -14,10 +17,22 @@ val mailStoreModule = module {
folderRepositoryManager = get(),
localStoreProvider = get(),
messageStoreManager = get(),
specialFolderSelectionStrategy = get()
specialFolderSelectionStrategy = get(),
saveMessageDataCreator = get()
)
}
factory { SpecialLocalFoldersCreator(preferences = get(), localStoreProvider = get()) }
single { MessageStoreManager(accountManager = get(), messageStoreFactory = get()) }
single { MessageRepository(messageStoreManager = get()) }
factory { MessagePreviewCreator.newInstance() }
factory { MessageFulltextCreator.newInstance() }
factory { AttachmentCounter.newInstance() }
factory {
SaveMessageDataCreator(
encryptionExtractor = get(),
messagePreviewCreator = get(),
messageFulltextCreator = get(),
attachmentCounter = get()
)
}
}

View file

@ -13,6 +13,11 @@ import com.fsck.k9.mail.Header
* storage implementation.
*/
interface MessageStore {
/**
* Save a remote message in this store.
*/
fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData)
/**
* Move a message to another folder.
*

View file

@ -0,0 +1,16 @@
package com.fsck.k9.mailstore
import com.fsck.k9.mail.Message
import com.fsck.k9.message.extractors.PreviewResult
data class SaveMessageData(
val message: Message,
val subject: String?,
val date: Long,
val internalDate: Long,
val partialMessage: Boolean,
val attachmentCount: Int,
val previewResult: PreviewResult,
val textForSearchIndex: String? = null,
val encryptionType: String?
)

View file

@ -0,0 +1,47 @@
package com.fsck.k9.mailstore
import com.fsck.k9.crypto.EncryptionExtractor
import com.fsck.k9.mail.Message
import com.fsck.k9.message.extractors.AttachmentCounter
import com.fsck.k9.message.extractors.MessageFulltextCreator
import com.fsck.k9.message.extractors.MessagePreviewCreator
class SaveMessageDataCreator(
private val encryptionExtractor: EncryptionExtractor,
private val messagePreviewCreator: MessagePreviewCreator,
private val messageFulltextCreator: MessageFulltextCreator,
private val attachmentCounter: AttachmentCounter
) {
fun createSaveMessageData(message: Message, partialMessage: Boolean): SaveMessageData {
val now = System.currentTimeMillis()
val date = message.sentDate?.time ?: now
val internalDate = message.internalDate?.time ?: now
val encryptionResult = encryptionExtractor.extractEncryption(message)
return if (encryptionResult != null) {
SaveMessageData(
message = message,
subject = message.subject,
date = date,
internalDate = internalDate,
partialMessage = partialMessage,
attachmentCount = encryptionResult.attachmentCount,
previewResult = encryptionResult.previewResult,
textForSearchIndex = encryptionResult.textForSearchIndex,
encryptionType = encryptionResult.encryptionType
)
} else {
SaveMessageData(
message = message,
subject = message.subject,
date = date,
internalDate = internalDate,
partialMessage = partialMessage,
attachmentCount = attachmentCounter.getAttachmentCount(message),
previewResult = messagePreviewCreator.createPreview(message),
textForSearchIndex = messageFulltextCreator.createFulltext(message),
encryptionType = null
)
}
}
}

View file

@ -0,0 +1,52 @@
package com.fsck.k9.message.extractors
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.internet.MimeParameterDecoder
import com.fsck.k9.mail.internet.MimeUtility
import com.fsck.k9.mail.internet.MimeValue
import java.util.Locale
private const val FALLBACK_NAME = "noname"
/**
* Extract a display name and the size from the headers of a message part.
*/
class BasicPartInfoExtractor {
fun extractPartInfo(part: Part): BasicPartInfo {
val contentDisposition = part.disposition?.toMimeValue()
return BasicPartInfo(
displayName = part.getDisplayName(contentDisposition),
size = contentDisposition?.getParameter("size")?.toLongOrNull()
)
}
fun extractDisplayName(part: Part): String {
return part.getDisplayName()
}
private fun Part.getDisplayName(contentDisposition: MimeValue? = disposition?.toMimeValue()): String {
return contentDisposition?.getParameter("filename")
?: contentType?.getParameter("name")
?: mimeType.toDisplayName()
}
private fun String?.toDisplayName(): String {
val extension = this?.let { mimeType -> MimeUtility.getExtensionByMimeType(mimeType) }
return if (extension.isNullOrEmpty()) FALLBACK_NAME else "$FALLBACK_NAME.$extension"
}
private fun String.toMimeValue(): MimeValue = MimeParameterDecoder.decode(this)
private fun MimeValue.getParameter(name: String): String? = parameters[name.toLowerCase(Locale.ROOT)]
private fun String.getParameter(name: String): String? {
val mimeValue = MimeParameterDecoder.decode(this)
return mimeValue.parameters[name.toLowerCase(Locale.ROOT)]
}
}
data class BasicPartInfo(
val displayName: String,
val size: Long?
)

View file

@ -5,4 +5,5 @@ import org.koin.dsl.module
val extractorModule = module {
single { AttachmentInfoExtractor(get()) }
single { TextPartFinder() }
single { BasicPartInfoExtractor() }
}

View file

@ -29,6 +29,7 @@ class K9BackendFolderTest : K9RobolectricTest() {
val preferences: Preferences by inject()
val localStoreProvider: LocalStoreProvider by inject()
val messageStoreManager: MessageStoreManager by inject()
val saveMessageDataCreator: SaveMessageDataCreator by inject()
val account: Account = createAccount()
val backendFolder = createBackendFolder()
@ -118,7 +119,13 @@ class K9BackendFolderTest : K9RobolectricTest() {
fun createBackendFolder(): BackendFolder {
val localStore: LocalStore = localStoreProvider.getInstance(account)
val messageStore = messageStoreManager.getMessageStore(account)
val backendStorage = K9BackendStorage(localStore, messageStore, createFolderSettingsProvider(), emptyList())
val backendStorage = K9BackendStorage(
localStore,
messageStore,
createFolderSettingsProvider(),
saveMessageDataCreator,
emptyList()
)
backendStorage.updateFolders {
createFolders(listOf(FolderInfo(FOLDER_SERVER_ID, FOLDER_NAME, FOLDER_TYPE)))
}
@ -126,7 +133,7 @@ class K9BackendFolderTest : K9RobolectricTest() {
val folderServerIds = backendStorage.getFolderServerIds()
assertTrue(FOLDER_SERVER_ID in folderServerIds)
return K9BackendFolder(localStore, messageStore, FOLDER_SERVER_ID)
return K9BackendFolder(localStore, messageStore, saveMessageDataCreator, FOLDER_SERVER_ID)
}
fun createMessageInBackendFolder(messageServerId: String, flags: Set<Flag> = emptySet()) {

View file

@ -20,6 +20,7 @@ class K9BackendStorageTest : K9RobolectricTest() {
val preferences: Preferences by inject()
val localStoreProvider: LocalStoreProvider by inject()
val messageStoreManager: MessageStoreManager by inject()
val saveMessageDataCreator: SaveMessageDataCreator by inject()
val account: Account = createAccount()
val database: LockableDatabase = localStoreProvider.getInstance(account).database
@ -81,7 +82,7 @@ class K9BackendStorageTest : K9RobolectricTest() {
val localStore = localStoreProvider.getInstance(account)
val messageStore = messageStoreManager.getMessageStore(account)
val folderSettingsProvider = createFolderSettingsProvider()
return K9BackendStorage(localStore, messageStore, folderSettingsProvider, emptyList())
return K9BackendStorage(localStore, messageStore, folderSettingsProvider, saveMessageDataCreator, emptyList())
}
}

View file

@ -0,0 +1,120 @@
package com.fsck.k9.message.extractors
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.internet.MimeBodyPart
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class BasicPartInfoExtractorTest {
private val basicPartInfoExtractor = BasicPartInfoExtractor()
@Test
fun `extractPartInfo with 'filename' parameter in Content-Disposition header`() {
val part = createPart(
contentType = "application/octet-stream",
contentDisposition = "attachment; filename=\"attachment_name.txt\"; size=23"
)
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
assertThat(partInfo.displayName).isEqualTo("attachment_name.txt")
assertThat(partInfo.size).isEqualTo(23L)
}
@Test
fun `extractPartInfo with 'name' parameter in Content-Type header`() {
val part = createPart(
contentType = "image/jpeg; name=\"attachment.jpeg\"",
contentDisposition = "attachment; size=42"
)
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
assertThat(partInfo.displayName).isEqualTo("attachment.jpeg")
assertThat(partInfo.size).isEqualTo(42L)
}
@Test
fun `extractPartInfo without display name and size`() {
val part = createPart(contentType = "text/plain", contentDisposition = "attachment")
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
assertThat(partInfo.displayName).isEqualTo("noname.txt")
assertThat(partInfo.size).isNull()
}
@Test
fun `extractPartInfo without display name and unknown mime type`() {
val part = createPart(contentType = "x-made-up/unknown", contentDisposition = "attachment")
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
assertThat(partInfo.displayName).isEqualTo("noname")
assertThat(partInfo.size).isNull()
}
@Test
fun `extractPartInfo with missing Content-Disposition header`() {
val part = createPart(
contentType = "application/octet-stream; name=\"attachment.dat\"",
contentDisposition = null
)
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
assertThat(partInfo.displayName).isEqualTo("attachment.dat")
assertThat(partInfo.size).isNull()
}
@Test
fun `extractPartInfo with missing Content-Disposition header and name`() {
val part = createPart(
contentType = "application/octet-stream",
contentDisposition = null
)
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
assertThat(partInfo.displayName).isEqualTo("noname")
assertThat(partInfo.size).isNull()
}
@Test
fun `extractPartInfo without any relevant headers`() {
val part = createPart(
contentType = null,
contentDisposition = null
)
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
assertThat(partInfo.displayName).isEqualTo("noname.txt")
assertThat(partInfo.size).isNull()
}
@Test
fun `extractPartInfo with invalid Content-Disposition header`() {
val part = createPart(
contentType = "application/octet-stream",
contentDisposition = "something; <invalid>"
)
val partInfo = basicPartInfoExtractor.extractPartInfo(part)
assertThat(partInfo.displayName).isEqualTo("noname")
assertThat(partInfo.size).isNull()
}
private fun createPart(contentType: String?, contentDisposition: String?): Part {
return MimeBodyPart().apply {
if (contentType != null) {
addHeader("Content-Type", contentType)
}
if (contentDisposition != null) {
addHeader("Content-Disposition", contentDisposition)
}
}
}
}

View file

@ -10,6 +10,7 @@ dependencies {
implementation "androidx.core:core-ktx:${versions.androidxCore}"
implementation "com.jakewharton.timber:timber:${versions.timber}"
implementation "org.apache.james:apache-mime4j-core:${versions.mime4j}"
implementation "commons-io:commons-io:${versions.commonsIo}"
implementation "com.squareup.moshi:moshi:${versions.moshi}"
testImplementation project(':mail:testing')

View file

@ -7,5 +7,7 @@ import org.koin.dsl.module
val storageModule = module {
single<SchemaDefinitionFactory> { K9SchemaDefinitionFactory() }
single<MessageStoreFactory> { K9MessageStoreFactory(localStoreProvider = get(), storageManager = get()) }
single<MessageStoreFactory> {
K9MessageStoreFactory(localStoreProvider = get(), storageManager = get(), basicPartInfoExtractor = get())
}
}

View file

@ -1,6 +1,7 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.K9
import com.fsck.k9.helper.FileHelper
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider
import java.io.File
@ -11,14 +12,19 @@ class AttachmentFileManager(
private val accountUuid: String
) {
fun deleteFile(messagePartId: Long) {
val file = getAttachmentFile(messagePartId.toString())
val file = getAttachmentFile(messagePartId)
if (file.exists() && !file.delete() && K9.isDebugLoggingEnabled) {
Timber.w("Couldn't delete message part file: %s", file.absolutePath)
}
}
private fun getAttachmentFile(messagePartId: String): File {
fun moveTemporaryFile(temporaryFile: File, messagePartId: Long) {
val destinationFile = getAttachmentFile(messagePartId)
FileHelper.renameOrMoveByCopying(temporaryFile, destinationFile)
}
private fun getAttachmentFile(messagePartId: Long): File {
val attachmentDirectory = storageManager.getAttachmentDirectory(accountUuid, InternalStorageProvider.ID)
return File(attachmentDirectory, messagePartId)
return File(attachmentDirectory, messagePartId.toString())
}
}

View file

@ -5,7 +5,7 @@ import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mail.Flag
import com.fsck.k9.mailstore.LockableDatabase
private val SPECIAL_FLAGS = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED)
internal val SPECIAL_FLAGS = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED)
internal class FlagMessageOperations(private val lockableDatabase: LockableDatabase) {

View file

@ -12,17 +12,26 @@ import com.fsck.k9.mailstore.LocalStore
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.MessageStore
import com.fsck.k9.mailstore.MoreMessages
import com.fsck.k9.mailstore.SaveMessageData
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
// TODO: Remove dependency on LocalStore
class K9MessageStore(
private val localStore: LocalStore,
storageManager: StorageManager,
basicPartInfoExtractor: BasicPartInfoExtractor,
accountUuid: String
) : MessageStore {
private val database: LockableDatabase = localStore.database
private val attachmentFileManager = AttachmentFileManager(storageManager, accountUuid)
private val threadMessageOperations = ThreadMessageOperations()
private val saveMessageOperations = SaveMessageOperations(
database,
attachmentFileManager,
basicPartInfoExtractor,
threadMessageOperations
)
private val moveMessageOperations = MoveMessageOperations(database, threadMessageOperations)
private val flagMessageOperations = FlagMessageOperations(database)
private val retrieveMessageOperations = RetrieveMessageOperations(database)
@ -33,6 +42,10 @@ class K9MessageStore(
private val deleteFolderOperations = DeleteFolderOperations(database, attachmentFileManager)
private val keyValueStoreOperations = KeyValueStoreOperations(database)
override fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
saveMessageOperations.saveRemoteMessage(folderId, messageServerId, messageData)
}
override fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
return moveMessageOperations.moveMessage(messageId, destinationFolderId).also {
localStore.notifyChange()

View file

@ -5,13 +5,15 @@ import com.fsck.k9.mailstore.LocalStoreProvider
import com.fsck.k9.mailstore.MessageStore
import com.fsck.k9.mailstore.MessageStoreFactory
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
class K9MessageStoreFactory(
private val localStoreProvider: LocalStoreProvider,
private val storageManager: StorageManager
private val storageManager: StorageManager,
private val basicPartInfoExtractor: BasicPartInfoExtractor
) : MessageStoreFactory {
override fun create(account: Account): MessageStore {
val localStore = localStoreProvider.getInstance(account)
return K9MessageStore(localStore, storageManager, account.uuid)
return K9MessageStore(localStore, storageManager, basicPartInfoExtractor, account.uuid)
}
}

View file

@ -0,0 +1,382 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mail.Address
import com.fsck.k9.mail.Body
import com.fsck.k9.mail.BoundaryGenerator
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Message.RecipientType
import com.fsck.k9.mail.Multipart
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.filter.CountingOutputStream
import com.fsck.k9.mail.internet.BinaryTempFileBody
import com.fsck.k9.mail.internet.MimeHeader
import com.fsck.k9.mail.internet.MimeUtility
import com.fsck.k9.mail.internet.SizeAware
import com.fsck.k9.mailstore.DatabasePreviewType
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.SaveMessageData
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
import com.fsck.k9.message.extractors.PreviewResult.PreviewType
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.Locale
import java.util.Stack
import org.apache.commons.io.IOUtils
import org.apache.james.mime4j.codec.Base64InputStream
import org.apache.james.mime4j.codec.QuotedPrintableInputStream
import org.apache.james.mime4j.util.MimeUtil
internal const val MAX_BODY_SIZE_FOR_DATABASE = 16 * 1024L
internal class SaveMessageOperations(
private val lockableDatabase: LockableDatabase,
private val attachmentFileManager: AttachmentFileManager,
private val partInfoExtractor: BasicPartInfoExtractor,
private val threadMessageOperations: ThreadMessageOperations
) {
fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
lockableDatabase.execute(true) { database ->
val message = messageData.message
val threadInfo = threadMessageOperations.doMessageThreading(database, folderId, message.toThreadHeaders())
val rootMessagePartId = saveMessageParts(database, message)
val messageId = saveMessage(
database,
folderId,
messageServerId,
rootMessagePartId,
messageData,
emptyMessageId = threadInfo.messageId
)
if (threadInfo.threadId == null) {
threadMessageOperations.createThreadEntry(database, messageId, threadInfo.rootId, threadInfo.parentId)
}
createOrReplaceFulltextEntry(database, messageId, messageData)
}
}
private fun saveMessageParts(database: SQLiteDatabase, message: Message): Long {
val rootPartContainer = PartContainer(parentId = null, part = message)
val rootId = saveMessagePart(database, rootPartContainer, rootId = null, order = 0)
val partsToSave = Stack<PartContainer>()
addChildrenToStack(partsToSave, part = message, parentId = rootId)
var order = 1
while (partsToSave.isNotEmpty()) {
val partContainer = partsToSave.pop()
val messagePartId = saveMessagePart(database, partContainer, rootId, order)
order++
addChildrenToStack(partsToSave, partContainer.part, parentId = messagePartId)
}
return rootId
}
private fun saveMessagePart(
database: SQLiteDatabase,
partContainer: PartContainer,
rootId: Long?,
order: Int
): Long {
val part = partContainer.part
val values = ContentValues().apply {
put("root", rootId)
put("parent", partContainer.parentId ?: -1) // -1 for compatibility with previous code
put("seq", order)
put("server_extra", part.serverExtra)
}
return updateOrInsertMessagePart(database, values, part, existingMessagePartId = null)
}
private fun updateOrInsertMessagePart(
database: SQLiteDatabase,
values: ContentValues,
part: Part,
existingMessagePartId: Long?
): Long {
val headerBytes = getHeaderBytes(part)
values.put("mime_type", part.mimeType)
values.put("header", headerBytes)
values.put("type", MessagePartType.UNKNOWN)
val file: File? = when (val body = part.body) {
is Multipart -> multipartToContentValues(values, body)
is Message -> messageMarkerToContentValues(values)
null -> missingPartToContentValues(values, part)
else -> leafPartToContentValues(values, part, body)
}
val messagePartId = if (existingMessagePartId != null) {
database.update("message_parts", values, "id = ?", arrayOf(existingMessagePartId.toString()))
existingMessagePartId
} else {
database.insertOrThrow("message_parts", null, values)
}
if (file != null) {
attachmentFileManager.moveTemporaryFile(file, messagePartId)
}
return messagePartId
}
private fun multipartToContentValues(values: ContentValues, multipart: Multipart): File? {
values.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
values.put("preamble", multipart.preamble)
values.put("epilogue", multipart.epilogue)
values.put("boundary", multipart.boundary)
return null
}
private fun messageMarkerToContentValues(cv: ContentValues): File? {
cv.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
return null
}
private fun missingPartToContentValues(values: ContentValues, part: Part): File? {
val partInfo = partInfoExtractor.extractPartInfo(part)
values.put("display_name", partInfo.displayName)
values.put("data_location", DataLocation.MISSING)
values.put("decoded_body_size", partInfo.size)
if (MimeUtility.isMultipart(part.mimeType)) {
values.put("boundary", BoundaryGenerator.getInstance().generateBoundary())
}
return null
}
private fun leafPartToContentValues(values: ContentValues, part: Part, body: Body): File? {
val displayName = partInfoExtractor.extractDisplayName(part)
values.put("display_name", displayName)
val encoding = getTransferEncoding(part)
values.put("encoding", encoding)
values.put("content_id", part.contentId)
check(body is SizeAware) { "Body needs to implement SizeAware" }
val sizeAwareBody = body as SizeAware
val fileSize = sizeAwareBody.size
return if (fileSize > MAX_BODY_SIZE_FOR_DATABASE) {
values.put("data_location", DataLocation.ON_DISK)
val file = writeBodyToDiskIfNecessary(part)
val size = decodeAndCountBytes(file, encoding, fileSize)
values.put("decoded_body_size", size)
file
} else {
values.put("data_location", DataLocation.IN_DATABASE)
val bodyData = getBodyBytes(body)
values.put("data", bodyData)
val size = decodeAndCountBytes(bodyData.inputStream(), encoding, bodyData.size.toLong())
values.put("decoded_body_size", size)
null
}
}
private fun writeBodyToDiskIfNecessary(part: Part): File? {
val body = part.body
return if (body is BinaryTempFileBody) {
body.file
} else {
writeBodyToDisk(body)
}
}
private fun writeBodyToDisk(body: Body): File? {
val file = File.createTempFile("body", null, BinaryTempFileBody.getTempDirectory())
FileOutputStream(file).use { outputStream ->
body.writeTo(outputStream)
}
return file
}
private fun decodeAndCountBytes(file: File?, encoding: String, fallbackValue: Long): Long {
return FileInputStream(file).use { inputStream ->
decodeAndCountBytes(inputStream, encoding, fallbackValue)
}
}
private fun decodeAndCountBytes(rawInputStream: InputStream, encoding: String, fallbackValue: Long): Long {
return try {
getDecodingInputStream(rawInputStream, encoding).use { decodingInputStream ->
val countingOutputStream = CountingOutputStream()
IOUtils.copy(decodingInputStream, countingOutputStream)
countingOutputStream.count
}
} catch (e: IOException) {
fallbackValue
}
}
private fun getDecodingInputStream(rawInputStream: InputStream, encoding: String?): InputStream {
return when (encoding) {
MimeUtil.ENC_BASE64 -> {
object : Base64InputStream(rawInputStream) {
override fun close() {
super.close()
rawInputStream.close()
}
}
}
MimeUtil.ENC_QUOTED_PRINTABLE -> {
object : QuotedPrintableInputStream(rawInputStream) {
override fun close() {
super.close()
rawInputStream.close()
}
}
}
else -> {
rawInputStream
}
}
}
private fun getHeaderBytes(part: Part): ByteArray {
val output = ByteArrayOutputStream()
part.writeHeaderTo(output)
return output.toByteArray()
}
private fun getBodyBytes(body: Body): ByteArray {
val output = ByteArrayOutputStream()
body.writeTo(output)
return output.toByteArray()
}
private fun getTransferEncoding(part: Part): String {
val contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING).firstOrNull()
return contentTransferEncoding?.toLowerCase(Locale.ROOT) ?: MimeUtil.ENC_7BIT
}
private fun addChildrenToStack(stack: Stack<PartContainer>, part: Part, parentId: Long) {
when (val body = part.body) {
is Multipart -> {
for (i in body.count - 1 downTo 0) {
val childPart = body.getBodyPart(i)
stack.push(PartContainer(parentId, childPart))
}
}
is Message -> {
stack.push(PartContainer(parentId, body))
}
}
}
private fun saveMessage(
database: SQLiteDatabase,
folderId: Long,
messageServerId: String,
rootMessagePartId: Long,
messageData: SaveMessageData,
emptyMessageId: Long?
): Long {
val message = messageData.message
if (messageData.partialMessage) {
message.setFlag(Flag.X_DOWNLOADED_PARTIAL, true)
} else {
message.setFlag(Flag.X_DOWNLOADED_FULL, true)
}
val values = ContentValues().apply {
put("folder_id", folderId)
put("uid", messageServerId)
put("deleted", 0)
put("empty", 0)
put("message_part_id", rootMessagePartId)
put("date", messageData.date)
put("internal_date", messageData.internalDate)
put("subject", messageData.subject)
put("flags", message.flags.toDatabaseValue())
put("read", message.isSet(Flag.SEEN).toDatabaseValue())
put("flagged", message.isSet(Flag.FLAGGED).toDatabaseValue())
put("answered", message.isSet(Flag.ANSWERED).toDatabaseValue())
put("forwarded", message.isSet(Flag.FORWARDED).toDatabaseValue())
put("sender_list", Address.pack(message.from))
put("to_list", Address.pack(message.getRecipients(RecipientType.TO)))
put("cc_list", Address.pack(message.getRecipients(RecipientType.CC)))
put("bcc_list", Address.pack(message.getRecipients(RecipientType.BCC)))
put("reply_to_list", Address.pack(message.replyTo))
put("attachment_count", messageData.attachmentCount)
put("message_id", message.messageId)
put("mime_type", message.mimeType)
put("encryption_type", messageData.encryptionType)
val previewResult = messageData.previewResult
put("preview_type", previewResult.previewType.toDatabaseValue())
if (previewResult.isPreviewTextAvailable) {
put("preview", previewResult.previewText)
} else {
putNull("preview")
}
}
return if (emptyMessageId != null) {
values.put("id", emptyMessageId)
database.replace("messages", null, values)
emptyMessageId
} else {
database.insert("messages", null, values)
}
}
private fun createOrReplaceFulltextEntry(database: SQLiteDatabase, messageId: Long, messageData: SaveMessageData) {
val fulltext = messageData.textForSearchIndex ?: return
val values = ContentValues().apply {
put("docid", messageId)
put("fulltext", fulltext)
}
database.replace("messages_fulltext", null, values)
}
}
private fun Set<Flag>.toDatabaseValue(): String {
return this
.filter { it !in SPECIAL_FLAGS }
.joinToString(separator = ",")
}
private fun Boolean.toDatabaseValue() = if (this) 1 else 0
private fun PreviewType.toDatabaseValue(): String {
return DatabasePreviewType.fromPreviewType(this).databaseValue
}
// Note: The contents of the 'message_parts' table depend on these values.
// TODO: currently unused, might be used for caching at a later point
internal object MessagePartType {
const val UNKNOWN = 0
}
// Note: The contents of the 'message_parts' table depend on these values.
internal object DataLocation {
const val MISSING = 0
const val IN_DATABASE = 1
const val ON_DISK = 2
const val CHILD_PART_CONTAINS_DATA = 3
}
private data class PartContainer(val parentId: Long?, val part: Part)

View file

@ -3,6 +3,7 @@ package com.fsck.k9.storage.messages
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.helper.Utility
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.message.MessageHeaderParser
import java.util.Locale
@ -59,7 +60,7 @@ internal class ThreadMessageOperations {
}
}
private fun createThreadEntry(database: SQLiteDatabase, messageId: Long, rootId: Long?, parentId: Long?): Long {
fun createThreadEntry(database: SQLiteDatabase, messageId: Long, rootId: Long?, parentId: Long?): Long {
val values = ContentValues().apply {
put("message_id", messageId)
put("root", rootId)
@ -70,7 +71,7 @@ internal class ThreadMessageOperations {
}
// TODO: Use MessageIdParser
private fun doMessageThreading(database: SQLiteDatabase, folderId: Long, threadHeaders: ThreadHeaders): ThreadInfo {
fun doMessageThreading(database: SQLiteDatabase, folderId: Long, threadHeaders: ThreadHeaders): ThreadInfo {
val messageIdHeader = threadHeaders.messageIdHeader
val msgThreadInfo = getThreadInfo(database, folderId, messageIdHeader, onlyEmpty = true)
@ -201,3 +202,11 @@ internal data class ThreadHeaders(
val inReplyToHeader: String?,
val referencesHeader: String?
)
internal fun Message.toThreadHeaders(): ThreadHeaders {
return ThreadHeaders(
messageIdHeader = messageId,
inReplyToHeader = getHeader("In-Reply-To").firstOrNull(),
referencesHeader = getHeader("References").firstOrNull()
)
}

View file

@ -40,7 +40,7 @@ fun createDatabase(): SQLiteDatabase {
fun SQLiteDatabase.createMessage(
deleted: Boolean = false,
folderId: Long,
uid: String,
uid: String? = null,
subject: String = "",
date: Long = 0L,
flags: String = "",

View file

@ -0,0 +1,104 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.helper.getIntOrNull
import com.fsck.k9.helper.getLongOrNull
import com.fsck.k9.helper.getStringOrNull
import com.fsck.k9.helper.map
fun SQLiteDatabase.createMessagePart(
type: Int = MessagePartType.UNKNOWN,
root: Int? = null,
parent: Int = -1,
seq: Int = 0,
mimeType: String? = null,
decodedBodySize: Int? = null,
displayName: String? = null,
header: String? = null,
encoding: String? = null,
charset: String? = null,
dataLocation: Int = DataLocation.MISSING,
data: ByteArray? = null,
preamble: String? = null,
epilogue: String? = null,
boundary: String? = null,
contentId: String? = null,
serverExtra: String? = null
): Long {
val values = ContentValues().apply {
put("type", type)
put("root", root)
put("parent", parent)
put("seq", seq)
put("mime_type", mimeType)
put("decoded_body_size", decodedBodySize)
put("display_name", displayName)
put("header", header)
put("encoding", encoding)
put("charset", charset)
put("data_location", dataLocation)
put("data", data)
put("preamble", preamble)
put("epilogue", epilogue)
put("boundary", boundary)
put("content_id", contentId)
put("server_extra", serverExtra)
}
return insert("message_parts", null, values)
}
fun SQLiteDatabase.readMessageParts(): List<MessagePartEntry> {
return rawQuery("SELECT * FROM message_parts", null).use { cursor ->
cursor.map {
MessagePartEntry(
id = cursor.getLongOrNull("id"),
type = cursor.getIntOrNull("type"),
root = cursor.getLongOrNull("root"),
parent = cursor.getLongOrNull("parent"),
seq = cursor.getIntOrNull("seq"),
mimeType = cursor.getStringOrNull("mime_type"),
decodedBodySize = cursor.getIntOrNull("decoded_body_size"),
displayName = cursor.getStringOrNull("display_name"),
header = cursor.getBlobOrNull("header"),
encoding = cursor.getStringOrNull("encoding"),
charset = cursor.getStringOrNull("charset"),
dataLocation = cursor.getIntOrNull("data_location"),
data = cursor.getBlobOrNull("data"),
preamble = cursor.getStringOrNull("preamble"),
epilogue = cursor.getStringOrNull("epilogue"),
boundary = cursor.getStringOrNull("boundary"),
contentId = cursor.getStringOrNull("content_id"),
serverExtra = cursor.getStringOrNull("server_extra"),
)
}
}
}
class MessagePartEntry(
val id: Long?,
val type: Int?,
val root: Long?,
val parent: Long?,
val seq: Int?,
val mimeType: String?,
val decodedBodySize: Int?,
val displayName: String?,
val header: ByteArray?,
val encoding: String?,
val charset: String?,
val dataLocation: Int?,
val data: ByteArray?,
val preamble: String?,
val epilogue: String?,
val boundary: String?,
val contentId: String?,
val serverExtra: String?
)
private fun Cursor.getBlobOrNull(columnName: String): ByteArray? {
val columnIndex = getColumnIndex(columnName)
return if (isNull(columnIndex)) null else getBlob(columnIndex)
}

View file

@ -0,0 +1,355 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.mail.Address
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Multipart
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.buildMessage
import com.fsck.k9.mailstore.SaveMessageData
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
import com.fsck.k9.message.extractors.PreviewResult
import com.fsck.k9.storage.RobolectricTest
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.anyOrNull
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.mock
import java.io.ByteArrayOutputStream
import java.util.Stack
import org.junit.After
import org.junit.Test
private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000"
class SaveMessageOperationsTest : RobolectricTest() {
private val messagePartDirectory = createRandomTempDirectory()
private val sqliteDatabase = createDatabase()
private val storageManager = mock<StorageManager> {
on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory
}
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID)
private val basicPartInfoExtractor = BasicPartInfoExtractor()
private val threadMessageOperations = ThreadMessageOperations()
private val saveMessageOperations = SaveMessageOperations(
lockableDatabase,
attachmentFileManager,
basicPartInfoExtractor,
threadMessageOperations
)
@After
fun tearDown() {
messagePartDirectory.deleteRecursively()
}
@Test
fun `save message with text_plain body`() {
val messageData = buildMessage {
header("Subject", "Test Message")
header("From", "alice@domain.example")
header("To", "Bob <bob@domain.example>")
header("Cc", "<cloe@domain.example>")
header("Date", "Mon, 12 Apr 2021 03:42:00 +0200")
header("Message-ID", "<msg0001@domain.example>")
textBody("Text")
}.apply {
setFlag(Flag.FLAGGED, true)
setFlag(Flag.SEEN, true)
setFlag(Flag.ANSWERED, true)
setFlag(Flag.FORWARDED, true)
}.toSaveMessageData(
previewResult = PreviewResult.text("Preview")
)
saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
val messages = sqliteDatabase.readMessages()
assertThat(messages).hasSize(1)
val message = messages.first()
with(message) {
assertThat(deleted).isEqualTo(0)
assertThat(folderId).isEqualTo(1)
assertThat(uid).isEqualTo("uid1")
assertThat(subject).isEqualTo("Test Message")
assertThat(date).isEqualTo(1618191720000L)
assertThat(internalDate).isEqualTo(1618191720000L)
assertThat(flags).isEqualTo("X_DOWNLOADED_FULL")
assertThat(senderList).isEqualTo("alice@domain.example")
assertThat(toList).isEqualTo(Address.pack(Address.parse("Bob <bob@domain.example>")))
assertThat(ccList).isEqualTo("cloe@domain.example")
assertThat(bccList).isEqualTo("")
assertThat(replyToList).isEqualTo("")
assertThat(attachmentCount).isEqualTo(0)
assertThat(messageId).isEqualTo("<msg0001@domain.example>")
assertThat(previewType).isEqualTo("text")
assertThat(preview).isEqualTo("Preview")
assertThat(mimeType).isEqualTo("text/plain")
assertThat(empty).isEqualTo(0)
assertThat(read).isEqualTo(1)
assertThat(flagged).isEqualTo(1)
assertThat(answered).isEqualTo(1)
assertThat(forwarded).isEqualTo(1)
assertThat(encryptionType).isNull()
}
val messageParts = sqliteDatabase.readMessageParts()
assertThat(messageParts).hasSize(1)
val messagePart = messageParts.first()
with(messagePart) {
assertThat(type).isEqualTo(MessagePartType.UNKNOWN)
assertThat(root).isEqualTo(messagePart.id)
assertThat(parent).isEqualTo(-1)
assertThat(seq).isEqualTo(0)
assertThat(mimeType).isEqualTo("text/plain")
assertThat(displayName).isEqualTo("noname.txt")
assertThat(header?.toString(Charsets.UTF_8)).isEqualTo(messageData.message.header())
assertThat(encoding).isEqualTo("quoted-printable")
assertThat(charset).isNull()
assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE)
assertThat(decodedBodySize).isEqualTo(4)
assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("Text")
assertThat(preamble).isNull()
assertThat(epilogue).isNull()
assertThat(boundary).isNull()
assertThat(contentId).isNull()
assertThat(serverExtra).isNull()
}
val threads = sqliteDatabase.readThreads()
assertThat(threads).hasSize(1)
val thread = threads.first()
with(thread) {
assertThat(messageId).isEqualTo(message.id)
assertThat(root).isEqualTo(id)
assertThat(parent).isNull()
}
}
@Test
fun `save message with multipart body`() {
val messageData = buildMessage {
multipart("alternative") {
bodyPart("text/plain") {
textBody("plain")
}
bodyPart("text/html") {
textBody("html")
}
}
}.toSaveMessageData()
saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
val messages = sqliteDatabase.readMessages()
assertThat(messages).hasSize(1)
val messageParts = sqliteDatabase.readMessageParts()
assertThat(messageParts).hasSize(3)
val rootMessagePart = messageParts.first { it.seq == 0 }
with(rootMessagePart) {
assertThat(type).isEqualTo(MessagePartType.UNKNOWN)
assertThat(root).isEqualTo(id)
assertThat(parent).isEqualTo(-1)
assertThat(mimeType).isEqualTo("multipart/alternative")
assertThat(displayName).isNull()
assertThat(header?.toString(Charsets.UTF_8)).isEqualTo(messageData.message.header())
assertThat(encoding).isNull()
assertThat(charset).isNull()
assertThat(dataLocation).isEqualTo(DataLocation.CHILD_PART_CONTAINS_DATA)
assertThat(decodedBodySize).isNull()
assertThat(data).isNull()
assertThat(preamble).isNull()
assertThat(epilogue).isNull()
assertThat(boundary).isEqualTo(messageData.message.boundary())
assertThat(contentId).isNull()
assertThat(serverExtra).isNull()
}
val textPlainMessagePart = messageParts.first { it.seq == 1 }
with(textPlainMessagePart) {
assertThat(type).isEqualTo(MessagePartType.UNKNOWN)
assertThat(root).isEqualTo(rootMessagePart.id)
assertThat(parent).isEqualTo(rootMessagePart.id)
assertThat(mimeType).isEqualTo("text/plain")
assertThat(displayName).isEqualTo("noname.txt")
assertThat(header).isNotNull()
assertThat(encoding).isEqualTo("quoted-printable")
assertThat(charset).isNull()
assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE)
assertThat(decodedBodySize).isEqualTo(5)
assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("plain")
assertThat(preamble).isNull()
assertThat(epilogue).isNull()
assertThat(boundary).isNull()
assertThat(contentId).isNull()
assertThat(serverExtra).isNull()
}
val textHtmlMessagePart = messageParts.first { it.seq == 2 }
with(textHtmlMessagePart) {
assertThat(type).isEqualTo(MessagePartType.UNKNOWN)
assertThat(root).isEqualTo(rootMessagePart.id)
assertThat(parent).isEqualTo(rootMessagePart.id)
assertThat(mimeType).isEqualTo("text/html")
assertThat(displayName).isEqualTo("noname.html")
assertThat(header).isNotNull()
assertThat(encoding).isEqualTo("quoted-printable")
assertThat(charset).isNull()
assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE)
assertThat(decodedBodySize).isEqualTo(4)
assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("html")
assertThat(preamble).isNull()
assertThat(epilogue).isNull()
assertThat(boundary).isNull()
assertThat(contentId).isNull()
assertThat(serverExtra).isNull()
}
}
@Test
fun `save message into existing thread`() {
val messageId1 = sqliteDatabase.createMessage(
folderId = 1,
empty = true,
messageIdHeader = "<msg0001@domain.example>"
)
val messageId2 = sqliteDatabase.createMessage(
folderId = 1,
empty = true,
messageIdHeader = "<msg0002@domain.example>"
)
val messageId3 = sqliteDatabase.createMessage(
folderId = 1,
empty = false,
messageIdHeader = "<msg0003@domain.example>"
)
val threadId1 = sqliteDatabase.createThread(messageId1)
val threadId2 = sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1)
val threadId3 = sqliteDatabase.createThread(messageId3, root = threadId1, parent = threadId2)
val messageData = buildMessage {
header("Message-ID", "<msg0002@domain.example>")
header("In-Reply-To", "<msg0001@domain.example>")
textBody()
}.toSaveMessageData()
saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
val threads = sqliteDatabase.readThreads()
assertThat(threads).hasSize(3)
assertThat(threads.first { it.id == threadId1 }).isEqualTo(
ThreadEntry(
id = threadId1,
messageId = messageId1,
root = threadId1,
parent = null
)
)
assertThat(threads.first { it.id == threadId2 }).isEqualTo(
ThreadEntry(
id = threadId2,
messageId = messageId2,
root = threadId1,
parent = threadId1
)
)
assertThat(threads.first { it.id == threadId3 }).isEqualTo(
ThreadEntry(
id = threadId3,
messageId = messageId3,
root = threadId1,
parent = threadId2
)
)
}
@Test
fun `save message with references header should create empty messages`() {
val messageData = buildMessage {
header("Message-ID", "<msg0003@domain.example>")
header("In-Reply-To", "<msg0002@domain.example>")
header("References", "<msg0001@domain.example> <msg0002@domain.example>")
textBody()
}.toSaveMessageData()
saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData)
val messages = sqliteDatabase.readMessages()
assertThat(messages).hasSize(3)
val threads = sqliteDatabase.readThreads()
assertThat(threads).hasSize(3)
val thread1 = threads.first { it.id == it.root }
val message1 = messages.first { it.id == thread1.messageId }
assertThat(message1.empty).isEqualTo(1)
val thread2 = threads.first { it.parent == thread1.id }
val message2 = messages.first { it.id == thread2.messageId }
assertThat(message2.empty).isEqualTo(1)
val thread3 = threads.first { it.parent == thread2.id }
val message3 = messages.first { it.id == thread3.messageId }
assertThat(message3.empty).isEqualTo(0)
assertThat(message3.uid).isEqualTo("uid1")
}
private fun Message.toSaveMessageData(
subject: String? = getSubject(),
date: Long = sentDate?.time ?: System.currentTimeMillis(),
internalDate: Long = date,
partialMessage: Boolean = isPartialMessage(),
attachmentCount: Int = 0,
previewResult: PreviewResult = PreviewResult.none(),
textForSearchIndex: String? = null,
encryptionType: String? = null
): SaveMessageData {
return SaveMessageData(
message = this,
subject,
date,
internalDate,
partialMessage,
attachmentCount,
previewResult,
textForSearchIndex,
encryptionType
)
}
private fun Message.isPartialMessage(): Boolean {
val stack = Stack<Part>()
stack.push(this)
while (stack.isNotEmpty()) {
val part = stack.pop()
when (val body = part.body) {
null -> return true
is Multipart -> {
for (i in 0 until body.count) {
stack.push(body.getBodyPart(i))
}
}
}
}
return false
}
private fun Message.header(): String {
val outputStream = ByteArrayOutputStream()
writeHeaderTo(outputStream)
return outputStream.toString("UTF-8")
}
private fun Message.boundary(): String? = (body as Multipart).boundary
}

View file

@ -0,0 +1,65 @@
package com.fsck.k9.mail
import com.fsck.k9.mail.internet.MimeBodyPart
import com.fsck.k9.mail.internet.MimeMessage
import com.fsck.k9.mail.internet.MimeMessageHelper
import com.fsck.k9.mail.internet.MimeMultipart
import com.fsck.k9.mail.internet.TextBody
import com.fsck.k9.mailstore.BinaryMemoryBody
fun buildMessage(block: PartBuilder.() -> Unit): Message {
return MimeMessage().also { message ->
PartBuilder(message).block()
}
}
@DslMarker
annotation class MessageBuilderMarker
@MessageBuilderMarker
class PartBuilder(private val part: Part) {
private var gotBodyBlock = false
fun header(name: String, value: String) {
part.addHeader(name, value)
}
fun textBody(text: String = "Hello World") {
require(!gotBodyBlock) { "Only one body block allowed" }
gotBodyBlock = true
val body = TextBody(text)
MimeMessageHelper.setBody(part, body)
}
fun dataBody(size: Int = 20 * 1024, encoding: String = "7bit") {
require(!gotBodyBlock) { "Only one body block allowed" }
gotBodyBlock = true
val body = BinaryMemoryBody(ByteArray(size) { 'A'.toByte() }, encoding)
MimeMessageHelper.setBody(part, body)
}
fun multipart(subType: String = "mixed", block: MultipartBuilder.() -> Unit) {
require(!gotBodyBlock) { "Only one body block allowed" }
gotBodyBlock = true
val multipart = MimeMultipart.newInstance()
multipart.setSubType(subType)
MultipartBuilder(multipart).block()
MimeMessageHelper.setBody(part, multipart)
}
}
@MessageBuilderMarker
class MultipartBuilder(private val multipart: Multipart) {
fun bodyPart(mimeType: String? = null, block: PartBuilder.() -> Unit) {
MimeBodyPart().let { bodyPart ->
if (mimeType != null) {
bodyPart.addHeader("Content-Type", mimeType)
}
multipart.addBodyPart(bodyPart)
PartBuilder(bodyPart).block()
}
}
}